From 70b0e90b95585caf53d3dbc999e59cf1f72d92d0 Mon Sep 17 00:00:00 2001 From: Pfriedrix Date: Fri, 7 Aug 2026 16:49:42 +0300 Subject: [PATCH 1/2] chore: remove every comment from the Zig sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4,422 comment lines across build.zig and src/, stripped by a scan that knows where a `//` is not a comment — inside a string, inside a char literal, or on a `\\` multiline-string line, which is where all 21 URLs in the tree live. All of them survived; test count is unchanged at 266, so no module dropped out of the test root's import list. CLAUDE.md's Style section said these comments were the only rationale record in the repo, and it was right, so it now says where a "why" goes instead: the commit message, its own Traps section, or README. The traps worth losing sleep over — keychain's five narrow C headers, build.zig's signing, the test-root import list — were already written down there. YAML and Markdown are untouched; the Zig version pin still carries its "must match README" note in ci.yml. --- CLAUDE.md | 17 +- build.zig | 25 --- src/ansi.zig | 53 ------ src/app.zig | 14 -- src/claude.zig | 7 - src/claude_projects.zig | 81 +-------- src/commands/auth.zig | 5 - src/commands/clean.zig | 9 - src/commands/config.zig | 92 ---------- src/commands/daemon.zig | 14 -- src/commands/issue.zig | 181 -------------------- src/commands/list.zig | 73 -------- src/commands/open.zig | 5 - src/commands/remove.zig | 96 +---------- src/commands/setup.zig | 7 - src/commands/start.zig | 207 +--------------------- src/commands/start_plan_test.zig | 18 -- src/commands/stats.zig | 48 ------ src/commands/watch.zig | 175 ------------------- src/config.zig | 61 ------- src/daemon.zig | 285 ------------------------------- src/derived_data.zig | 28 +-- src/disk.zig | 20 --- src/exec.zig | 39 ----- src/fold.zig | 25 --- src/git.zig | 207 ++-------------------- src/github.zig | 71 +------- src/keychain.zig | 26 --- src/linear.zig | 279 +----------------------------- src/link.zig | 38 ----- src/main.zig | 48 ------ src/mcp.zig | 58 ------- src/oauth.zig | 19 --- src/prompt.zig | 52 +----- src/pty.zig | 155 +---------------- src/release.zig | 162 ------------------ src/remote_cache.zig | 97 +---------- src/repos.zig | 53 ------ src/ring.zig | 70 +------- src/semver.zig | 41 ----- src/sessions.zig | 149 ---------------- src/term.zig | 231 +++++++------------------ src/ui.zig | 38 ----- src/usage.zig | 222 +----------------------- src/usage_cache.zig | 79 +-------- src/watch_attach.zig | 118 ------------- src/watch_client.zig | 60 ------- src/watch_hooks.zig | 174 +------------------ src/watch_paths.zig | 50 ------ src/watch_session.zig | 128 -------------- src/watch_status.zig | 114 ------------- src/watch_table.zig | 105 +----------- src/wire.zig | 143 +--------------- src/xcode.zig | 64 +------ 54 files changed, 121 insertions(+), 4515 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88c186c..52c4eb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,9 +119,18 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo ## Style -Comments here explain **why**, never what: a `//!` header stating what the module is for, -`///` on public declarations, and inline notes that name the bug a line prevents or the -constraint that forced a shape. Keep them and keep them accurate — they are the only -rationale record this repo has. Do not add ones that restate the code. +**The Zig sources carry no comments.** No `//!` module headers, no `///` on declarations, +no inline notes. They were all removed deliberately; do not reintroduce them, and do not +add one to explain a change you are making. + +That leaves three places for a "why", and something has to go in one of them or it is lost: +the commit message, this file's **Traps** section (for anything that would bite the next +person editing the file), or README (for anything a *user* of `lcc` would want). Reach for +`git log -p` and `git blame` when a line looks arbitrary — that is now the rationale record. + +Test names carry the rest. A `test "…"` string is the one place left where a constraint is +stated in words, so make it a sentence about the behaviour and not a label for the +function: `test "a turn that went silent asks for a person, rather than claiming it +finished"` survives the loss of its comment; `test "decay"` would not. Default branch is `master`. Branch prefixes: `feature/`, `fix/`, `docs/`, `chore/`. diff --git a/build.zig b/build.zig index 21e8af8..c68be1a 100644 --- a/build.zig +++ b/build.zig @@ -10,8 +10,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - // Keychain access goes through the modern SecItem* API, which speaks - // CoreFoundation types. mod.linkFramework("CoreFoundation", .{}); mod.linkFramework("Security", .{}); @@ -20,13 +18,6 @@ pub fn build(b: *std.Build) void { .root_module = mod, }); - // The Keychain decides who may read the Linear token from the program's code - // signature, and Zig's linker only ad-hoc signs — an identity that is nothing - // but the hash of the binary. So every rebuild arrives at the Keychain as a - // *changed* program, which re-asks for the login password and blocks until it is - // answered. Signing with a real certificate, self-signed included, pins the - // designated requirement to `identifier "lcc"` plus that certificate, and one - // "Always Allow" then survives every later build. const install_exe = b.addInstallArtifact(exe, .{}); b.getInstallStep().dependOn(&install_exe.step); if (signIdentity(b)) |identity| { @@ -50,8 +41,6 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "Run lcc"); run_step.dependOn(&run_cmd.step); - // A module of its own: reusing the executable's module made `zig build - // test` reuse the executable's compilation and silently skip the tests. const test_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, @@ -67,15 +56,6 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_tests.step); } -/// Which certificate to sign the installed binary with. Nothing has to be set up for -/// this: whatever the machine already has for code signing is what gets used, since -/// *which* certificate it is does not matter — only that it stays the same between -/// builds. `lcc-dev` first for anyone who made one on purpose, then an Apple -/// Development certificate, which a machine that builds apps already has. -/// -/// `-Dsign=` overrides, `-Dsign=none` opts out, `LCC_CODESIGN_IDENTITY` -/// does the same from the environment. Finding nothing is not an error — the build -/// then leaves the linker's ad-hoc signature alone. fn signIdentity(b: *std.Build) ?[]const u8 { const option = b.option( []const u8, @@ -101,17 +81,12 @@ fn signIdentity(b: *std.Build) ?[]const u8 { for ([_][]const u8{ "lcc-dev", "Apple Development:" }) |preferred| { const found = commonNameContaining(identities, preferred) orelse continue; - // Named out loud rather than picked silently: the signature is what the - // Keychain recognises lcc by, so it should never be a surprise. std.log.info("signing lcc with \"{s}\"", .{found}); return found; } return null; } -/// The certificate name out of `security find-identity` output — the quoted common -/// name on the first line mentioning `needle`. Lines look like: -/// ` 1) A1B2C3… "Apple Development: Someone (TEAMID)"`. fn commonNameContaining(identities: []const u8, needle: []const u8) ?[]const u8 { var lines = std.mem.splitScalar(u8, identities, '\n'); while (lines.next()) |line| { diff --git a/src/ansi.zig b/src/ansi.zig index 4850937..b67226d 100644 --- a/src/ansi.zig +++ b/src/ansi.zig @@ -1,43 +1,12 @@ -//! Just enough escape-sequence parsing to strip terminal configuration out of -//! replayed scrollback. -//! -//! Explicitly **not** a terminal emulator, and the temptation to grow one is -//! the failure mode here. It once also watched the child's output to decide -//! when to reassert a scroll region and when the terminal's saved-cursor slot -//! was free — both for a status bar that no longer exists, because doing that -//! well *is* emulating a terminal. What is left answers one question: is this -//! sequence configuration or drawing? -//! -//! It has to be an object rather than a function because a sequence can be -//! split across two reads, at whatever boundary the ring happened to wrap on. - const std = @import("std"); -/// Drops the sequences that configure a terminal, keeping the ones that draw. -/// -/// For replayed scrollback only. A replay exists to repaint a screen, and the -/// bytes in it are whatever the child once wrote — including, at the very -/// start, its terminal setup: `CSI > 1 u` pushes kitty keyboard flags, -/// `CSI > 4 ; 2 m` turns on modifyOtherKeys, `CSI ? 2004 h` turns on bracketed -/// paste. Replaying those does not repaint anything. It pushes a *second* level -/// onto the terminal's keyboard stack, and from then on the child and the -/// terminal disagree about how keys are encoded: the child sends its picker a -/// `\r` it never receives, while the mouse — a separate protocol — keeps -/// working. That is the shape of the bug this exists to prevent. -/// -/// SGR is deliberately not touched. `CSI 31 m` is colour, which is content; -/// only the private forms (`>`, `<`, `?`, `=`) configure. pub const ModeFilter = struct { const State = enum { text, esc, csi }; state: State = .text, - /// The sequence so far, held back until its final byte says whether it is - /// content or configuration. Bounded: anything longer is not one of ours. pending: [64]u8 = undefined, len: usize = 0, - /// Filters `chunk` into `out`, which must be at least `chunk.len + 64`. - /// Returns the bytes to write. pub fn filter(self: *ModeFilter, chunk: []const u8, out: []u8) []const u8 { var n: usize = 0; for (chunk) |byte| { @@ -55,8 +24,6 @@ pub const ModeFilter = struct { if (byte == '[') { self.state = .csi; } else { - // Two-byte escapes are content (cursor save/restore, - // charset). Put back what was held. out[n] = 0x1b; n += 1; out[n] = byte; @@ -87,24 +54,14 @@ pub const ModeFilter = struct { } }; -/// Whether a CSI body (everything after `ESC [`) sets terminal state rather -/// than drawing. fn configures(body: []const u8) bool { if (body.len == 0) return false; const final = body[body.len - 1]; const private = body[0] == '?' or body[0] == '>' or body[0] == '<' or body[0] == '='; return switch (final) { - // Private mode set/reset: alt screen, bracketed paste, focus - // reporting, mouse tracking, colour-scheme notifications. 'h', 'l' => private, - // Keyboard protocol push, pop and query. 'u' => private, - // `CSI > 4 ; 2 m` is modifyOtherKeys; a bare `CSI 31 m` is colour. 'm' => private, - // DECSTBM. A scroll region is state, not drawing, and scrollback holds - // every one the child ever set — so replaying them lands on whichever - // was last in the ring rather than the one actually in force, which - // would wedge the live screen into a stale subregion. 'r' => true, else => false, }; @@ -116,10 +73,6 @@ test "a replay keeps what draws and drops what configures" { var f: ModeFilter = .{}; var out: [512]u8 = undefined; - // The exact opening Claude Code writes, measured from a real capture. The - // keyboard-protocol pushes in it are what a replay must not repeat: a - // second push leaves the terminal a level above where the child thinks it - // is, and its picker then never sees the Enter it is waiting for. const startup = "\x1b7\x1b[r\x1b8\x1b[?25h\x1b[?25l\x1b[?2004h\x1b[?1004h" ++ "\x1b[?2031h\x1b[1u\x1b[>4;2m\x1b[?2026h"; const kept = f.filter(startup, &out); @@ -127,7 +80,6 @@ test "a replay keeps what draws and drops what configures" { for ([_][]const u8{ "\x1b[>1u", "\x1b[4;2m", "\x1b[?2004h", "\x1b[?1004h", "\x1b[?2031h", "\x1b[r" }) |mode| { try testing.expect(std.mem.indexOf(u8, kept, mode) == null); } - // Cursor save and restore are content: they position what follows. try testing.expect(std.mem.indexOf(u8, kept, "\x1b7") != null); try testing.expect(std.mem.indexOf(u8, kept, "\x1b8") != null); } @@ -135,8 +87,6 @@ test "a replay keeps what draws and drops what configures" { test "colour and cursor movement survive the filter untouched" { var f: ModeFilter = .{}; var out: [512]u8 = undefined; - // Everything a repaint is actually made of. Dropping any of it would make - // the replay worse than not replaying at all. const drawing = "\x1b[38;2;255;193;7mhello\x1b[39m\x1b[2K\x1b[12G\x1b[4A plain text\n"; try testing.expectEqualStrings(drawing, f.filter(drawing, &out)); } @@ -144,9 +94,6 @@ test "colour and cursor movement survive the filter untouched" { test "a mode sequence split across two frames is still dropped" { var f: ModeFilter = .{}; var out: [256]u8 = undefined; - // The ring hands out slices at whatever boundary it wrapped on, so half a - // sequence in one frame and half in the next is ordinary. try testing.expectEqualStrings("a", f.filter("a\x1b[>1", &out)); try testing.expectEqualStrings("b", f.filter("ub", &out)); } - diff --git a/src/app.zig b/src/app.zig index d0c1239..67a8b04 100644 --- a/src/app.zig +++ b/src/app.zig @@ -1,5 +1,3 @@ -//! Shared context handed to every command, plus the bits two commands need. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); @@ -17,18 +15,12 @@ pub const App = struct { return self.repoAt(null); } - /// The repository containing `cwd`, for `--repo`. The path is carried into - /// `Repo.cwd` as well: once a caller has named a repository, "the current - /// branch" means the one checked out *there*, not in the directory lcc - /// happens to have been run from. pub fn repoAt(self: App, cwd: ?[]const u8) !git.Repo { const root = try git.repoRoot(self.gpa, self.io, cwd); return .{ .gpa = self.gpa, .io = self.io, .root = root, .cwd = cwd }; } }; -/// Cancelling a prompt exits 130, the conventional SIGINT status — the same -/// thing the TypeScript version did with inquirer's ExitPromptError. pub const cancelled_exit_code: u8 = 130; pub const Choice = struct { @@ -36,19 +28,15 @@ pub const Choice = struct { managed: bool, }; -/// The directory every worktree lcc creates for this repo sits under, per the -/// configured template. pub fn managedPrefix(app: App, repo: git.Repo) ![]const u8 { const cfg = try config.load(app.gpa, app.io, app.environ); return git.worktreePathPrefix(app.gpa, cfg.worktreeTemplate, repo.root); } -/// An empty prefix matches everything, which would tag every worktree. pub fn isManaged(prefix: []const u8, path: []const u8) bool { return prefix.len > 0 and std.mem.startsWith(u8, path, prefix); } -/// Worktrees other than the main one, tagged with whether lcc created them. pub fn worktreeChoices(app: App, repo: git.Repo) ![]Choice { const entries = try repo.listWorktrees(); const prefix = try managedPrefix(app, repo); @@ -64,7 +52,6 @@ pub fn worktreeChoices(app: App, repo: git.Repo) ![]Choice { return out.toOwnedSlice(app.gpa); } -/// Wall-clock seconds since the epoch, for the columns that render an age. pub fn nowSeconds(io: Io) i64 { const ts = Io.Timestamp.now(io, .real); return @intCast(@divTrunc(ts.nanoseconds, std.time.ns_per_s)); @@ -95,7 +82,6 @@ pub fn shortHead(head: []const u8) []const u8 { return head[0..@min(8, head.len)]; } -/// The picker used by both `lcc open` and `lcc remove`. pub fn pickWorktree(app: App, choices: []const Choice, message: []const u8) !?Choice { const items = try app.gpa.alloc(prompt.Item, choices.len); for (choices, 0..) |choice, i| { diff --git a/src/claude.zig b/src/claude.zig index 21bef30..1b35d44 100644 --- a/src/claude.zig +++ b/src/claude.zig @@ -1,21 +1,14 @@ -//! Launching Claude Code in a worktree. - const std = @import("std"); const Io = std.Io; const exec = @import("exec.zig"); pub const Error = error{ClaudeNotFound} || std.mem.Allocator.Error; -/// The absolute `claude` binary. -/// -/// Public because the watch path needs it *before* forking: a PATH search must -/// not happen on the child side of a fork, where almost nothing is safe to call. pub fn resolvePath(gpa: std.mem.Allocator, io: Io) Error![]u8 { return exec.capture(gpa, io, &.{ "which", "claude" }, null) catch return Error.ClaudeNotFound; } -/// Hands the terminal to Claude Code and returns its exit status. pub fn launch( gpa: std.mem.Allocator, io: Io, diff --git a/src/claude_projects.zig b/src/claude_projects.zig index 1dd3954..4fed8ba 100644 --- a/src/claude_projects.zig +++ b/src/claude_projects.zig @@ -1,42 +1,23 @@ -//! Claude Code session transcripts under `~/.claude/projects`, and reclaiming -//! the ones whose working directory is gone. -//! -//! Claude Code keys each directory on the cwd it was launched in, flattened into -//! a single name. That flattening is lossy — `/` and `.` both become `-`, so -//! `LocationTracker3.0.worktrees/pe-224` and `LocationTracker3-0-worktrees-pe-224` -//! are indistinguishable afterwards. Rather than guess at the inverse, lcc reads -//! the `cwd` field out of a transcript: the sessions record the real path. - const std = @import("std"); const Io = std.Io; const disk = @import("disk.zig"); pub const Entry = struct { - /// Absolute path of the directory, e.g. `~/.claude/projects/-Users-me-…`. path: []const u8, - /// Directory name — the flattened cwd Claude Code derived it from. name: []const u8, - /// The directory Claude Code was launched in, read from a transcript. cwd: []const u8, - /// How many `.jsonl` transcripts the directory holds. sessions: usize, }; pub const Sized = struct { entry: Entry, - /// Disk usage in bytes. size: u64, }; pub const Error = error{RefusingToDelete} || std.mem.Allocator.Error; -/// A transcript records its cwd in the first user message, which in practice -/// lands within the first few KB. Reading a bounded prefix keeps the scan cheap -/// even when a transcript has grown to tens of MB. const prefix_limit = 64 * 1024; -/// Where Claude Code keeps its per-project session state. -/// `LCC_CLAUDE_PROJECTS` overrides it. pub fn root( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -57,9 +38,6 @@ pub fn root( return std.fs.path.join(gpa, &.{ home, ".claude", "projects" }); } -/// The directory name Claude Code derives from a cwd: every byte that is not -/// alphanumeric becomes `-`. Forward only — the note at the top of the file is -/// about the inverse, which is what cannot be recovered. pub fn dirName(gpa: std.mem.Allocator, cwd: []const u8) ![]u8 { const name = try gpa.dupe(u8, cwd); for (name) |*c| { @@ -68,10 +46,6 @@ pub fn dirName(gpa: std.mem.Allocator, cwd: []const u8) ![]u8 { return name; } -/// Whether Claude Code holds a transcript for `cwd` itself. `claude --resume` -/// keys on the exact directory it is launched in, so this is the question that -/// decides whether its picker would have anything to show; transcripts from a -/// subdirectory live in their own project directory and do not count. pub fn hasSessionsFor( gpa: std.mem.Allocator, io: Io, @@ -79,16 +53,12 @@ pub fn hasSessionsFor( cwd: []const u8, ) bool { const projects = root(gpa, environ) catch return false; - // Claude Code names the directory after the resolved path, same as the `cwd` - // it records — and `git worktree list` can hand us an unresolved one. const resolved = disk.realPath(gpa, io, cwd); if (transcriptCount(gpa, io, projects, resolved) > 0) return true; if (std.mem.eql(u8, resolved, cwd)) return false; return transcriptCount(gpa, io, projects, cwd) > 0; } -/// Transcripts in the project directory belonging to `cwd`. Counting only — -/// unlike `scanSessions` it never opens a transcript. fn transcriptCount( gpa: std.mem.Allocator, io: Io, @@ -109,9 +79,6 @@ fn transcriptCount( return count; } -/// Every project directory under `dir_path` whose origin cwd could be read. -/// A directory whose transcripts never name a cwd is left out entirely — lcc -/// cannot tell whether its project still exists, so it must not offer to delete it. pub fn list(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) ![]Entry { var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return &.{}; defer dir.close(io); @@ -140,9 +107,6 @@ const Scan = struct { count: usize, }; -/// Counts the transcripts in one project directory and returns the first cwd any -/// of them records. All sessions in a directory share it — the name was derived -/// from it — so the first hit is the answer. fn scanSessions(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) !Scan { var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return .{ .cwd = null, .count = 0 }; @@ -164,13 +128,6 @@ fn scanSessions(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) !Scan { return .{ .cwd = found, .count = count }; } -/// The first `prefix_limit` bytes of a file, or fewer if that is all there is. -/// Null when the file cannot be opened or read at all. -/// -/// `readFileAlloc` cannot do this: its limit is a ceiling on the whole file, so -/// a transcript past it comes back as `error.StreamTooLong` with no bytes — -/// which is exactly the case this needs to serve, since a worked-in worktree -/// has nothing but multi-megabyte transcripts. fn readPrefix(gpa: std.mem.Allocator, io: Io, file_path: []const u8) ?[]const u8 { var file = Io.Dir.cwd().openFile(io, file_path, .{}) catch return null; defer file.close(io); @@ -181,8 +138,6 @@ fn readPrefix(gpa: std.mem.Allocator, io: Io, file_path: []const u8) ?[]const u8 return buf[0..n]; } -/// Pulls the first `"cwd":"…"` value out of a transcript prefix, undoing JSON -/// string escaping. Null when the prefix holds no complete cwd field. pub fn extractCwd(gpa: std.mem.Allocator, prefix: []const u8) !?[]const u8 { const key = "\"cwd\":\""; const key_at = std.mem.indexOf(u8, prefix, key) orelse return null; @@ -191,7 +146,7 @@ pub fn extractCwd(gpa: std.mem.Allocator, prefix: []const u8) !?[]const u8 { var i = start; while (i < prefix.len) { switch (prefix[i]) { - '\\' => i += 2, // Skip the escape and whatever it escapes. + '\\' => i += 2, '"' => { const value = try unescape(gpa, prefix[start..i]); return if (value.len == 0) null else value; @@ -199,12 +154,9 @@ pub fn extractCwd(gpa: std.mem.Allocator, prefix: []const u8) !?[]const u8 { else => i += 1, } } - return null; // Truncated mid-value. + return null; } -/// The escapes a path can realistically carry: `\\`, `\"`, `\/`, and `\uXXXX` -/// for anything Claude Code chose to escape. Unknown escapes keep their literal -/// character, which is what every JSON parser does for the two-character forms. fn unescape(gpa: std.mem.Allocator, raw: []const u8) ![]const u8 { if (std.mem.indexOfScalar(u8, raw, '\\') == null) return gpa.dupe(u8, raw); @@ -244,15 +196,12 @@ fn unescape(gpa: std.mem.Allocator, raw: []const u8) ![]const u8 { return out.toOwnedSlice(gpa); } -/// Directories whose cwd is the worktree itself or something inside it. A single -/// worktree can own several: one per directory Claude Code was launched from. pub fn forWorktree( gpa: std.mem.Allocator, io: Io, entries: []const Entry, worktree_path: []const u8, ) ![]Entry { - // Claude Code records the resolved path; `git worktree list` does not. const resolved = disk.realPath(gpa, io, worktree_path); var matched: std.ArrayList(Entry) = .empty; @@ -267,8 +216,6 @@ pub fn forWorktree( return matched.toOwnedSlice(gpa); } -/// Entries whose working directory is gone from disk — the worktree was removed -/// long ago and the transcripts outlived it. pub fn orphans(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Entry { var dead: std.ArrayList(Entry) = .empty; for (entries) |entry| { @@ -280,7 +227,6 @@ pub fn orphans(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Entry return dead.toOwnedSlice(gpa); } -/// Attaches disk usage to each entry. pub fn withSizes(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Sized { const paths = try gpa.alloc([]const u8, entries.len); for (entries, 0..) |entry, i| paths[i] = entry.path; @@ -291,8 +237,6 @@ pub fn withSizes(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Size return sized; } -/// Delete one project directory. Refuses anything that is not a direct child of -/// `dir_path`, so `~/.claude` itself and its siblings can never be hit. pub fn remove(gpa: std.mem.Allocator, io: Io, entry: Entry, dir_path: []const u8) !void { _ = gpa; if (entry.name.len == 0) return Error.RefusingToDelete; @@ -333,15 +277,11 @@ test "extractCwd undoes escaping and refuses a truncated value" { defer gpa.free(got); try std.testing.expectEqualStrings("/Users/me/Say \"hi\"/there", got); - // A prefix that stops inside the value must not yield half a path. try std.testing.expect((try extractCwd(gpa, "{\"cwd\":\"/Users/me/Proj")) == null); try std.testing.expect((try extractCwd(gpa, "{\"type\":\"mode\"}")) == null); try std.testing.expect((try extractCwd(gpa, "{\"cwd\":\"\"}")) == null); } -/// A transcript that names `cwd` on its first line and then runs well past -/// `prefix_limit` — the shape every worked-in worktree leaves behind, and the -/// one a whole-file read under a ceiling cannot see at all. fn oversizedTranscript(arena: std.mem.Allocator, cwd_path: []const u8) ![]const u8 { var out: std.ArrayList(u8) = .empty; try out.appendSlice(arena, try std.fmt.allocPrint( @@ -373,10 +313,6 @@ test "a transcript larger than the prefix limit still yields its cwd" { const worktree = try std.fs.path.join(arena, &.{ base, "App.worktrees", "pe-1" }); try cwd.createDirPath(io, worktree); - // What a worked-in worktree actually holds: one transcript, megabytes long, - // naming its cwd on the first line. Reading it whole under a small ceiling - // yields nothing at all, so the directory would vanish from the listing and - // the worktree would report no usage. const project_dir = try std.fs.path.join(arena, &.{ projects, try dirName(arena, worktree) }); try cwd.createDirPath(io, project_dir); @@ -408,12 +344,6 @@ test "clean reclaims an orphan whose transcripts are all oversized" { const projects = try std.fs.path.join(arena, &.{ base, "projects" }); const cwd = Io.Dir.cwd(); - // `lcc clean` walks list → orphans → remove, and every step is downstream of - // reading the cwd. A worktree deleted after real work on it leaves exactly - // the transcripts that read is worst at: big ones, and no small one beside - // them. Miss it and the megabytes stay on disk forever, unreclaimable — - // which is the failure mode that hurts, since the whole point of the command - // is the space. const gone = try std.fs.path.join(arena, &.{ base, "App.worktrees", "pe-removed" }); const project_dir = try std.fs.path.join(arena, &.{ projects, try dirName(arena, gone) }); try cwd.createDirPath(io, project_dir); @@ -422,7 +352,6 @@ test "clean reclaims an orphan whose transcripts are all oversized" { .data = try oversizedTranscript(arena, gone), }); - // A live worktree alongside it, to pin that `orphans` still tells them apart. const alive = try std.fs.path.join(arena, &.{ base, "App.worktrees", "pe-1" }); try cwd.createDirPath(io, alive); const alive_dir = try std.fs.path.join(arena, &.{ projects, try dirName(arena, alive) }); @@ -444,7 +373,6 @@ test "clean reclaims an orphan whose transcripts are all oversized" { error.FileNotFound, cwd.access(io, project_dir, .{}), ); - // The live worktree's transcripts were never in danger. try cwd.access(io, alive_dir, .{}); } @@ -467,12 +395,10 @@ test "hasSessionsFor answers for the launch directory only" { try environ.put("HOME", base); try environ.put("LCC_CLAUDE_PROJECTS", projects); - // A worktree path with a `.` in it, to pin the flattening. const worktree = try std.fs.path.join(arena, &.{ base, "App.worktrees", "pe-1" }); try cwd.createDirPath(io, worktree); try std.testing.expect(!hasSessionsFor(arena, io, &environ, worktree)); - // A directory holding no transcript is still no reason to resume. const project_dir = try std.fs.path.join(arena, &.{ projects, try dirName(arena, worktree) }); try cwd.createDirPath(io, project_dir); try cwd.createDirPath(io, try std.fs.path.join(arena, &.{ project_dir, "memory" })); @@ -484,8 +410,6 @@ test "hasSessionsFor answers for the launch directory only" { }); try std.testing.expect(hasSessionsFor(arena, io, &environ, worktree)); - // A transcript from a subdirectory belongs to that subdirectory, and - // `claude --resume` at the parent would not list it. const sub = try std.fs.path.join(arena, &.{ worktree, "Common" }); try cwd.createDirPath(io, sub); try std.testing.expect(!hasSessionsFor(arena, io, &environ, sub)); @@ -531,7 +455,6 @@ test "list reads origins and skips directories with no discoverable cwd" { ), }); - // No transcript names a cwd — must be invisible to lcc. const unknown = try std.fs.path.join(arena, &.{ projects, "-unknown" }); try cwd.createDirPath(io, unknown); try cwd.writeFile(io, .{ diff --git a/src/commands/auth.zig b/src/commands/auth.zig index f1edcbb..f05ffe0 100644 --- a/src/commands/auth.zig +++ b/src/commands/auth.zig @@ -1,5 +1,3 @@ -//! `lcc auth` — OAuth browser flow, status, logout, and the PAT fallback. - const std = @import("std"); const app_mod = @import("../app.zig"); const config = @import("../config.zig"); @@ -48,7 +46,6 @@ fn login(app: app_mod.App) !void { app.ui.hint("If the browser doesn't open, visit:\n {s}", .{url}); app.ui.flush(); - // Non-fatal: the user can still use the printed URL. _ = exec.run(app.gpa, app.io, &.{ "open", url }, null) catch null; const callback = oauth.awaitCallback(app.gpa, app.io, state) catch |err| { @@ -144,8 +141,6 @@ const c = @cImport({ @cInclude("time.h"); }); -/// Local wall-clock time. libc does the timezone work that `toLocaleString()` -/// did in Node; the layout is ISO-ish rather than locale-specific. fn formatTime(gpa: std.mem.Allocator, unix_seconds: i64) ![]u8 { var t: c.time_t = @intCast(unix_seconds); var tm: c.struct_tm = undefined; diff --git a/src/commands/clean.zig b/src/commands/clean.zig index 568da64..3cad72a 100644 --- a/src/commands/clean.zig +++ b/src/commands/clean.zig @@ -1,6 +1,3 @@ -//! `lcc clean` — reclaim what worktrees leave behind after they are gone: -//! Xcode build data and Claude Code session transcripts. - const std = @import("std"); const app_mod = @import("../app.zig"); const cp = @import("../claude_projects.zig"); @@ -11,7 +8,6 @@ const ui = @import("../ui.zig"); pub const Opts = struct { yes: bool = false, - /// Which categories to consider. Neither flag means both. build_data: bool = false, sessions: bool = false, @@ -26,8 +22,6 @@ pub const Opts = struct { const Kind = enum { build_data, sessions }; -/// One removable thing, whichever category it came from. The two roots differ, -/// so each variant carries the entry its own module knows how to delete safely. const Target = union(Kind) { build_data: dd.Entry, sessions: cp.Entry, @@ -37,7 +31,6 @@ const Candidate = struct { target: Target, size: u64, - /// The folder name, as Xcode or Claude Code chose it. fn name(self: Candidate) []const u8 { return switch (self.target) { .build_data => |e| e.name, @@ -52,7 +45,6 @@ const Candidate = struct { }; } - /// The project or worktree that no longer exists. fn origin(self: Candidate) []const u8 { return switch (self.target) { .build_data => |e| e.workspace_path, @@ -152,7 +144,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { }); } -/// One `du` across both categories, so the wait does not double when cleaning both. fn measure(app: app_mod.App, targets: []const Target) ![]Candidate { const paths = try app.gpa.alloc([]const u8, targets.len); for (targets, 0..) |target, i| { diff --git a/src/commands/config.zig b/src/commands/config.zig index 309ff7d..c503609 100644 --- a/src/commands/config.zig +++ b/src/commands/config.zig @@ -1,16 +1,3 @@ -//! `lcc config` — every setting, browsable, and each one addressable by name. -//! -//! Two shapes for the same table. Bare, it opens a list you move through and -//! change in place; that is the one to reach for when you do not remember what -//! a setting is called, which is most of the time. Named — `lcc config -//! []` — it reads or writes exactly one and never touches raw mode, -//! which is the only form a script, a slash command or a tool call can use. -//! -//! Both drive the same `keys` table, so a setting cannot exist in one and be -//! missing from the other. That was the problem with `lcc setup`: a hand-written -//! walk through five settings in a fixed order, which every later setting was -//! simply absent from. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -27,33 +14,15 @@ pub const Opts = struct { pub const Error = error{ UnknownKey, BadValue } || std.mem.Allocator.Error; -/// What a key holds, which decides how it parses and how it prints. const Kind = enum { text, boolean, list, choice }; const Key = struct { - /// What you type: the key as it appears in the JSON file. name: []const u8, kind: Kind, - /// What you read. Short enough to sit beside its value in a list and say - /// what the setting *does*, so nothing needs a line of explanation under - /// it — a settings list that has to describe itself is one whose names are - /// wrong. label: []const u8, - /// For `.choice`, so a wrong value can be answered with the right ones - /// instead of just "no". choices: []const []const u8 = &.{}, }; -/// Every key `lcc config` will touch. -/// -/// `clientId` is deliberately absent: it belongs to the OAuth setup and has its -/// own command (`lcc auth setup --client-id`), where the surrounding text can -/// say what it is for. -/// Deliberately absent: `--yes` and `--force`. A stored setting that -/// pre-approves a destructive operation removes the one confirmation standing -/// between a mistyped command and a deleted worktree, and it does so invisibly, -/// months after anyone typed it. `--json` is absent for a duller reason: it is -/// a property of one invocation, never a preference. pub const keys = [_]Key{ .{ .name = "watchByDefault", .kind = .boolean, .label = "Sessions outlive the terminal" }, .{ .name = "planMode", .kind = .boolean, .label = "Start in plan mode" }, @@ -81,9 +50,6 @@ fn find(name: []const u8) ?Key { pub fn run(app: app_mod.App, opts: Opts) !void { const key_name = opts.key orelse { - // A terminal gets the browser; anything else gets the plain listing, - // because a picker reached from a tool call fails with NotATerminal - // after doing nothing useful. if (!opts.json and (Io.File.stdout().isTty(app.io) catch false)) return browse(app); return list(app, opts); }; @@ -144,8 +110,6 @@ fn get(app: app_mod.App, opts: Opts, key: Key) !void { app.ui.flush(); return; } - // Bare, so `lcc config worktreeTemplate` can be read by a shell without - // anything to strip off it. app.ui.info("{s}", .{text}); } @@ -186,11 +150,6 @@ fn set(app: app_mod.App, opts: Opts, key: Key, raw: []const u8) !void { app.ui.success("{s} = {s}", .{ key.name, text }); } -/// The interactive form: the whole table, moved through and changed in place. -/// -/// Every change is written as it is made rather than collected behind a save -/// step. The file is six lines of JSON and the alternative is a modal state -/// where what is on screen and what is on disk disagree. fn browse(app: app_mod.App) !void { var terminal = try term.Terminal.enterRaw(); defer terminal.restore(); @@ -243,8 +202,6 @@ fn browse(app: app_mod.App) !void { lines += 1; } - // Navigation, not explanation. The settings say what they do; this says - // what the keys do, and nothing else earns a line here. out.print("\n {s}↑↓ · enter · q{s}\n", .{ p.dim, p.reset }) catch {}; lines += 2; @@ -257,7 +214,6 @@ fn browse(app: app_mod.App) !void { .down => cursor = if (cursor + 1 >= keys.len) 0 else cursor + 1, .space, .enter => try change(app, &screen, &terminal, keys[cursor], cfg), .text => |t| { - // By key position, so a Cyrillic layout still navigates. if (term.layoutKey(t)) |key| switch (key) { 'q' => return, 'j' => cursor = if (cursor + 1 >= keys.len) 0 else cursor + 1, @@ -270,21 +226,12 @@ fn browse(app: app_mod.App) !void { } } -/// The value as a switch reads it. `on`/`off` rather than `true`/`false`, -/// because this is a list of toggles; the named form keeps the literal the file -/// holds, since that is what you would type back at it. fn display(app: app_mod.App, cfg: config.Config, key: Key) ![]const u8 { const raw = try render(app, cfg, key); if (key.kind != .boolean) return raw; return if (std.mem.eql(u8, raw, "true")) "on" else "off"; } -/// Apply one change to the highlighted setting. -/// -/// A boolean toggles and a choice cycles, both in place — an editor for two or -/// three values would be more machinery than the values are worth. Text and -/// lists need a real line editor, which means handing the terminal to -/// `prompt.input` and taking it back afterwards. fn change( app: app_mod.App, screen: *term.Screen, @@ -310,9 +257,6 @@ fn change( } }, .text, .list => { - // `prompt.input` owns the terminal for its lifetime, so this one is - // given back and taken again around it — the same handover - // `lcc watch` performs around an attached session. screen.eraseFrame(); screen.out.writeAll(term.csi ++ "?25h") catch {}; screen.out.flush() catch {}; @@ -363,14 +307,6 @@ fn applyList(patch: *config.Patch, name: []const u8, items: []const []const u8) if (std.mem.eql(u8, name, "mcpCarry")) patch.mcpCarry = mcpCarryFrom(items); } -/// `mcpCarry` has three states, not two, and the words for them predate this -/// command: "all" carries every local server, "none" carries none, a list -/// carries those. Empty means "all" because the key's absence is what carries -/// everything — the distinction `config.McpCarry` exists to keep. -/// -/// Taken literally, a typed "none" would otherwise become a list holding one -/// server called `none`, and the session would silently get no MCP servers for -/// a reason nobody could see. pub fn mcpCarryFrom(items: []const []const u8) config.McpCarry { if (items.len == 0) return .all; if (items.len == 1) { @@ -398,8 +334,6 @@ fn render(app: app_mod.App, cfg: config.Config, key: Key) ![]const u8 { if (std.mem.eql(u8, key.name, "listNetwork")) return @tagName(cfg.listNetwork); if (std.mem.eql(u8, key.name, "worktreeTemplate")) return cfg.worktreeTemplate; if (std.mem.eql(u8, key.name, "startTaskCommand")) { - // An empty template is the default and means "open with no prompt"; - // printing nothing would read as a bug. return if (cfg.startTaskCommand.len == 0) "(none)" else cfg.startTaskCommand; } if (std.mem.eql(u8, key.name, "activeStates")) return std.mem.join(app.gpa, ", ", cfg.activeStates); @@ -413,8 +347,6 @@ fn render(app: app_mod.App, cfg: config.Config, key: Key) ![]const u8 { return ""; } -/// Generous about what counts as a yes: this is typed by hand, and refusing -/// `on` or `1` would be pedantry rather than safety. pub fn parseBool(raw: []const u8) ?bool { const trimmed = std.mem.trim(u8, raw, " \t"); inline for (.{ "true", "yes", "on", "1" }) |yes| { @@ -426,8 +358,6 @@ pub fn parseBool(raw: []const u8) ?bool { return null; } -/// Comma-separated, trimmed, empties dropped. An empty string is an empty list -/// rather than a list containing nothing-in-particular. pub fn splitList(gpa: std.mem.Allocator, raw: []const u8) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; var it = std.mem.splitScalar(u8, raw, ','); @@ -444,9 +374,6 @@ const testing = std.testing; test "every key is unique and carries a label short enough to sit in a list" { for (keys, 0..) |key, i| { try testing.expect(key.label.len > 0); - // Short enough to sit beside its value rather than wrap. There is no - // second line and no description under it to fall back on — the name - // is the whole explanation. try testing.expect(ui.displayWidth(key.label) <= 32); for (keys[i + 1 ..]) |other| { try testing.expect(!std.mem.eql(u8, key.name, other.name)); @@ -455,18 +382,12 @@ test "every key is unique and carries a label short enough to sit in a list" { try testing.expect(find("watchByDefault") != null); try testing.expect(find("nonsense") == null); - // Every choice key offers its options, or a wrong value can only be - // answered with "no" rather than with the right answer. for (keys) |key| { if (key.kind == .choice) try testing.expect(key.choices.len > 1); } } test "the destructive flags are deliberately not settings" { - // A stored `yes` or `force` removes the one confirmation between a mistyped - // command and a deleted worktree — invisibly, months after it was typed. - // Asserted rather than left to a comment, because the obvious next commit - // is someone adding them for symmetry. try testing.expect(find("yes") == null); try testing.expect(find("force") == null); try testing.expect(find("json") == null); @@ -477,14 +398,11 @@ test "mcpCarry keeps the three states its words describe" { try testing.expect(mcpCarryFrom(&.{}) == .all); try testing.expect(mcpCarryFrom(&.{"all"}) == .all); try testing.expect(mcpCarryFrom(&.{"ALL"}) == .all); - // "none" is empty, not a server called none — which is what a literal - // reading would produce, and it would fail silently. try testing.expectEqual(@as(usize, 0), mcpCarryFrom(&.{"none"}).only.len); const named = mcpCarryFrom(&.{ "linear-server", "xcode" }); try testing.expectEqual(@as(usize, 2), named.only.len); try testing.expectEqualStrings("linear-server", named.only[0]); - // A server genuinely called "all" alongside others is still a list. try testing.expectEqual(@as(usize, 2), mcpCarryFrom(&.{ "all", "xcode" }).only.len); _ = gpa; } @@ -493,8 +411,6 @@ test "listNetwork parses its three states and nothing else" { try testing.expectEqual(config.ListNetwork.refresh, config.ListNetwork.parse("refresh").?); try testing.expectEqual(config.ListNetwork.cached, config.ListNetwork.parse("cached").?); try testing.expectEqual(config.ListNetwork.local, config.ListNetwork.parse(" local ").?); - // Two booleans would have let someone ask for both "skip the network" and - // "ignore the cache"; one setting cannot express that at all. try testing.expect(config.ListNetwork.parse("both") == null); try testing.expect(config.ListNetwork.parse("") == null); } @@ -506,8 +422,6 @@ test "a boolean accepts what people actually type" { for ([_][]const u8{ "false", "FALSE", "no", "off", "0" }) |raw| { try testing.expectEqual(false, parseBool(raw).?); } - // Anything else is refused rather than guessed — silently reading "maybe" - // as false would turn watch mode off without saying so. try testing.expect(parseBool("maybe") == null); try testing.expect(parseBool("") == null); try testing.expect(parseBool("2") == null); @@ -522,13 +436,9 @@ test "a list splits on commas and drops the gaps" { const states = try splitList(arena, "Todo, In Progress ,In Review"); try testing.expectEqual(@as(usize, 3), states.len); try testing.expectEqualStrings("Todo", states[0]); - // Trimmed, because typing a space after a comma is the normal thing to do - // and " In Progress" would never match a Linear state. try testing.expectEqualStrings("In Progress", states[1]); try testing.expectEqualStrings("In Review", states[2]); - // Empty is an empty list, not a list holding one empty string — which for - // `linkPatterns` would be a pattern matching everything. try testing.expectEqual(@as(usize, 0), (try splitList(arena, "")).len); try testing.expectEqual(@as(usize, 0), (try splitList(arena, " , , ")).len); } @@ -546,13 +456,11 @@ test "watchByDefault round-trips through the file" { var environ: std.process.Environ.Map = .init(arena); try environ.put("HOME", home); - // On when nothing has been said. try testing.expect((try config.load(arena, io, &environ)).watchByDefault); try config.save(arena, io, &environ, .{ .watchByDefault = false }); try testing.expect(!(try config.load(arena, io, &environ)).watchByDefault); - // And back, without disturbing anything else that was stored. try config.save(arena, io, &environ, .{ .startTaskCommand = "/start-task {identifier}" }); const cfg = try config.load(arena, io, &environ); try testing.expect(!cfg.watchByDefault); diff --git a/src/commands/daemon.zig b/src/commands/daemon.zig index d17aa53..3b52787 100644 --- a/src/commands/daemon.zig +++ b/src/commands/daemon.zig @@ -1,14 +1,3 @@ -//! `lcc daemon` — run the session daemon, or ask what it is doing. -//! -//! **Not in `usage`, and not for users.** `watch_client` re-execs this to bring -//! the session host up; `--foreground` and `--status` exist for whoever is -//! debugging that. Someone running `lcc` has sessions, not a daemon, and every -//! sentence they read says so. -//! -//! What they do need — seeing the sessions and ending all of them — is -//! `lcc open` and `lcc open --stop-all`. Both live in `commands/watch.zig`, so -//! the visible vocabulary and the plumbing stay separable. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -18,7 +7,6 @@ const watch_paths = @import("../watch_paths.zig"); const wire = @import("../wire.zig"); pub const Opts = struct { - /// Stay attached to this terminal instead of detaching. foreground: bool = false, status: bool = false, json: bool = false, @@ -49,8 +37,6 @@ fn status(app: app_mod.App, opts: Opts) !void { if (!running) { app.ui.info("No daemon running.", .{}); - // The breadcrumb case: a daemon that died leaves pids behind, and they - // are the only record that anything was running. if (state.sessions.len > 0) { app.ui.warn( "{d} session(s) were recorded before it stopped — their agents may still be running.", diff --git a/src/commands/issue.zig b/src/commands/issue.zig index f8026c2..c3642e0 100644 --- a/src/commands/issue.zig +++ b/src/commands/issue.zig @@ -1,12 +1,3 @@ -//! Reading and writing one Linear issue, named by its identifier. -//! -//! `lcc start --json` resolves an issue too, but it is not a read-only probe: it -//! cuts a branch and a worktree on the way, and a caller that only wanted to look -//! has to undo them. This is the command that only looks. -//! -//! Nothing here needs a repository. `show` answers the same from anywhere, which -//! is what lets a caller ask about an issue before deciding where its code lives. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -20,9 +11,6 @@ const semver = @import("../semver.zig"); pub const Verb = enum { show, state, comment, project }; -/// The subcommand `raw` names, case-insensitively — the shape `open.resolveTarget` -/// uses. Null is "not one of ours", which the caller turns into a message naming -/// the ones that are. pub fn resolveVerb(raw: []const u8) ?Verb { if (std.ascii.eqlIgnoreCase(raw, "show")) return .show; if (std.ascii.eqlIgnoreCase(raw, "state")) return .state; @@ -31,9 +19,6 @@ pub fn resolveVerb(raw: []const u8) ?Verb { return null; } -/// The flags a verb takes, and only those. A union rather than one flat struct -/// carrying every flag: an option a verb has no use for should not be a thing that -/// parses and is then ignored. pub const Sub = union(enum) { show: Show, state: SetState, @@ -43,42 +28,23 @@ pub const Sub = union(enum) { pub const Show = struct {}; pub const SetState = struct { - /// The state as it is written on the board — resolved against that team's - /// own workflow states, never against a status type. name: ?[]const u8 = null, - /// `--type` — narrows a name two states share, by Linear's `statusType`. - /// It may only narrow: a type that matches no name is still no such state. type: ?[]const u8 = null, }; pub const AddComment = struct { - /// `-m` — the body outright. body: ?[]const u8 = null, - /// `-f` — the body read off disk. Mutually exclusive with `body`. file: ?[]const u8 = null, }; pub const SetProject = struct { - /// `--assign vX.Y.Z` — the version said outright. There is no inference - /// here and no resolver: a command that writes to Linear names what it is - /// writing. assign: ?[]const u8 = null, - /// `--resolve` — work out which release this issue targets and say so. - /// Read-only, always exits 0. Mutually exclusive with `--assign`. resolve: bool = false, - /// `--fetch` — refresh the view of `origin` first. Off by default because - /// this runs on a hot path, and a stale view degrades to a conservative - /// answer rather than a wrong one. fetch: bool = false, - /// Create the project when it does not exist. The flag **is** the consent, - /// moved out of a prompt so a non-interactive caller can give it. create: bool = false, - /// Reassign an issue that is already in a project. Moving between releases - /// is a deliberate "cut", so it does not happen by accident. force: bool = false, }; - /// The defaults for `verb`, so the parser has somewhere to put its flags. pub fn empty(verb: Verb) Sub { return switch (verb) { .show => .{ .show = .{} }, @@ -90,8 +56,6 @@ pub const Sub = union(enum) { }; pub const Opts = struct { - /// `PE-42`, exactly as typed. Parsed into a `linear.Ref` inside `run`, so a bad - /// identifier is one refusal in one place however many subcommands there are. issue: ?[]const u8 = null, json: bool = false, sub: Sub = .{ .show = .{} }, @@ -118,12 +82,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { } } -/// The Keychain read, the config, and a token that has not expired. -/// -/// The hint comes before the read, not after: the Keychain grants access to a -/// binary by its code signature, so a freshly built lcc can block here on a system -/// dialog asking for the login password. Announced, that is a wait with a reason; -/// unannounced, it is a process sitting silently with no output and no child. fn authorize(app: app_mod.App, opts: Opts) !oauth.Token { app.ui.hint("Reading the Linear token from the Keychain...", .{}); app.ui.flush(); @@ -194,9 +152,6 @@ fn setState( const target = switch (try linear.resolveState(app.gpa, ctx.states, wanted, sub.type)) { .found => |state| state, - // The full board, in its own order, so "no such state" says what there is - // instead of only what there is not. When the state list was capped, that - // is a different claim and the message makes it. .unknown => bail( app, opts.json, @@ -209,11 +164,6 @@ fn setState( stateNames(app, ctx.states), }, ), - // Refused in both modes, not just the machine one: two modes disagreeing - // about which state an issue landed in is worse than either refusing. The - // escape is `--type`, because `(name, type)` is the pair that is unique — - // and it is the same string in both modes, so a caller can retry without - // a human. .ambiguous => |hits| bail( app, opts.json, @@ -223,9 +173,6 @@ fn setState( ), }; - // Linear's GitHub integration moves an issue on a branch push, so arriving to - // find it already there is the normal case, not a race. Writing anyway would - // bump `updatedAt` and put a state change in the activity feed nobody made. const changed = !std.mem.eql(u8, ctx.current.id, target.id); const landed: linear.WorkflowState = if (!changed) ctx.current else blk: { const updated = linear.setIssueState(app.gpa, app.io, token, ctx.issue_id, target.id) catch |err| bail( @@ -265,8 +212,6 @@ fn stateNames(app: app_mod.App, states: []const linear.WorkflowState) []const u8 return std.mem.join(app.gpa, ", ", names) catch ""; } -/// The types that tell two same-named states apart, which is the only thing a -/// caller can use to narrow them. fn stateTypes(app: app_mod.App, states: []const linear.WorkflowState) []const u8 { const types = app.gpa.alloc([]const u8, states.len) catch return ""; for (states, 0..) |state, i| types[i] = state.type; @@ -281,12 +226,8 @@ const ReportState = struct { const StateReport = struct { issue: ReportRef, - /// False when the issue was already there. The write is skipped, not faked — - /// `from` and `to` are then the same value. changed: bool, from: ReportState, - /// What Linear ended up with, re-read out of the mutation's own payload rather - /// than echoed back from the request. to: ReportState, }; @@ -307,8 +248,6 @@ fn project( "'{s}' is not a release version — expected something like v2.6.0.", .{sub.assign.?}, ); - // Normalised, so `2.6.0` and `v2.6.0` name the same project and a created one - // is always spelled the way the board spells it. const name = try semver.render(app.gpa, wanted); const found = linear.fetchIssueDetail(app.gpa, app.io, token, ref) catch |err| bail( @@ -320,9 +259,6 @@ fn project( ); const detail = found orelse bail(app, opts.json, "issue_not_found", "No issue {s} in Linear.", .{named}); - // Moving an issue between releases is a deliberate cut, and this is where that - // is enforced rather than merely written down. The refusal names the flag, so - // a caller that meant it can say so without going and reading the docs. if (detail.project) |current| { if (!sub.force and !std.mem.eql(u8, current.name, name)) { bail( @@ -378,8 +314,6 @@ fn project( break :blk made; }; - // Already in the right project: reported, not rewritten, for the same reason - // `state` skips a no-op — an activity-feed entry nobody made is noise. const changed = if (detail.project) |current| !std.mem.eql(u8, current.id, target.id) else true; if (changed) { _ = linear.setIssueProject(app.gpa, app.io, token, detail.issue.id, target.id) catch |err| bail( @@ -413,12 +347,6 @@ fn project( app.ui.flush(); } -/// Which release this issue targets, worked out rather than named. -/// -/// Read-only and **always exits 0**, even when only a human can settle it: an -/// unresolved case comes back as a proposal with the whole computation already -/// done, so the caller's question is a formatting job rather than a re-derivation. -/// The write is `--assign`, which infers nothing. fn resolveProject( app: app_mod.App, opts: Opts, @@ -447,8 +375,6 @@ fn resolveProject( } } - // Rule 2 is the common case in a start-task flow, and it needs neither git nor - // the project board. Asking here is what keeps those two off the hot path. var git_facts: ?GitFacts = null; const outcome = release.resolveLocal(facts) orelse blk: { git_facts = gatherGit(app, sub, &facts, ¬es); @@ -467,8 +393,6 @@ fn resolveProject( renderResolve(app, value); } -/// The git half of `Facts`, kept for the report so a reader can see what the rule -/// was decided against. Null when there is no repository here at all. const GitFacts = struct { root: []const u8, head: ?[]const u8, @@ -499,9 +423,6 @@ fn gatherGit( facts.current_branch = repo.currentBranch() catch null; facts.default_branch = repo.defaultBranch() catch "main"; - // Every count is measured from a resolved sha rather than the literal `HEAD`: - // the counts run in the main checkout, so `HEAD` there would be a different - // commit whenever the user is standing in a linked worktree. const head = repo.headSha(); var branches: std.ArrayList(release.ReleaseBranch) = .empty; @@ -534,8 +455,6 @@ fn gatherGit( var tags: std.ArrayList(semver.Version) = .empty; if (repo.listTags()) |names| { - // Tags are noisy — `build-4471` lives beside `v2.6.0` — so the ones that - // are not versions are skipped silently rather than noted one by one. for (names) |name| { if (semver.parse(name)) |version| tags.append(app.gpa, version) catch {}; } @@ -543,9 +462,6 @@ fn gatherGit( semver.sortAsc(tags.items); facts.tags = tags.items; - // The base rides on a request lcc already makes for this branch, so there is - // no second process and no second network hop. `gh` is optional throughout: - // its absence costs a note, and the commit-distance contest still answers. var pr_base: ?[]const u8 = null; if (facts.current_branch) |branch| { if (github.forBranches(app.gpa, app.io, repo.root, &.{branch})) |prs| { @@ -593,9 +509,6 @@ fn gatherBoard( std.mem.sort(release.Project, completed, {}, projectAsc); facts.completed_projects = completed; - // Everything already cut, from the two sources that mean *shipped* — tags and - // completed projects. A live release branch means stabilising, which is the - // veto's job below, not this ceiling's. var ceiling: ?semver.Version = null; if (facts.tags.len > 0) ceiling = facts.tags[facts.tags.len - 1]; if (completed.len > 0) { @@ -621,8 +534,6 @@ fn gatherBoard( } } -/// The projects whose names really are versions. A `v2.6.0-rc1` on the board is -/// invisible to the resolver rather than standing in for the release. fn versioned(app: app_mod.App, projects: []const linear.ReleaseProject) []release.Project { var out: std.ArrayList(release.Project) = .empty; for (projects) |candidate| { @@ -640,11 +551,6 @@ fn projectAsc(_: void, a: release.Project, b: release.Project) bool { return a.version.order(b.version) == .lt; } -/// The project called `name`, looked for in both halves of the board. -/// -/// A completed project is a legitimate target for an explicit `--assign`: dropping -/// shipped releases is about what the *resolver* may propose, not about what a -/// human may name outright. fn findProject(board: linear.ReleaseProjects, name: []const u8) ?linear.Project { for ([_][]const linear.ReleaseProject{ board.open, board.completed }) |half| { for (half) |candidate| { @@ -659,9 +565,7 @@ fn findProject(board: linear.ReleaseProjects, name: []const u8) ?linear.Project const ProjectReport = struct { issue: ReportRef, project: ReportProject, - /// Whether this run attached it. False when the issue was already there. changed: bool, - /// Whether this run had to create the project first. created: bool, }; @@ -698,36 +602,20 @@ const ReportGit = struct { release_branches: []const ReportReleaseBranch, }; -/// What `--resolve` promises. -/// -/// Every status exits 0 — an unresolved case is an answer, not a failure, and the -/// point of moving this into lcc is that the computation survives the human gate -/// instead of being defeated by it. `question` and `command` are pre-worded so the -/// caller quotes rather than re-derives them. const ResolveReport = struct { issue: ReportRef, - /// `resolved`, `already_set`, `needs_confirmation` or `needs_choice`. status: []const u8, rule: u8, rule_name: []const u8, - /// The version. Present for `resolved`, for `already_set` unless the project's - /// name is not a version, and for `needs_confirmation` as the candidate. version: ?[]const u8, evidence: ?ReportEvidence, - /// The Linear project carrying that name, when one exists. project: ?ReportProject, - /// Whether a human has to agree before the project is created — false for the - /// rules that read a version off a fact, true for the one that infers it. confirm_before_create: bool, baseline: ?ReportBaseline, choices: []const ReportChoice, - /// A project already on the issue that this answer disagrees with. Non-null - /// means `--assign` will refuse without `--force`. conflict: ?ReportProject, question: ?[]const u8, command: ?[]const u8, - /// Null when the answer never needed git — not an empty object, because "not - /// gathered" and "gathered and empty" are different facts. git: ?ReportGit, notes: []const []const u8, }; @@ -789,13 +677,9 @@ fn buildResolveReport( value.rule_name = @tagName(hit.rule); value.version = name; value.evidence = .{ .kind = @tagName(hit.evidence.kind), .text = hit.evidence.text }; - // Rules 1 and 3 to 5 read a version off a fact, so a project created - // for one needs no further agreement. value.confirm_before_create = false; if (hit.project) |p| value.project = .{ .id = p.id, .name = p.name }; if (hit.project == null) { - // Only the board having been read makes "there is none" a claim - // worth acting on, so say which of the two nulls this is. if (facts.projects_asked) { value.project = findProjectNamed(facts, name); } @@ -897,9 +781,6 @@ fn renderResolve(app: app_mod.App, value: ResolveReport) void { app.ui.flush(); } -/// A markdown plan is the largest thing anyone reasonably comments with. A -/// megabyte is headroom over that; past it the caller has pointed at the wrong -/// file, and saying so beats sending it. const max_body = 1 << 20; fn comment( @@ -915,8 +796,6 @@ fn comment( bail(app, opts.json, "body_empty", "Refusing to post an empty comment to {s}.", .{named}); } - // The issue's own UUID is what `commentCreate` writes against, and the read - // that fetches it is also what proves the issue exists before anything is sent. const found = linear.fetchIssueDetail(app.gpa, app.io, token, ref) catch |err| bail( app, opts.json, @@ -959,13 +838,6 @@ fn comment( app.ui.flush(); } -/// The comment body, from `-m` outright or from a file. -/// -/// The path is resolved before it is read, and the ways that can fail are kept -/// apart: `realPathFileAlloc` resolves anything that exists, directories included, -/// so "no such file" is a claim to make once it is true rather than a catch-all. -/// Told a file is missing when it is sitting there unreadable, you go looking for -/// the wrong thing. fn commentBody(app: app_mod.App, opts: Opts, sub: Sub.AddComment) []const u8 { if (sub.body) |text| return text; const raw = sub.file.?; @@ -987,9 +859,6 @@ fn commentBody(app: app_mod.App, opts: Opts, sub: Sub.AddComment) []const u8 { bail(app, opts.json, "body_unreadable", "Cannot read {s}: {s}", .{ raw, @errorName(err) }); } -/// The three fields a write's report needs to identify what it wrote to. A subset -/// of `ReportIssue` rather than the whole of it, because a write should not have -/// to have read the description in order to report itself. const ReportRef = struct { id: []const u8, identifier: []const u8, @@ -1003,14 +872,9 @@ const CommentReport = struct { url: []const u8, created_at: []const u8, }, - /// Bytes as sent, so a caller that fed a file can tell a truncated read from a - /// whole one without re-stat-ing it. body_bytes: usize, }; -/// Shared by every subcommand, and field-for-field the `issue` block of -/// `lcc start --json` where the two overlap, plus the ids a write needs and the -/// fields only a detail read pays for. A caller parses one shape, not two. const ReportIssue = struct { id: []const u8, identifier: []const u8, @@ -1032,16 +896,9 @@ const ReportProject = struct { name: []const u8, }; -/// What `show --json` promises. A declared type rather than a literal inside the -/// printer, because it is a contract another program parses — the test at the -/// bottom of this file is what keeps the field names from drifting. const ShowReport = struct { issue: ReportIssue, - /// Null is "in no project", which for an issue past Todo is the invariant a - /// caller is checking for. project: ?ReportProject, - /// Every label, flat. Which one picks a pipeline is the caller's taxonomy, and - /// grouping them here would be lcc taking a view on one it does not own. labels: []const []const u8, description: ?[]const u8, }; @@ -1072,9 +929,6 @@ fn buildShowReport(detail: linear.Detail) ShowReport { fn renderShow(app: app_mod.App, value: ShowReport) void { app.ui.info("{s} {s}", .{ value.issue.identifier, value.issue.title }); app.ui.info(" State {s}", .{value.issue.state}); - // Stated even when there is none: an issue past Todo with no project is - // invisible on the release board, and a line that disappears when it is - // missing is the one a reader stops looking for. if (value.project) |attached| { app.ui.info(" Project {s}", .{attached.name}); } else { @@ -1090,8 +944,6 @@ fn renderShow(app: app_mod.App, value: ShowReport) void { app.ui.flush(); } -/// The exits a caller has to be able to react to, in the shape it asked for. JSON -/// goes to stdout and the human line to stderr, so both readers get served. fn bail( app: app_mod.App, json: bool, @@ -1119,9 +971,7 @@ test "resolveVerb takes the subcommand however it is cased, and nothing else" { try std.testing.expectEqual(Verb.project, resolveVerb("project").?); try std.testing.expect(resolveVerb("") == null); try std.testing.expect(resolveVerb("frobnicate") == null); - // An identifier in the verb's place is a missing subcommand, not a verb. try std.testing.expect(resolveVerb("PE-42") == null); - // Nor is a flag: `lcc issue --json PE-42` names no subcommand at all. try std.testing.expect(resolveVerb("--json") == null); } @@ -1155,10 +1005,6 @@ test "the state payload reports what landed, and says when nothing was written" try std.testing.expectEqualStrings("Todo", parsed.from.name); try std.testing.expectEqualStrings("In Progress", parsed.to.name); - // Already there: Linear's GitHub integration moves an issue on a branch push, - // so this is the normal case. The write is skipped rather than faked, and - // `from` and `to` are the same value — which is how a caller tells a no-op - // from a move without comparing names itself. const untouched: StateReport = .{ .issue = issue, .changed = false, .from = progress, .to = progress }; const idle = try std.json.Stringify.valueAlloc(gpa, untouched, .{ .whitespace = .indent_2 }); defer gpa.free(idle); @@ -1194,8 +1040,6 @@ test "an unresolved release comes back as a proposal, not as a failure" { .description = null, }; - // Rule 6: nothing open left, so lcc proposes a minor above what shipped and - // hands the question over already worded. const outcome: release.Outcome = .{ .needs_confirmation = .{ .candidate = semver.parse("v2.6.0").?, .baseline = semver.parse("v2.5.2").?, @@ -1240,15 +1084,10 @@ test "an unresolved release comes back as a proposal, not as a failure" { try std.testing.expectEqualStrings("v2.6.0", parsed.version.?); try std.testing.expectEqualStrings("v2.5.2", parsed.baseline.?.version); try std.testing.expectEqualStrings("tag", parsed.baseline.?.source); - // The one rule that infers rather than reads: creating this project needs a - // human to agree first, and the flag that says so is in the command. try std.testing.expect(parsed.confirm_before_create); try std.testing.expect(std.mem.endsWith(u8, parsed.command.?, "--assign v2.6.0 --create")); - // Already worded, so the caller's hard gate is a formatting job. try std.testing.expect(std.mem.indexOf(u8, parsed.question.?, "v2.5.2") != null); - // `git: null` is the staging contract: rule 2 and this path never paid for it, - // and an empty object would claim it was gathered and came back bare. try std.testing.expect(std.mem.indexOf(u8, body, "\"git\": null") != null); } @@ -1290,11 +1129,7 @@ test "a resolved release names the rule that found it and needs no confirmation" try std.testing.expectEqualStrings("resolved", value.status); try std.testing.expectEqual(@as(u8, 3), value.rule); try std.testing.expectEqualStrings("v2.7.0", value.version.?); - // Rule 3 read the version off a branch that exists, so there is nothing left - // for a human to agree to — only rule 6 infers. try std.testing.expect(!value.confirm_before_create); - // The board was never asked, so no project was found, and the command says so - // by carrying `--create`. try std.testing.expect(std.mem.endsWith(u8, value.command.?, "--create")); } @@ -1310,9 +1145,6 @@ test "findProject takes a shipped release too, because an explicit name is not a }; try std.testing.expectEqualStrings("p-260", findProject(board, "v2.6.0").?.id); - // Dropping shipped releases governs what the resolver may *propose*. A human - // naming one outright — backfilling an issue onto a release that already went - // out — is a different act, and refusing it here would be lcc overruling them. try std.testing.expectEqualStrings("p-252", findProject(board, "v2.5.2").?.id); try std.testing.expect(findProject(board, "v9.9.9") == null); } @@ -1334,9 +1166,6 @@ test "the project payload keeps the shape a caller parses" { issue: struct { id: []const u8, identifier: []const u8, url: []const u8 }, project: struct { id: []const u8, name: []const u8 }, changed: bool, - /// Separate from `changed` on purpose: a caller has to be able to tell an - /// issue being filed into an existing release from one that brought a new - /// release board into existence. created: bool, }; @@ -1380,8 +1209,6 @@ test "the comment payload keeps the shape a caller parses" { const parsed = try std.json.parseFromSliceLeaky(Schema, arena_state.allocator(), body, .{}); try std.testing.expectEqualStrings("PE-250", parsed.issue.identifier); - // The comment's own URL, not the issue's: a caller that reports "commented" - // should be able to link to the comment it made. try std.testing.expectEqualStrings("comment-uuid", parsed.comment.id); try std.testing.expectEqual(@as(usize, 42), parsed.body_bytes); } @@ -1413,9 +1240,6 @@ test "the show payload keeps the shape a caller parses" { const body = try std.json.Stringify.valueAlloc(gpa, buildShowReport(detail), .{ .whitespace = .indent_2 }); defer gpa.free(body); - // The shape the caller relies on, spelled out independently of `ShowReport`. - // Parsing rejects unknown fields, so renaming, dropping *or* adding one fails - // here rather than in whatever is reading the JSON. const Schema = struct { issue: struct { id: []const u8, @@ -1442,8 +1266,6 @@ test "the show payload keeps the shape a caller parses" { const parsed = try std.json.parseFromSliceLeaky(Schema, arena_state.allocator(), body, .{}); try std.testing.expectEqualStrings("PE-250", parsed.issue.identifier); - // The two ids a write needs, so setting a state or creating a project never - // has to send the human key `PE` where Linear wants a UUID. try std.testing.expectEqualStrings("state-uuid", parsed.issue.state_id); try std.testing.expectEqualStrings("team-uuid", parsed.issue.team_id.?); try std.testing.expectEqualStrings("v2.6.0", parsed.project.?.name); @@ -1477,9 +1299,6 @@ test "an issue in no project says so, rather than leaving the key out" { const body = try std.json.Stringify.valueAlloc(gpa, buildShowReport(detail), .{ .whitespace = .indent_2 }); defer gpa.free(body); - // A missing project is the invariant violation a caller is looking for, so the - // key is present and null rather than absent — an absent key reads as "lcc did - // not check", which is a different answer. try std.testing.expect(std.mem.indexOf(u8, body, "\"project\": null") != null); try std.testing.expect(std.mem.indexOf(u8, body, "\"description\": null") != null); try std.testing.expect(std.mem.indexOf(u8, body, "\"assignee\": null") != null); diff --git a/src/commands/list.zig b/src/commands/list.zig index f7b3d96..3f6ea89 100644 --- a/src/commands/list.zig +++ b/src/commands/list.zig @@ -1,21 +1,3 @@ -//! `lcc list` — a dashboard of the worktrees in the current repo. Working-tree -//! state, drift from the upstream, and how the work is tracked, in one screen. -//! -//! Everything local comes from two git calls plus one `git status` per worktree. -//! The PR and Linear columns are one batched request each, and both degrade to a -//! dash when they cannot be answered — an unauthenticated shell still gets the -//! rest of the table. -//! -//! None of that work depends on any of the rest of it, so none of it waits: the -//! two hosts, the `git status` calls and the transcript scan all go out at once -//! and the table is assembled from whatever comes back. Done in sequence the -//! network alone was most of the command's runtime, and the slower of the two -//! round trips is now the whole of it. -//! -//! What that still cannot fix is that a round trip is half a second no matter -//! how little is being asked. So the two answers are cached for a few minutes — -//! see `remote_cache` — and `--refresh` is how you say you want them asked again. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -31,15 +13,8 @@ const ui = @import("../ui.zig"); const usage = @import("../usage.zig"); pub const Opts = struct { - /// Skip the two network columns. local: bool = false, - /// Show what each worktree has spent on Claude Code. On by default: the - /// question "how much has this task cost" comes up every time the dashboard - /// does. Off is for when reading the transcripts is not worth the wait. tokens: bool = true, - /// Ask GitHub and Linear again instead of reusing a recent answer. For the - /// moment right after merging a PR, when the cached state is the one thing - /// you know to be wrong. refresh: bool = false, }; @@ -51,11 +26,8 @@ const Row = struct { tree: Tree, status: []const u8, sync: []const u8, - /// True once the remote branch the worktree tracked has been deleted. remote_gone: bool, age: []const u8, - /// Context tokens this worktree's Claude Code sessions have read, or a dash - /// when it has none and when the column is off. tokens: []const u8, pr: []const u8, pr_state: ?github.State, @@ -73,16 +45,10 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const now = app_mod.nowSeconds(app.io); - // Both remote columns are asked per branch, so both need the list of them — - // and that list is also what says whether a cached answer covered this set of - // worktrees. Only the branches that name an issue go to Linear. const branches = try branchNames(app, choices); const refs = try issueRefs(app, choices); const asked = try identifiers(app, refs); - // The cache is read here, before anything is spawned, and written after - // everything has been joined. A task never touches it, so there is nothing - // for two of them to race over. var cache: rc.Cache = if (opts.local) .none(app.gpa, app.io) else @@ -107,9 +73,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const spend = try app.gpa.alloc([]const u8, choices.len); @memset(spend, "—"); - // Everything slow, at once. `git status` walks each worktree, the two columns - // wait on two different hosts, and the token scan reads `~/.claude` — four - // kinds of waiting with nothing to say to each other. { var group: Io.Group = .init; group.async(app.io, branchStatusTask, .{ repo, &statuses }); @@ -145,8 +108,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { } } -/// The branch of every worktree on screen. A detached one contributes nothing: -/// there is no head ref to match a pull request against. fn branchNames(app: app_mod.App, choices: []const app_mod.Choice) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; for (choices) |choice| { @@ -158,9 +119,6 @@ fn branchNames(app: app_mod.App, choices: []const app_mod.Choice) ![]const []con return out.toOwnedSlice(app.gpa); } -/// The issues the worktrees on screen belong to, in the order the branches give -/// them. Duplicates are left in: `fetchIssueStatuses` folds them itself, and -/// `identifiers` is what the cache is keyed on. fn issueRefs(app: app_mod.App, choices: []const app_mod.Choice) ![]const linear.Ref { var refs: std.ArrayList(linear.Ref) = .empty; for (choices) |choice| { @@ -171,8 +129,6 @@ fn issueRefs(app: app_mod.App, choices: []const app_mod.Choice) ![]const linear. return refs.toOwnedSlice(app.gpa); } -/// `PE-224` for each distinct ref, uppercased the way Linear stores keys and -/// sorted, so the same set of worktrees always produces the same list. fn identifiers(app: app_mod.App, refs: []const linear.Ref) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; for (refs) |ref| { @@ -191,13 +147,10 @@ fn lessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } -/// One column's worth of pull requests, and why it is missing when it is. const PrColumn = struct { list: []const github.PullRequest = &.{}, note: ?[]const u8 = null, - /// How old the cached answer is, when that is where these came from. cached_age: ?i64 = null, - /// Came off the network this run, so it is worth storing. fetched: bool = false, }; @@ -208,11 +161,7 @@ const IssueColumn = struct { fetched: bool = false, }; -/// The tasks below run on the thread pool, which is why none of them return an -/// error and none of them touch `app.ui`: a column that cannot be answered -/// reports that in its own result, and the caller decides what to print. fn branchStatusTask(repo: git.Repo, out: *[]const git.BranchStatus) void { - // One call for every branch's upstream, drift and tip date. out.* = repo.branchStatuses() catch &.{}; } @@ -220,9 +169,6 @@ fn dirtyTask(repo: git.Repo, worktree_path: []const u8, out: *?u32) void { out.* = repo.dirtyCount(worktree_path); } -/// The TOKENS cell for each worktree, in the order given. Reading transcripts is -/// the one part of this dashboard that scales with how much Claude Code has been -/// used rather than with the size of the repo, so `--no-tokens` turns it off. fn tokenTask(app: app_mod.App, choices: []const app_mod.Choice, cells: [][]const u8) void { const cp_root = cp.root(app.gpa, app.environ) catch return; const projects = cp.list(app.gpa, app.io, cp_root) catch return; @@ -270,8 +216,6 @@ fn issueTask(app: app_mod.App, refs: []const linear.Ref, out: *IssueColumn) void out.fetched = true; } -/// Says which columns were answered from the cache and how stale they are. A -/// cache nobody can see is a cache that gets blamed for showing the wrong thing. fn cacheNote(gpa: std.mem.Allocator, prs: PrColumn, issues: IssueColumn) !?[]const u8 { const which: []const u8 = if (prs.cached_age != null and issues.cached_age != null) "PR and LINEAR" @@ -282,10 +226,7 @@ fn cacheNote(gpa: std.mem.Allocator, prs: PrColumn, issues: IssueColumn) !?[]con else return null; - // The older of the two is the honest number to show for both. const age = @max(prs.cached_age orelse 0, issues.cached_age orelse 0); - // `ui.age` renders anything under a minute as "now", which does not take an - // "ago" — and inside a five-minute TTL that is the common case. const when: []const u8 = if (age < 60) "just now" else @@ -362,7 +303,6 @@ fn buildRow( .sync = sync, .remote_gone = remote_gone, .age = age, - // Filled in by the caller — the token scan is one pass for every row. .tokens = "—", .pr = pr, .pr_state = pr_state, @@ -472,8 +412,6 @@ fn paintStatus(row: Row, text: []const u8) ui.Painted { }; } -/// Drift is context, not a verdict — only a deleted upstream is worth an alarm, -/// because it means the branch has nowhere left to push. fn paintSync(row: Row, text: []const u8) ui.Painted { return if (row.remote_gone) ui.yellow(text) else ui.dim(text); } @@ -487,8 +425,6 @@ fn paintPr(row: Row, text: []const u8) ui.Painted { }; } -/// `ui.pad` renders lazily, which colour wrapping cannot use — the escape has to -/// go around text of a known length. fn pad(gpa: std.mem.Allocator, text: []const u8, width: usize) ![]const u8 { return std.fmt.allocPrint(gpa, "{f}", .{ui.pad(text, width)}); } @@ -500,12 +436,10 @@ test "cacheNote names only the columns that came from the cache" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // Nothing cached — nothing to say. try std.testing.expect(try cacheNote(arena, .{}, .{}) == null); const both = (try cacheNote(arena, .{ .cached_age = 120 }, .{ .cached_age = 200 })).?; try std.testing.expect(std.mem.startsWith(u8, both, "PR and LINEAR from cache")); - // The older of the two is what gets shown, not whichever was checked first. try std.testing.expect(std.mem.indexOf(u8, both, "3m ago") != null); const pr_only = (try cacheNote(arena, .{ .cached_age = 90 }, .{})).?; @@ -515,8 +449,6 @@ test "cacheNote names only the columns that came from the cache" { const issue_only = (try cacheNote(arena, .{}, .{ .cached_age = 90 })).?; try std.testing.expect(std.mem.startsWith(u8, issue_only, "LINEAR from cache")); - // Under a minute reads as "just now": `ui.age` says "now", which cannot take - // an "ago" after it. const fresh = (try cacheNote(arena, .{ .cached_age = 3 }, .{ .cached_age = 3 })).?; try std.testing.expect(std.mem.indexOf(u8, fresh, "asked just now") != null); try std.testing.expect(std.mem.indexOf(u8, fresh, "now ago") == null); @@ -536,8 +468,6 @@ test "identifiers dedupes, uppercases and sorts what Linear will be asked" { .ui = undefined, }; - // Two worktrees on the same issue ask about it once, and a branch carrying - // the key lowercased must land on the same identifier as one that does not. const refs = [_]linear.Ref{ .{ .team = "pe", .number = 270 }, .{ .team = "PE", .number = 7 }, @@ -578,19 +508,16 @@ test "measure sizes every column to its widest cell, header included" { const full = measure(&rows, .{}); try std.testing.expectEqual(@as(usize, "feature/pe-256-app-hangs".len), full.branch); try std.testing.expectEqual(@as(usize, "3 dirty".len), full.status); - // Codepoints, not bytes: the arrows are multi-byte. try std.testing.expectEqual(@as(usize, 5), full.sync); try std.testing.expectEqual(@as(usize, "AGE".len), full.age); try std.testing.expectEqual(@as(usize, "#412 open".len), full.pr); try std.testing.expectEqual(@as(usize, "In Progress".len), full.issue); try std.testing.expectEqual(@as(usize, "TOKENS".len), full.tokens); - // --local drops the two network columns entirely. const local = measure(&rows, .{ .local = true }); try std.testing.expectEqual(@as(usize, 0), local.pr); try std.testing.expectEqual(@as(usize, 0), local.issue); - // --no-tokens drops its column, and nothing else moves. const quiet = measure(&rows, .{ .tokens = false }); try std.testing.expectEqual(@as(usize, 0), quiet.tokens); try std.testing.expectEqual(full.branch, quiet.branch); diff --git a/src/commands/open.zig b/src/commands/open.zig index 9cd4294..03264a3 100644 --- a/src/commands/open.zig +++ b/src/commands/open.zig @@ -44,13 +44,9 @@ fn openInClaude( picked: app_mod.Choice, no_resume: bool, ) !void { - // `--resume` in a directory Claude Code has never run in opens a picker with - // nothing to pick, so only ask for it once a transcript exists. const resumable = !no_resume and claude_projects.hasSessionsFor(app.gpa, app.io, app.environ, picked.entry.path); - // Same reason `lcc start` does it: local-scope MCP servers are keyed on the - // directory they were added in, so a worktree sees none of the repo's own. const carried = try mcp.carry(app.gpa, app.io, app.environ, repo.root); const label = picked.entry.branch orelse app_mod.shortHead(picked.entry.head); @@ -61,7 +57,6 @@ fn openInClaude( }); if (!resumable and !no_resume) app.ui.hint("No sessions here yet — starting fresh.", .{}); - // What this task has cost so far, before adding to it. const spent = usage.forWorktree(app.gpa, app.io, app.environ, picked.entry.path); if (!spent.empty()) { app.ui.hint("Spent here: {f}", .{usage.brief(spent, app_mod.nowSeconds(app.io))}); diff --git a/src/commands/remove.zig b/src/commands/remove.zig index 43760ef..3ccab33 100644 --- a/src/commands/remove.zig +++ b/src/commands/remove.zig @@ -1,6 +1,3 @@ -//! `lcc remove` — drop selected worktrees, their Xcode build data, and branches. -//! `--merged` does the same in bulk for everything whose work is already safe. - const std = @import("std"); const app_mod = @import("../app.zig"); const cp = @import("../claude_projects.zig"); @@ -19,31 +16,16 @@ pub const Opts = struct { yes: bool = false, keep_derived_data: bool = false, keep_branch: bool = false, - /// Leave a running Xcode alone — it is not even asked what it has open. The - /// escape hatch for anyone who would rather deal with their own windows than - /// have a CLI reach into them. keep_xcode: bool = false, - /// Decide from local refs alone — no fetch, and no asking GitHub what became - /// of a branch's pull request. Everything still works; a squash-merged branch - /// whose remote branch is still there just goes back to reading as unmerged. local: bool = false, - /// Delete the Claude Code session transcripts too. Off by default: build - /// data regenerates on the next build, a transcript never comes back. sessions: bool = false, - /// Bulk mode — every worktree and branch whose commits already survive. merged: bool = false, }; -/// Everything lcc found attached to one worktree, measured in a single `du`. const Attached = struct { derived: []dd.Sized = &.{}, sessions: []cp.Sized = &.{}, - /// What a running Xcode still has open in it. Closed before the directory goes, - /// so no window is left on a path that no longer exists. xcode: xcode.Open = .{}, - /// What those sessions spent. A transcript's disk size says nothing about - /// the work it holds — this is the number that makes deleting one a - /// decision rather than a shrug. spent: usage.Totals = .{}, fn reclaimable(self: Attached, sessions_go: bool) u64 { @@ -56,8 +38,6 @@ const Attached = struct { } }; -/// One explicitly selected worktree and everything the removal needs to decide -/// and explain before changing the filesystem. const Removal = struct { choice: app_mod.Choice, attached: Attached = .{}, @@ -82,8 +62,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { return; } - // Match before removing: once a directory is gone the folders still resolve by - // path, but the user needs to see everything about to go in one confirmation. const dd_root = try dd.root(app.gpa, app.io, app.environ); const cp_root = try cp.root(app.gpa, app.environ); const removals = try prepareRemovals(app, repo, selected, opts, dd_root, cp_root); @@ -130,9 +108,6 @@ fn choicesAt( return subset; } -/// Resolves branch safety, Xcode windows, build data, sessions, sizes, and usage -/// for the entire selection in batches. A large selection should not mean one -/// fetch, `du`, AppleScript query, or transcript scan per worktree. fn prepareRemovals( app: app_mod.App, repo: git.Repo, @@ -152,8 +127,6 @@ fn prepareRemovals( }; } - // Local refs cannot recognize a squash merge while its remote branch still - // exists. Ask GitHub once for every selected branch that needs that answer. if (!opts.local and !opts.keep_branch) { var asked: std.ArrayList([]const u8) = .empty; for (removals) |removal| { @@ -175,8 +148,6 @@ fn prepareRemovals( &.{} else try dd.list(app.gpa, app.io, dd_root); - // Sessions are matched even when they are being kept: the confirmation must - // say that they exist rather than silently leaving them behind. const cp_all = try cp.list(app.gpa, app.io, cp_root); const held: xcode.Open = if (opts.keep_xcode) .{} else try xcode.openDocuments(app.gpa, app.io); if (held.unanswered) { @@ -253,9 +224,6 @@ fn confirmRemovalsMessage(app: app_mod.App, removals: []const Removal, opts: Opt return out.toOwnedSlice(app.gpa); } -/// Xcode cannot save through AppleScript, so an unsaved document removes its -/// whole worktree from the actionable subset. Reporting that before the combined -/// confirmation keeps every line in the prompt truthful about what Enter does. fn withoutUnsavedWork(app: app_mod.App, removals: []const Removal, opts: Opts) ![]const Removal { if (opts.force) return removals; @@ -291,8 +259,6 @@ fn removeSelected( const entry = removal.choice.entry; const label = entry.branch orelse app_mod.shortHead(entry.head); - // Unsaved editor work is not lcc's to throw away. Skip this row without - // stopping the rest of an explicitly selected batch. if (removal.attached.xcode.unsaved.len > 0 and !opts.force) { app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(label)}); for (removal.attached.xcode.unsaved) |doc| app.ui.hint(" {s}", .{doc.path}); @@ -363,11 +329,6 @@ fn removeSelected( const automation_hint = "Allow it under System Settings → Privacy & Security → Automation, or use --keep-xcode."; -/// Hands the worktree back before git deletes it. True when a window actually -/// closed, which a removal that then fails owes the user an explanation for. -/// -/// Never fatal, like the fetch: a window lcc could not close is a stale window the -/// user closes themselves, which is exactly where they were before lcc tried. fn closeXcode(app: app_mod.App, held: xcode.Open) bool { if (held.workspaces.len == 0) return false; @@ -385,21 +346,11 @@ fn closeXcode(app: app_mod.App, held: xcode.Open) bool { return true; } -/// The worktree survived a removal that had already closed its window — Xcode -/// opening a project is itself enough to leave untracked files behind, so this is -/// a normal way for the sequence to end, not a rare one. fn noteReopen(app: app_mod.App, closed: bool) void { if (!closed) return; app.ui.hint(" Its Xcode window is closed — reopen with: lcc open xcode", .{}); } -/// Brings the remote-tracking refs up to date before anything is judged by them. -/// -/// Never fatal. A repo with no remote, a machine that is offline, a host that -/// asks for credentials nobody is there to type — each of those means the refs -/// stay as they were, which is where every decision was being made from before. -/// It costs accuracy, not correctness: a stale ref only ever makes a branch look -/// *less* safe than it is, and lcc keeps what it cannot vouch for. fn refresh(app: app_mod.App, repo: git.Repo) void { app.ui.step("Fetching…", .{}); app.ui.flush(); @@ -408,12 +359,6 @@ fn refresh(app: app_mod.App, repo: git.Repo) void { }; } -/// What GitHub says about `branches`, or null when it could not be asked. -/// -/// Reads `lcc list`'s cache before the network, which is free and cannot mislead -/// here: an answer this reuses is at most `rc.ttl_seconds` old, `merged` is a -/// terminal state so a stored one cannot have become false, and a stale `open` -/// only leaves a branch reading as unmerged — the verdict it already had. fn pullRequests( app: app_mod.App, repo: git.Repo, @@ -465,7 +410,6 @@ fn appendConfirmationDetails( doc.name(), })); } - // Only reachable under --force; without it, unsaved work stops the run. for (attached.xcode.unsaved) |doc| { try out.appendSlice(w, try std.fmt.allocPrint(w, " unsaved {s} (in Xcode — the changes go too)\n", .{ doc.name(), @@ -516,7 +460,7 @@ fn disposeBranch( branch_name: ?[]const u8, disposition: ?git.BranchDisposition, ) !void { - const branch = branch_name orelse return; // Detached worktree — no branch to speak of. + const branch = branch_name orelse return; const d = disposition orelse { app.ui.hint("Branch left intact. Delete with: git branch -D {s}", .{branch}); return; @@ -576,10 +520,7 @@ fn purgeSessions(app: app_mod.App, sized: []const cp.Sized, root: []const u8) !u return reclaimed; } -/// One candidate for bulk removal: a worktree whose branch is safely merged, or -/// a branch that outlived its worktree. const Row = struct { - /// Null when only the branch is left. worktree: ?git.WorktreeEntry, branch: []const u8, disposition: git.BranchDisposition, @@ -603,20 +544,14 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { const worktrees = try repo.listWorktrees(); - // A branch checked out anywhere — including the main worktree — is off limits: - // git refuses to delete it, and lcc must not pretend otherwise. var checked_out: std.StringArrayHashMapUnmanaged(void) = .empty; for (worktrees) |entry| { if (entry.branch) |branch| try checked_out.put(app.gpa, branch, {}); } - // Every branch worth a verdict, judged by local refs first. Unsafe ones are - // kept in the list rather than dropped here: GitHub may still vouch for them, - // and the set of those is what decides how much it gets asked about. var candidates: std.ArrayList(Row) = .empty; for (worktrees) |entry| { if (entry.is_main) continue; - // Detached: no branch, so nothing tells us the commits survived. const branch = entry.branch orelse continue; try candidates.append(app.gpa, .{ .worktree = entry, @@ -646,7 +581,6 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { return; } - // `dd.root` shells out to `defaults`, so resolve both once and pass them down. const dd_root = try dd.root(app.gpa, app.io, app.environ); const cp_root = try cp.root(app.gpa, app.environ); try attach(app, rows.items, opts, dd_root, cp_root); @@ -663,8 +597,6 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { for (picked) |row| { if (row.worktree) |entry| { - // Same rule as the single-worktree path: unsaved work outranks a merged - // branch. The row is skipped whole, since the worktree still holds it. if (row.attached.xcode.unsaved.len > 0 and !opts.force) { app.ui.warn("Kept {f} — Xcode has unsaved changes in it", .{ui.cyan(row.branch)}); app.ui.hint(" Save them there, or rerun with: lcc remove --merged --force", .{}); @@ -711,14 +643,6 @@ fn runMerged(app: app_mod.App, repo: git.Repo, opts: Opts) !void { } } -/// Upgrades the rows local refs could not vouch for, where GitHub says the pull -/// request was merged. -/// -/// Only those rows go into the question. A branch already safe needs no help, the -/// default branch is not deletable whatever it says, and asking about either would -/// grow the query with rows whose answer changes nothing — on a repo with fifty -/// stale branches that is the difference between a handful of connections and all -/// fifty. fn consultGitHub(app: app_mod.App, repo: git.Repo, rows: []Row) !void { var asked: std.ArrayList([]const u8) = .empty; for (rows) |row| { @@ -736,8 +660,6 @@ fn consultGitHub(app: app_mod.App, repo: git.Repo, rows: []Row) !void { } } -/// Matches build data and session folders to every row, then sizes them all with -/// a single `du` — one child process for the whole batch rather than one per row. fn attach( app: app_mod.App, rows: []Row, @@ -751,8 +673,6 @@ fn attach( try dd.list(app.gpa, app.io, dd_root); const cp_all = try cp.list(app.gpa, app.io, cp_root); - // Xcode is asked once for the whole batch, the same way `du` is: what it has - // open does not change per row, only which row each document belongs to. const held: xcode.Open = if (opts.keep_xcode) .{} else try xcode.openDocuments(app.gpa, app.io); if (held.unanswered) { app.ui.warn("Could not ask Xcode what it has open — it may be holding some of these.", .{}); @@ -784,8 +704,6 @@ fn attach( } const sizes = try disk.usage(app.gpa, app.io, paths.items); - // One scanner for the batch, so a message that appears in two worktrees' - // transcripts is still only counted once. var scanner: usage.Scanner = .init(app.gpa, app.io, .open(app.gpa, app.io, app.environ)); defer scanner.deinit(); @@ -810,8 +728,6 @@ fn attach( } } -/// One line of the `--merged` checkbox list: what it is, why it is safe, what -/// removing it frees, and what it spent getting here. fn rowLabel( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -826,8 +742,6 @@ fn rowLabel( else try std.fmt.allocPrint(gpa, "{f}", .{ui.bytes(size)}); - // What the worktree spent, so a row is not judged on disk size alone — the - // transcripts go with it when `--sessions` is on. const spent = if (row.attached.spent.empty()) try gpa.dupe(u8, "—") else @@ -840,8 +754,6 @@ fn rowLabel( else "branch only — no worktree left"; - // A row Xcode is holding says so: closing that window is part of what ticking - // the row does, and unsaved work in it is what will hold the row back. const note = if (row.attached.xcode.unsaved.len > 0) " — unsaved in Xcode" else if (row.attached.xcode.workspaces.len > 0) @@ -971,11 +883,9 @@ test "a bulk row shows what it frees and what it spent" { const label = try rowLabel(arena, &environ, row, 22, 11, .{}); try std.testing.expect(std.mem.indexOf(u8, label, "53M") != null); - // No build data and sessions kept, so there is nothing to reclaim yet. try std.testing.expect(std.mem.indexOf(u8, label, "—") != null); try std.testing.expect(std.mem.indexOf(u8, label, "~/Projects/App/.lcc/worktrees/pe-101") != null); - // A branch whose worktree is already gone has no usage to report. var orphan = row; orphan.worktree = null; orphan.attached = .{}; @@ -983,8 +893,6 @@ test "a bulk row shows what it frees and what it spent" { try std.testing.expect(std.mem.indexOf(u8, orphan_label, "53M") == null); try std.testing.expect(std.mem.indexOf(u8, orphan_label, "branch only") != null); - // A row GitHub vouched for names the pull request that did it, so the reason - // column says where the answer came from rather than just "safe". var by_pr = row; by_pr.disposition = (git.BranchDisposition{ .branch = "feature/pe-101-shipped", @@ -995,8 +903,6 @@ test "a bulk row shows what it frees and what it spent" { const pr_label = try rowLabel(arena, &environ, by_pr, 22, 11, .{}); try std.testing.expect(std.mem.indexOf(u8, pr_label, "merged #412") != null); - // A worktree Xcode still has open says so, and unsaved work in it says that - // instead — the row is about to be held back rather than closed. const doc: xcode.Document = .{ .app = "/Applications/Xcode.app", .path = "/Users/me/Projects/App/.lcc/worktrees/pe-101/App.xcodeproj", diff --git a/src/commands/setup.zig b/src/commands/setup.zig index c803a3c..1fbefea 100644 --- a/src/commands/setup.zig +++ b/src/commands/setup.zig @@ -1,10 +1,3 @@ -//! `lcc setup` — the name people already type for `lcc config`. -//! -//! It used to be its own walk through five settings in a fixed order, which -//! meant every setting added afterwards was simply absent from it, and there -//! were two places to remember when adding one. There is now a single settings -//! table and a single editor; this is a second door into them. - const app_mod = @import("../app.zig"); const config_cmd = @import("config.zig"); diff --git a/src/commands/start.zig b/src/commands/start.zig index 5270fe1..fb6db8f 100644 --- a/src/commands/start.zig +++ b/src/commands/start.zig @@ -19,43 +19,18 @@ const watch_cmd = @import("watch.zig"); const priority_label = [_][]const u8{ " ", "U ", "H ", "M ", "L " }; pub const Opts = struct { - /// Show every assigned issue in the picker, not just `activeStates`. all: bool = false, - /// `PE-256` — resolve that issue directly and skip the picker. issue: ?[]const u8 = null, json: bool = false, - /// Base for a branch that does not exist yet. Interactive mode asks when it - /// cannot tell; `--json` has nobody to ask, so it takes this or the default branch. base: ?[]const u8 = null, - /// The repository the issue's code lives in, when neither what lcc remembers nor - /// where the work already is can answer that — see `resolveRepo`. repo: ?[]const u8 = null, - /// A plan the agent should start from, reachable through `{plan}` in - /// `startTaskCommand`. Only the path travels — see `expandCommand`. plan: ?[]const u8 = null, - /// Open in Claude Code's plan mode. A `--plan` file turns it off regardless, - /// since the session already has one. Resolved against `planMode` by the - /// argv layer. plan_mode: bool = true, - /// Hand the session to the daemon instead of taking over this terminal, so - /// it survives the terminal closing. Everything above the launch is - /// unchanged — this only replaces the last step. - /// - /// Resolved against `watchByDefault`, which is on, by the argv layer. watch: bool = true, - /// With `--watch`, print the session id and return instead of showing the - /// dashboard. What a slash command or a script uses. no_attach: bool = false, - /// Cancelling a picker returns instead of exiting 130. - /// - /// Set only when this runs *inside* something else — the dashboard's `n`. - /// There, changing your mind about which issue to take should put you back - /// where you were, not quit lcc out from under the sessions you were - /// watching. Standalone, 130 is the conventional answer and stays. cancel_returns: bool = false, }; -/// What a cancelled picker does, which depends on who is asking. fn cancel(opts: Opts) error{Cancelled} { if (opts.cancel_returns) return error.Cancelled; std.process.exit(app_mod.cancelled_exit_code); @@ -68,15 +43,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { if (opts.json and opts.all) { bail(app, opts.json, "usage", "--all only affects the picker, which --json does not use.", .{}); } - // Resolved first, and to an absolute path: the agent is launched with the - // worktree as its cwd, so a relative path would be read against the wrong - // directory. A plan that is not there costs a lookup to find out here, and a - // created worktree to find out later. - // - // `realPathFileAlloc` resolves any path that exists, directories included, and - // an absent file is only one of the ways this fails — so "not found" is a claim - // to make once it is true, not a catch-all. Told a plan is missing when it is - // sitting there unreadable, you go looking for the wrong thing. const plan_path: ?[]const u8 = if (opts.plan) |raw| blk: { const resolved = Io.Dir.cwd().realPathFileAlloc(app.io, raw, app.gpa) catch |err| switch (err) { error.FileNotFound => bail(app, opts.json, "plan_not_found", "No plan file at {s}.", .{raw}), @@ -89,11 +55,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { } break :blk resolved; } else null; - // Said before the read, not after: the Keychain grants access to a binary by its - // code signature, so a freshly built lcc can block here on a system dialog for - // the login password. Announced, that is a wait with a reason; unannounced, it - // is a process sitting silently with no output and no child, which is - // indistinguishable from a hang in lcc itself. app.ui.hint("Reading the Linear token from the Keychain...", .{}); app.ui.flush(); if (oauth.getToken(app.gpa) == null) { @@ -102,17 +63,8 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const cfg = try config.load(app.gpa, app.io, app.environ); - // Either the work is planned here or a plan was brought to it. One name, so the - // banner and the launch cannot come to different conclusions about which. const plan_mode = plan_path == null and opts.plan_mode; - // `{plan}` is the only thing that carries a plan to the agent, and - // `startTaskCommand` is empty by default — so without the placeholder, `--plan` - // has one remaining effect: switching plan mode off. That leaves the session - // neither planning here nor holding a plan from elsewhere, which is the one - // state this design says cannot exist. The template alone answers it, so it is - // answered here: a command that refuses must not first cut a branch and a - // worktree it then abandons. if (plan_path != null and !templateCarriesPlan(cfg.startTaskCommand)) { bail( app, @@ -135,14 +87,11 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const suggested = try git.rewriteBranchName(app.gpa, selected.branch_name, "feature"); if (!opts.json) app.ui.hint("Selected {s} — branch {s}", .{ selected.identifier, suggested }); - // Which repository, before anything is created in one. Resolved after the issue - // is known, because the issue is what the answer is remembered against. var repo = try resolveRepo(app, opts, selected.identifier); repo.stdout_reserved = opts.json; const wt = try bootstrap(app, opts, cfg, repo, suggested); - // Written down only now: an answer is worth keeping once it produced a worktree. const learned = try repos.remember( app.gpa, repos.load(app.gpa, app.io, app.environ), @@ -151,8 +100,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { ); repos.save(app.gpa, app.io, app.environ, learned) catch {}; - // The main checkout already *is* the directory those servers are keyed on, so - // handing them back would only duplicate what the agent loads by itself. const claude_carried = if (wt.is_main_checkout) null else @@ -160,8 +107,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const carried: ?McpFact = if (claude_carried) |value| .{ .path = value.path, .names = value.names } else null; - // Expanded once, above the split: `--json` reports the very string a launch - // would send, and cannot drift from it by being built twice. const trimmed_command = std.mem.trim(u8, cfg.startTaskCommand, " \t"); const expanded = if (trimmed_command.len > 0) try expandCommand(app.gpa, cfg.startTaskCommand, selected, wt.branch, plan_path) @@ -183,8 +128,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { app.ui.info("{f} in {f}", .{ ui.bold(launch_label), ui.dim(wt.path) }); app.ui.hint("Linear: {s}", .{selected.url}); if (plan_path) |path| app.ui.hint("Plan: {s}", .{path}); - // Only says anything when this issue has been worked on before — picking up - // a task should show what it has already cost. const spent = usage.forWorktree(app.gpa, app.io, app.environ, wt.path); if (!spent.empty()) { app.ui.hint("Spent here: {f}", .{usage.brief(spent, app_mod.nowSeconds(app.io))}); @@ -206,9 +149,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { plan_mode, ); - // The only branch `--watch` adds. Everything above — the token, the issue, - // the repository, the worktree, the links, the argv — is shared, so the two - // launch paths cannot come to different conclusions about what to run. if (opts.watch) { if (watch_client.startSession(app, .{ .worktree = wt.path, @@ -226,16 +166,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { app.ui.flush(); return watch_cmd.run(app, .{}); } else |err| { - // Falls through to the foreground launch rather than failing. - // - // This is the path *every* start takes now, so a daemon that cannot - // be reached must not be able to take `lcc start` down with it. Said - // out loud, though: a silent fallback would hide a broken daemon - // until someone noticed their sessions had stopped surviving. - // - // The error name is kept and the daemon is not: `{s}` is the part - // that tells anyone debugging this what actually went wrong, while - // which process failed to answer is not a fact the reader can act on. app.ui.warn("Could not start this in the background ({s}) — running in this terminal instead.", .{@errorName(err)}); app.ui.hint("The session will not survive this terminal closing.", .{}); } @@ -245,7 +175,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { std.process.exit(code); } -/// The one issue an identifier names. fn fetchNamed( app: app_mod.App, opts: Opts, @@ -276,8 +205,6 @@ fn fetchNamed( return found orelse bail(app, opts.json, "issue_not_found", "No issue {s} in Linear.", .{trimmed}); } -/// The picker over everything assigned, filtered by `activeStates`. Null when -/// there was nothing to choose from — the reason is already on screen. fn pickFromActive( app: app_mod.App, opts: Opts, @@ -337,20 +264,12 @@ fn pickFromActive( const MatchedBy = enum { branch, issue }; const Bootstrapped = struct { - /// The branch actually checked out there. Not always the one Linear suggests - /// today — see `matched_by`. branch: []const u8, path: []const u8, - /// Whether this run made it, or found it already there. status: enum { created, existing }, - /// How an existing worktree was recognised: `branch` for an exact name match, - /// `issue` when only the `PE-N` in it matched. Null for one this run created. matched_by: ?MatchedBy, - /// How the branch came to be, for a worktree this run created. created: ?git.Strategy, - /// What a new branch was cut from. base: ?[]const u8, - /// The main checkout has the branch checked out, so no worktree was involved. is_main_checkout: bool, linked: []const []const u8, skipped: []const []const u8, @@ -361,11 +280,6 @@ const Match = struct { by: MatchedBy, }; -/// The worktree an issue's work lives in. The exact branch name first, so a repo -/// holding both the old and the new name resolves to the new one; then the `PE-N` -/// ref, which is the half that survives the issue being renamed in Linear — -/// otherwise a renamed issue looks like untouched work and gets a second, empty -/// worktree while the real one sits next to it. fn findWorktree(entries: []const git.WorktreeEntry, branch: []const u8) ?Match { if (git.worktreeForBranch(entries, branch)) |entry| return .{ .entry = entry, .by = .branch }; for (entries) |entry| { @@ -375,10 +289,6 @@ fn findWorktree(entries: []const git.WorktreeEntry, branch: []const u8) ?Match { return null; } -/// A local branch carrying the same issue as `suggested` under a different name — -/// what a rename in Linear leaves behind, since `branchName` is derived from the -/// title while the commits stay where they were. The most recently committed one when -/// there are several: renames can happen more than once, and the newest is the work. fn newestBranchForIssue(statuses: []const git.BranchStatus, suggested: []const u8) ?[]const u8 { var best: ?git.BranchStatus = null; for (statuses) |status| { @@ -389,9 +299,6 @@ fn newestBranchForIssue(statuses: []const git.BranchStatus, suggested: []const u return if (best) |status| status.branch else null; } -/// The worktree for `suggested`, made if the issue has none yet. Finding one is a -/// normal outcome, not a failure: `lcc start PE-256` twice, or once from inside the -/// worktree it made the first time, both land here. fn bootstrap( app: app_mod.App, opts: Opts, @@ -409,8 +316,6 @@ fn bootstrap( const entries = try repo.listWorktrees(); if (findWorktree(entries, suggested)) |match| { - // git checks a branch out in one place at a time, so this is *the* place. - // The branch there wins over the suggestion: it is where the commits are. branch = match.entry.branch.?; path = match.entry.path; status = .existing; @@ -430,9 +335,6 @@ fn bootstrap( } } } else { - // No worktree, but the branch may still be there under an older name — the - // same rename that `findWorktree` deals with, one step earlier. Cutting a - // second branch beside it would leave the commits behind. if (repo.resolveStrategy(branch) == .new) { if (newestBranchForIssue(try repo.branchStatuses(), branch)) |existing| { if (!opts.json) app.ui.warn( @@ -463,7 +365,6 @@ fn bootstrap( "Worktree path already exists but no worktree is registered there: {s}\nRemove it first with: git worktree remove {s} (or delete the directory).", .{ path, path }, ), - // Captured in `--json` mode, already on the terminal otherwise. git.Error.GitFailed => if (git.last_error.len > 0) bail(app, opts.json, "git_failed", "git worktree add failed: {s}", .{git.last_error}) else @@ -515,13 +416,6 @@ fn bootstrap( }; } -/// Which repository the issue's work belongs in — the question `git` cannot answer -/// and Linear does not carry, so getting it wrong builds a branch and a worktree that -/// look entirely correct in a repo that has nothing to do with the issue. -/// -/// Answered in order of how much it can be trusted: what lcc was told before, then -/// work that already exists, then a question. Never a guess from the issue text — -/// the words in a title are exactly the words that turn up in unrelated repos. fn resolveRepo(app: app_mod.App, opts: Opts, identifier: []const u8) !git.Repo { if (opts.repo) |given| return app.repoAt(given) catch bail( app, @@ -533,8 +427,6 @@ fn resolveRepo(app: app_mod.App, opts: Opts, identifier: []const u8) !git.Repo { const state = repos.load(app.gpa, app.io, app.environ); - // The answer from last time. This is what makes `lcc start PE-236` work from - // anywhere, including a directory that is not a repository at all. if (repos.recall(state, identifier)) |remembered| { if (app.repoAt(remembered)) |found| { if (!opts.json) app.ui.hint("{s} lives in {f}", .{ @@ -542,18 +434,12 @@ fn resolveRepo(app: app_mod.App, opts: Opts, identifier: []const u8) !git.Repo { ui.bold(std.fs.path.basename(found.root)), }); return found; - } else |_| { - // The repo moved or was deleted; fall through and ask again. - } + } else |_| {} } - // Standing in a repository is the ordinary way of saying which one is meant, so - // it leads the shortlist and is checked for existing work first. const here: ?[]const u8 = if (app.repo()) |found| found.root else |_| null; const known = try repos.candidates(app.gpa, app.io, state, here, here); - // A branch for this issue somewhere is the answer, without anyone being asked: - // that is where the commits are. const started = try repos.withIssueBranch(app.gpa, app.io, known, identifier); if (started.len == 1) { const found = try app.repoAt(started[0]); @@ -566,8 +452,6 @@ fn resolveRepo(app: app_mod.App, opts: Opts, identifier: []const u8) !git.Repo { return found; } - // Nothing knows, and `--json` has nobody to ask. Refusing beats creating a - // worktree in whichever repository the caller happened to be standing in. if (opts.json) bail( app, opts.json, @@ -578,8 +462,6 @@ fn resolveRepo(app: app_mod.App, opts: Opts, identifier: []const u8) !git.Repo { .{ identifier, if (here) |root| root else "" }, ); - // Several repositories hold a branch for this issue — rare, and exactly when the - // shortlist is worth more than the full list. const offer = if (started.len > 1) started else known; if (offer.len == 0) return error.NotAGitRepository; return app.repoAt(try pickRepo(app, opts, identifier, offer)); @@ -609,20 +491,15 @@ fn pickRepo(app: app_mod.App, opts: Opts, identifier: []const u8, roots: []const return roots[index]; } -/// What a branch that does not exist yet gets cut from. An explicit `--base` wins; -/// otherwise the default branch, unless standing somewhere else is worth asking about. fn resolveBase(app: app_mod.App, opts: Opts, repo: git.Repo, branch: []const u8) ![]const u8 { if (opts.base) |explicit| return explicit; const def = try repo.defaultBranch(); - // An existing branch is checked out as it is; nothing is cut from anything. if (repo.resolveStrategy(branch) != .new) return def; const cur = try repo.currentBranch(); if (cur == null or std.mem.eql(u8, cur.?, def)) return def; - // Nobody to ask in machine mode, and silently cutting from whatever branch the - // caller happens to stand on would be the wrong kind of guess. if (opts.json) return def; app.ui.flush(); @@ -635,10 +512,6 @@ fn resolveBase(app: app_mod.App, opts: Opts, repo: git.Repo, branch: []const u8) return cancel(opts); } -/// What `--json` promises: where the issue lives, and the git facts a caller would -/// otherwise have to shell out for. A declared type rather than a literal inside -/// the printer, because it is a contract another program parses — the test at the -/// bottom of this file is what keeps the field names from drifting. const Report = struct { issue: ReportIssue, branch: ReportBranch, @@ -646,11 +519,6 @@ const Report = struct { repo: ReportRepo, links: ReportLinks, start_task_command: ?[]const u8, - /// The local-scope MCP servers lcc would carry into the worktree, and the file - /// it would pass as `--mcp-config`. Null when there are none to carry, including - /// when `mcpCarry` filters them all. A caller that is *already* running cannot be - /// given servers retroactively — this is here so it can say what a session - /// launched through lcc would have. mcp: ?ReportMcp, }; @@ -671,14 +539,10 @@ const ReportIssue = struct { }; const ReportBranch = struct { - /// The branch to use — the one checked out where the work is. name: []const u8, - /// What Linear's `branchName` implies today, which differs from `name` when the - /// issue was renamed after the branch was cut. suggested: []const u8, renamed: bool, upstream: ?[]const u8, - /// No upstream means the Linear GitHub integration cannot have seen the branch. pushed: bool, ahead: u32, behind: u32, @@ -692,8 +556,6 @@ const ReportWorktree = struct { created: ?[]const u8, base: ?[]const u8, is_main_checkout: bool, - /// Whether this very process is running inside it, which is what tells a caller - /// already in Claude Code that there is nothing left to open. is_cwd: bool, }; @@ -712,8 +574,6 @@ const ReportMcp = struct { servers: []const []const u8, }; -/// The git and config facts a report needs, gathered by the caller so that shaping -/// them stays pure and testable. const Facts = struct { current_branch: ?[]const u8, branch_status: ?git.BranchStatus, @@ -785,8 +645,6 @@ fn report( carried: ?McpFact, start_task_command: ?[]const u8, ) !void { - // The branch that exists, not the one Linear suggests: a renamed issue has its - // upstream and its drift on the branch the commits are actually on. var branch_status: ?git.BranchStatus = null; for (try repo.branchStatuses()) |status| { if (!std.mem.eql(u8, status.branch, wt.branch)) continue; @@ -809,16 +667,12 @@ fn report( app.ui.flush(); } -/// Whether the process is standing in `path`, or below it. Both sides are resolved: -/// `git worktree list` reports the path as it was given, symlinks and all. fn isCwd(app: app_mod.App, path: []const u8) bool { const here = Io.Dir.cwd().realPathFileAlloc(app.io, ".", app.gpa) catch return false; const there = disk.realPath(app.gpa, app.io, path); return std.mem.eql(u8, here, there) or disk.isInside(app.gpa, there, here); } -/// The exits a caller has to be able to react to, in the shape it asked for. JSON -/// goes to stdout and the human line to stderr, so both readers get served. fn bail( app: app_mod.App, json: bool, @@ -890,7 +744,6 @@ fn pickBaseBranch(app: app_mod.App, repo: git.Repo) !?[]const u8 { test "findWorktree prefers the exact branch, then the issue behind it" { const entries = [_]git.WorktreeEntry{ .{ .path = "/r", .branch = "main", .head = "a", .locked = false, .prunable = false, .is_main = true }, - // Cut when PE-250 had a different title; the work is here. .{ .path = "/wt/old", .branch = "feature/pe-250-fix-clvisit-handling-dedupe-arrivaldeparture-double-writes", @@ -902,17 +755,14 @@ test "findWorktree prefers the exact branch, then the issue behind it" { .{ .path = "/wt/other", .branch = "feature/pe-9-unrelated", .head = "c", .locked = false, .prunable = false, .is_main = false }, }; - // What Linear suggests for PE-250 after the rename finds the old worktree anyway. const renamed = findWorktree(&entries, "feature/pe-250-fix-clvisit-capture-dropped-visits").?; try std.testing.expectEqualStrings("/wt/old", renamed.entry.path); try std.testing.expectEqual(MatchedBy.issue, renamed.by); - // An exact name is an exact match, and reported as one. const exact = findWorktree(&entries, "feature/pe-250-fix-clvisit-handling-dedupe-arrivaldeparture-double-writes").?; try std.testing.expectEqualStrings("/wt/old", exact.entry.path); try std.testing.expectEqual(MatchedBy.branch, exact.by); - // With both names present the current one wins over the ref match. const both = entries ++ [_]git.WorktreeEntry{.{ .path = "/wt/new", .branch = "feature/pe-250-fix-clvisit-capture-dropped-visits", @@ -926,14 +776,12 @@ test "findWorktree prefers the exact branch, then the issue behind it" { try std.testing.expectEqual(MatchedBy.branch, preferred.by); try std.testing.expect(findWorktree(&entries, "feature/pe-251-nothing-here") == null); - // The main checkout on `main` must not soak up an issue branch. try std.testing.expect(findWorktree(entries[0..1], "feature/pe-250-x") == null); } test "newestBranchForIssue reuses the renamed branch, and only the right issue's" { const statuses = [_]git.BranchStatus{ .{ .branch = "main", .upstream = null, .ahead = 0, .behind = 0, .gone = false, .committed_at = 900 }, - // Two names for PE-250, from two renames; the newer one holds the work. .{ .branch = "feature/pe-250-first-name", .upstream = null, .ahead = 0, .behind = 0, .gone = false, .committed_at = 100 }, .{ .branch = "feature/pe-250-second-name", .upstream = null, .ahead = 0, .behind = 0, .gone = false, .committed_at = 200 }, .{ .branch = "feature/pe-25-different-issue", .upstream = null, .ahead = 0, .behind = 0, .gone = false, .committed_at = 999 }, @@ -942,7 +790,6 @@ test "newestBranchForIssue reuses the renamed branch, and only the right issue's const found = newestBranchForIssue(&statuses, "feature/pe-250-what-linear-suggests-now").?; try std.testing.expectEqualStrings("feature/pe-250-second-name", found); - // PE-25 must not soak up PE-250, and the suggestion itself is not a rename of itself. try std.testing.expect(newestBranchForIssue(&statuses, "feature/pe-9-nothing-here") == null); try std.testing.expect(newestBranchForIssue(&statuses, "feature/pe-25-different-issue") == null); } @@ -995,9 +842,6 @@ test "the --json payload keeps the shape a caller parses" { const body = try std.json.Stringify.valueAlloc(gpa, value, .{ .whitespace = .indent_2 }); defer gpa.free(body); - // The shape the caller relies on, spelled out independently of `Report`. Parsing - // rejects unknown fields, so renaming, dropping *or* adding one fails here rather - // than in whatever is reading the JSON. const Schema = struct { issue: struct { id: []const u8, @@ -1039,7 +883,6 @@ test "the --json payload keeps the shape a caller parses" { const parsed = try std.json.parseFromSliceLeaky(Schema, arena_state.allocator(), body, .{}); try std.testing.expectEqualStrings("PE-250", parsed.issue.identifier); - // The branch to use is the one with the commits; the rename is stated, not hidden. try std.testing.expectEqualStrings("feature/pe-250-fix-clvisit-handling-dedupe", parsed.branch.name); try std.testing.expectEqualStrings(issue.branch_name, parsed.branch.suggested); try std.testing.expect(parsed.branch.renamed); @@ -1052,8 +895,6 @@ test "the --json payload keeps the shape a caller parses" { try std.testing.expect(parsed.worktree.is_cwd); try std.testing.expectEqualStrings(".env", parsed.links.linked[0]); try std.testing.expectEqualStrings("/start-task PE-250", parsed.start_task_command.?); - // The servers a session launched through lcc would get, named so a caller that - // is missing one can tell whether lcc would have supplied it. try std.testing.expectEqualStrings("/cfg/mcp/-r.json", parsed.mcp.?.config); try std.testing.expectEqualStrings("linear-server", parsed.mcp.?.servers[0]); } @@ -1088,7 +929,6 @@ test "a created worktree reports no match and its base" { const value = buildReport(issue, issue.branch_name, wt, .{ .current_branch = "main", - // A branch that has just been cut has no `for-each-ref` row to find. .branch_status = null, .repo_root = "/r", .default_branch = "main", @@ -1107,8 +947,6 @@ test "a created worktree reports no match and its base" { try std.testing.expectEqualStrings("main", value.worktree.base.?); try std.testing.expect(value.start_task_command == null); - // Null optionals must be present as nulls, not dropped: a caller reading - // `worktree.created` should find the key whatever the outcome was. const body = try std.json.Stringify.valueAlloc(gpa, value, .{}); defer gpa.free(body); try std.testing.expect(std.mem.indexOf(u8, body, "\"matched_by\":null") != null); @@ -1117,17 +955,6 @@ test "a created worktree reports no match and its base" { try std.testing.expect(std.mem.indexOf(u8, body, "\"mcp\":null") != null); } -/// What `claude` is launched with, in order. -/// -/// `plan_mode` opens the session in Claude Code's own plan mode, which is where a -/// task should start: recon and questions before edits, and an explicit approval -/// before any of it becomes code. Taking a worktree is the moment that decision is -/// cheapest, so it is the default rather than something to remember. -/// -/// The `--` is load-bearing and unconditional. The prompt is a positional, and one -/// that opens with `-` — a markdown bullet, `---` front matter — is read as an -/// option without it. It used to be appended only alongside `--mcp-config`, which -/// made that depend on whether the repo happened to carry MCP servers. fn launchArgs( gpa: std.mem.Allocator, mcp_config: ?[]const u8, @@ -1146,23 +973,16 @@ fn launchArgs( pub const Expanded = struct { text: []u8, - /// Whether `{plan}` was in the template at all. `--plan` has no other channel to - /// the agent, so a false here means the path reached nobody — which the caller - /// refuses rather than launches. used_plan: bool, }; const Placeholder = enum { identifier, branch, url, plan }; -/// One placeholder, and where the template resumes after it. const Recognized = struct { key: Placeholder, end: usize, }; -/// The whole of the placeholder syntax, stated once. Anything this declines — -/// an unclosed brace, an unknown key — is template text, which is why both scans -/// step a single byte and keep looking rather than giving up on the rest. fn placeholderAt(template: []const u8, i: usize) ?Recognized { if (template[i] != '{') return null; const close = std.mem.indexOfScalarPos(u8, template, i, '}') orelse return null; @@ -1170,10 +990,6 @@ fn placeholderAt(template: []const u8, i: usize) ?Recognized { return .{ .key = key, .end = close + 1 }; } -/// Whether `startTaskCommand` has a `{plan}` for a plan path to travel through — -/// asked of the template on its own, before there is an issue, a branch or a -/// worktree to ask it against. `expandCommand` answers the same question as -/// `used_plan`, and both read `placeholderAt`, so the two cannot drift. pub fn templateCarriesPlan(template: []const u8) bool { var i: usize = 0; while (i < template.len) { @@ -1187,10 +1003,6 @@ pub fn templateCarriesPlan(template: []const u8) bool { return false; } -/// `{plan}` is the absolute path to a plan file, never its contents: the result -/// becomes one `argv` element and, in `--json`, one field of a payload a caller -/// parses. A 20KB plan inlined there would bloat both for bytes the agent can -/// read off disk itself. Absent `--plan`, it expands to nothing. pub fn expandCommand( gpa: std.mem.Allocator, template: []const u8, @@ -1242,30 +1054,23 @@ test "expandCommand fills the placeholders and leaves anything else alone" { template: []const u8, plan: ?[]const u8, want: []const u8, - /// `--plan` is refused when this comes back false, so it is asserted, not incidental. want_used_plan: bool = false, }{ .{ .template = "/start-task {identifier}", .plan = null, .want = "/start-task PE-250" }, .{ .template = "{branch} {url}", .plan = null, .want = "feature/pe-250-actual https://linear.app/x/issue/PE-250/fix" }, - // A plan travels as its path, so the expansion stays one short argv element - // however long the plan itself is. .{ .template = "/start-task {identifier} --plan {plan}", .plan = "/Users/me/.claude/plans/x.md", .want = "/start-task PE-250 --plan /Users/me/.claude/plans/x.md", .want_used_plan = true, }, - // Without --plan the key disappears rather than expanding to a literal — but - // the template still owns a channel, so `used_plan` is true. .{ .template = "read {plan} then go", .plan = null, .want = "read then go", .want_used_plan = true }, - // The case the bug was: a template with no `{plan}` at all silently drops it. .{ .template = "/start-task {identifier}", .plan = "/Users/me/.claude/plans/x.md", .want = "/start-task PE-250", .want_used_plan = false, }, - // Unknown keys and an unclosed brace are template text, not an error. .{ .template = "{nope} {identifier}", .plan = null, .want = "{nope} PE-250" }, .{ .template = "{unclosed", .plan = null, .want = "{unclosed" }, }; @@ -1281,8 +1086,6 @@ test "expandCommand fills the placeholders and leaves anything else alone" { test "launchArgs always separates the prompt from the options with --" { const gpa = std.testing.allocator; - // A plan file opens with `---` front matter or a `-` bullet often enough that - // this is the common case, not the exotic one. const opens_with_dash = "--- \n- step one"; const carried = try launchArgs(gpa, "/cfg/mcp.json", opens_with_dash, false); @@ -1293,15 +1096,12 @@ test "launchArgs always separates the prompt from the options with --" { try std.testing.expectEqualStrings("--", carried[2]); try std.testing.expectEqualStrings(opens_with_dash, carried[3]); - // The regression: with no MCP config to carry there was no `--` either, and - // the prompt reached `claude` as an option. const bare = try launchArgs(gpa, null, opens_with_dash, false); defer gpa.free(bare); try std.testing.expectEqual(@as(usize, 2), bare.len); try std.testing.expectEqualStrings("--", bare[0]); try std.testing.expectEqualStrings(opens_with_dash, bare[1]); - // Nothing to say, nothing to separate. const empty = try launchArgs(gpa, null, null, false); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); @@ -1314,7 +1114,6 @@ test "launchArgs always separates the prompt from the options with --" { test "launchArgs opens in plan mode, and the mode stays ahead of the separator" { const gpa = std.testing.allocator; - // The default: nothing was brought, so the session starts by planning. const planning = try launchArgs(gpa, null, "/start-task PE-250", true); defer gpa.free(planning); try std.testing.expectEqual(@as(usize, 4), planning.len); @@ -1323,8 +1122,6 @@ test "launchArgs opens in plan mode, and the mode stays ahead of the separator" try std.testing.expectEqualStrings("--", planning[2]); try std.testing.expectEqualStrings("/start-task PE-250", planning[3]); - // Everything the mode adds is an option, so it has to land before the `--` - // or it becomes part of the prompt. const with_mcp = try launchArgs(gpa, "/cfg/mcp.json", "/start-task PE-250", true); defer gpa.free(with_mcp); try std.testing.expectEqual(@as(usize, 6), with_mcp.len); @@ -1333,8 +1130,6 @@ test "launchArgs opens in plan mode, and the mode stays ahead of the separator" try std.testing.expectEqualStrings("--mcp-config", with_mcp[2]); try std.testing.expectEqualStrings("--", with_mcp[4]); - // No prompt is no reason to skip the mode: an empty startTaskCommand still - // opens a session, and it should still open it planning. const no_prompt = try launchArgs(gpa, null, null, true); defer gpa.free(no_prompt); try std.testing.expectEqual(@as(usize, 2), no_prompt.len); diff --git a/src/commands/start_plan_test.zig b/src/commands/start_plan_test.zig index c75d0b4..b247eb8 100644 --- a/src/commands/start_plan_test.zig +++ b/src/commands/start_plan_test.zig @@ -1,19 +1,7 @@ -//! Test home for `lcc start`'s plan channel — the rules deciding whether a `--plan` -//! path can reach the agent at all. -//! -//! `start.zig` keeps its tests in-source, next to what they cover. This file exists -//! because the plan-channel tests are authored by an agent that may not touch a -//! production file, and Zig collects tests only from files the test root imports -//! (`main.zig`'s `test` block says so). An out-of-file home is the only shape those -//! two rules leave, so the seam it needs is `pub`. - const std = @import("std"); const linear = @import("../linear.zig"); const start = @import("start.zig"); -/// The issue every plan-channel case expands a template against. Nothing here is ever -/// what a case is about — only the template is — so it is fixed once rather than -/// restated per case. pub const issue_fixture: linear.Issue = .{ .id = "uuid-1", .identifier = "PE-250", @@ -35,18 +23,14 @@ test "the plan-channel test home reaches start.expandCommand" { try std.testing.expectEqualStrings("PE-250", got.text); } -/// The branch a launch would use. Never what a case is about. const branch = "feature/pe-250-actual"; -/// A plan path that exists only as text — `expandCommand` substitutes it, nothing reads it. const plan_path = "/tmp/lcc-plan-fixture.md"; fn yn(b: bool) []const u8 { return if (b) "true" else "false"; } -/// Asserts the predicate's answer for one template, and on disagreement says what the -/// wrong answer costs — a refused launch, or a `--plan` accepted into a dead end. fn expectCarries(template: []const u8, want: bool) !void { const got = start.templateCarriesPlan(template); if (got != want) { @@ -83,8 +67,6 @@ test "AC-2: templateCarriesPlan answers false when there is no {plan} placeholde } test "AC-3: templateCarriesPlan answers false for an empty template and for one that is only spaces and tabs" { - // The empty template is the stock config; a predicate that indexes it unguarded - // panics here rather than returning. try expectCarries("", false); try expectCarries(" \t \t", false); } diff --git a/src/commands/stats.zig b/src/commands/stats.zig index 0992e8c..3f91422 100644 --- a/src/commands/stats.zig +++ b/src/commands/stats.zig @@ -1,13 +1,3 @@ -//! `lcc stats` — what every worktree in this repo has spent on Claude Code. -//! -//! One row per worktree, drawn from the transcripts whose cwd is that worktree. -//! `--models` breaks each row down by the model that did the work, which is the -//! dimension that explains a bill: the context tokens dominate the total, and -//! they are priced per model. -//! -//! Worktrees with no transcripts are still listed. "This one has cost nothing -//! yet" is an answer, and a row that silently vanished would look like a bug. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -16,25 +6,17 @@ const ui = @import("../ui.zig"); const usage = @import("../usage.zig"); pub const Opts = struct { - /// Break every worktree down by model. models: bool = false, - /// Print the numbers as JSON instead of a table. json: bool = false, }; const Row = struct { - /// Branch name, or the short head when detached. label: []const u8, - /// `main`, `lcc`, or nothing — where this worktree came from. Rendered under - /// the ORIGIN column, which is only drawn when some row has one to show. tag: []const u8, path: []const u8, totals: usage.Totals, }; -/// Whether the ORIGIN column is worth a header and its padding. A repo whose -/// worktrees were all made by hand outside the managed prefix has nothing to put -/// there, and an empty column would only cost every row a trailing space. fn anyTagged(rows: []const Row) bool { for (rows) |row| { if (row.tag.len > 0) return true; @@ -47,9 +29,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { const entries = try repo.listWorktrees(); const prefix = try app_mod.managedPrefix(app, repo); - // One pass over `~/.claude/projects` for every worktree, rather than one per - // worktree: `list` reads a prefix of a transcript per directory to learn - // which cwd it belongs to. const cp_root = try cp.root(app.gpa, app.environ); const projects = try cp.list(app.gpa, app.io, cp_root); @@ -74,7 +53,6 @@ pub fn run(app: app_mod.App, opts: Opts) !void { }; } - // Biggest spender first — the reason to run this command is to find it. std.mem.sort(Row, rows, {}, struct { fn lessThan(_: void, a: Row, b: Row) bool { return a.totals.counts.tokens() > b.totals.counts.tokens(); @@ -102,10 +80,6 @@ const Widths = struct { last: usize, }; -/// Column widths sized to the widest cell, header included. Model rows are -/// measured too — they are indented under the label column, so a long model -/// name can be what sets its width. `cells.models` is empty unless `--models` -/// asked for the breakdown, so there is nothing to gate on here. fn measure(rows: []const Row, cells: []const Cells) Widths { var w: Widths = .{ .label = "WORKTREE".len, @@ -139,8 +113,6 @@ fn measure(rows: []const Row, cells: []const Cells) Widths { const model_indent = 2; -/// A row's numbers as text. Formatted once, then measured and printed, so the -/// widths and the cells can never disagree. const Cells = struct { sessions: []const u8, messages: []const u8, @@ -210,8 +182,6 @@ fn format(app: app_mod.App, row: Row, opts: Opts, now: i64) !Cells { return cells; } -/// A total that is missing an unpriced model's share is marked, not rounded off -/// silently. fn formatCost(gpa: std.mem.Allocator, totals: usage.Totals) ![]const u8 { if (totals.unpriced) { return std.fmt.allocPrint(gpa, "{d:.2}+", .{totals.counts.cost_usd}); @@ -236,10 +206,6 @@ fn renderTable(app: app_mod.App, rows: []const Row, opts: Opts) !void { const w = measure(rows, cells); - // The rightmost cell of a line is never padded — padding it would leave - // trailing spaces on every row, which show up the moment output is piped or - // pasted somewhere. Which cell that is depends on whether ORIGIN is drawn, - // both in the header and in every row below it. const origin = anyTagged(rows); app.ui.hint("{f} {f} {f} {f} {f} {f} {f} {f}{s}", .{ ui.pad("WORKTREE", w.label), @@ -347,8 +313,6 @@ fn renderJson(app: app_mod.App, rows: []const Row, skipped: usize) !void { } try out.appendSlice(w, "]}"); } - // The idle gap travels with the numbers it defines: `active_seconds` is - // meaningless to a consumer that cannot see where the cut-off was put. try out.appendSlice(w, try std.fmt.allocPrint( w, "],\"skipped_transcripts\":{d},\"idle_gap_seconds\":{d}}}\n", @@ -357,8 +321,6 @@ fn renderJson(app: app_mod.App, rows: []const Row, skipped: usize) !void { app.ui.payload("{s}", .{out.items}); } -/// `ui.pad` renders lazily, which colour wrapping cannot use — the escape has to -/// go around text of a known length. fn pad(gpa: std.mem.Allocator, text: []const u8, width: usize) ![]const u8 { return std.fmt.allocPrint(gpa, "{f}", .{ui.pad(text, width)}); } @@ -367,8 +329,6 @@ fn testRow(label: []const u8, totals: usage.Totals) Row { return .{ .label = label, .tag = "", .path = "/x", .totals = totals }; } -/// A table rendered into memory, with colour off so the assertions are about -/// the layout rather than the escape codes. fn renderToString(arena: std.mem.Allocator, rows: []const Row) ![]const u8 { const was_colour = ui.colorEnabled(); ui.setColor(false); @@ -394,7 +354,6 @@ test "the origin column is headed, and absent when no worktree has one" { const spent: usage.Totals = .{ .counts = .{ .messages = 4, .output = 900 }, .sessions = 1 }; - // With a tag to show, LAST is padded so ORIGIN lines up under its header. const tagged = try renderToString(arena, &.{ .{ .label = "main", .tag = "main", .path = "/x", .totals = spent }, .{ .label = "feature/pe-1", .tag = "lcc", .path = "/y", .totals = spent }, @@ -404,8 +363,6 @@ test "the origin column is headed, and absent when no worktree has one" { try std.testing.expect(std.mem.endsWith(u8, lines.next().?, " main")); try std.testing.expect(std.mem.endsWith(u8, lines.next().?, " lcc")); - // With nothing to show, the column is gone entirely — header included, and - // no row picks up a trailing space where it used to be. const untagged = try renderToString(arena, &.{testRow("feature/pe-1", spent)}); var plain = std.mem.splitScalar(u8, untagged, '\n'); try std.testing.expect(std.mem.endsWith(u8, plain.next().?, "LAST")); @@ -432,11 +389,8 @@ test "the active column reports time worked, between ~USD and LAST" { const last_at = std.mem.indexOf(u8, header, "LAST").?; try std.testing.expect(cost_at < active_at and active_at < last_at); - // Four minutes then six, no break between them. try std.testing.expect(std.mem.indexOf(u8, lines.next().?, "10m") != null); - // A worktree nobody has opened has no time worked, and says so with the - // same dash as every other column rather than a misleading 0m. const untouched = try renderToString(arena, &.{testRow("feature/pe-2", .{})}); var idle = std.mem.splitScalar(u8, untouched, '\n'); _ = idle.next(); @@ -466,11 +420,9 @@ test "measure sizes every column to its widest cell, header included" { try std.testing.expectEqual(@as(usize, "feature/pe-256-app-hangs-on-launch".len), w.label); try std.testing.expectEqual(@as(usize, "1165".len), w.messages); try std.testing.expectEqual(@as(usize, "169.84".len), w.cost); - // Headers are the floor: both of these are wider than the cell under them. try std.testing.expectEqual(@as(usize, "SESS".len), w.sessions); try std.testing.expectEqual(@as(usize, "CONTEXT".len), w.input); - // A cell wider than its header pushes the column out. var wide = cells; wide[0].input = "1238.8M"; const grown = measure(&rows, &wide); diff --git a/src/commands/watch.zig b/src/commands/watch.zig index 0ba3540..74328b1 100644 --- a/src/commands/watch.zig +++ b/src/commands/watch.zig @@ -1,15 +1,3 @@ -//! `lcc open` — the worktrees and the sessions running in them, and the handler -//! its hooks call. -//! -//! The file is still named for `watch`, which is what the command was called -//! before `lcc open` absorbed it; renaming it would move every `watch_*.zig` -//! sibling for no gain. -//! -//! `--json` is a one-shot snapshot that never enters raw mode, which is both the -//! tool-callable path CLAUDE.md requires and the resolution of a conflict the -//! interactive version has — a full-screen TUI cannot write its frames through -//! `app.ui`. - const std = @import("std"); const Io = std.Io; const app_mod = @import("../app.zig"); @@ -31,22 +19,11 @@ const wire = @import("../wire.zig"); pub const Opts = struct { json: bool = false, - /// End every background session and let the daemon holding them exit. - /// - /// Lives here rather than on `lcc daemon` because the daemon is not a thing - /// a user of lcc has to know about: they started sessions, and this is how - /// they end all of them at once. stop_all: bool = false, - /// With `stop_all`, SIGKILL the sessions rather than asking them to finish. force: bool = false, }; -/// The `lcc watch-hook` side. Deliberately a separate entry point: it is not a -/// user-facing command, it is what a Claude Code hook execs. pub const HookOpts = struct { - /// The daemon's socket, as the daemon wrote it into the hook command line. - /// Authoritative over anything in the environment — a hook inherits the - /// session's, which belongs to whatever shell started it. socket: ?[]const u8 = null, event: ?[]const u8 = null, }; @@ -64,33 +41,17 @@ pub const Row = struct { stale: bool, }; -/// The error set is written out rather than inferred, which it cannot be: -/// `start` opens this dashboard, and the dashboard's `n` starts an issue -/// through `start`. Inference chases that in a circle. Both are command entry -/// points whose errors go straight to `describe`, so nothing downstream reads -/// the set anyway. pub fn run(app: app_mod.App, opts: Opts) anyerror!void { - // Before the tty check: `--stop-all` is a one-shot that must work from a - // tool call and from a script, not only from a terminal that would get the - // dashboard instead. if (opts.stop_all) return stopAll(app, opts); - // `--json` is a one-shot that never enters raw mode. That is both the - // tool-callable path CLAUDE.md requires and the resolution of a real - // conflict: a full-screen TUI cannot write its frames through `app.ui`, - // and `ui.divert` has no meaning for one. if (!opts.json and Io.File.stdout().isTty(app.io) catch false) { return dashboard(app); } if (!opts.json and !(Io.File.stdout().isTty(app.io) catch false)) { - // Checked before connecting, so the suggestion arrives instead of a - // failure from somewhere further in. app.ui.hint("Not a terminal — use `lcc open --json`.", .{}); } return snapshotOnce(app, opts); } -/// `lcc open --stop-all`. Nothing running is a result, not a failure — the -/// caller asked for no sessions and there are none. fn stopAll(app: app_mod.App, opts: Opts) !void { var conn = (watch_client.connectExisting(app, .control) catch null) orelse { app.ui.info("No background sessions are running.", .{}); @@ -107,8 +68,6 @@ fn stopAll(app: app_mod.App, opts: Opts) !void { } fn snapshotOnce(app: app_mod.App, opts: Opts) !void { - // The live snapshot when a daemon is up; the on-disk projection when it is - // not, so the answer is "nothing is running" rather than an error. const live = watch_client.snapshot(app) catch null; const now = app_mod.nowSeconds(app.io); const outdated = outdatedDaemon(app, app.gpa, exec.selfModified(app.gpa, app.io)); @@ -124,32 +83,17 @@ fn snapshotOnce(app: app_mod.App, opts: Opts) !void { const rows = try app.gpa.alloc(Row, resolved.len); for (resolved, 0..) |r, i| { var row = toRow(r.session, r.stale); - // The reader's verdict wins over what the file claims: a dead daemon - // makes every status unknown, and a vanished worktree makes it orphan. row.status = @tagName(r.status); rows[i] = row; } return emit(app, opts, rows, false, outdated, now); } -/// Is the daemon holding these sessions an older build than this binary? -/// -/// Read from the registry rather than asked over the wire, and deliberately: the -/// daemons this is meant to catch are the ones already running, which cannot be -/// taught to answer a new frame. `started_at` is the one thing every build has -/// always written. See `sessions.daemonOutdated`. fn outdatedDaemon(app: app_mod.App, arena: std.mem.Allocator, built: ?i64) bool { return sessions.daemonOutdated(sessions.load(arena, app.io, app.environ), built); } -/// What to say about it, in one line, in the two places a person will be looking. -/// -/// Says "these sessions are running an older build" rather than naming the -/// daemon: the process is not the reader's problem, the behaviour they are -/// about to get from it is. const outdated_warning = "These sessions are running an older build of lcc than this one."; -/// Naming what `--stop-all` costs, because it signals every session's process -/// group and a hint that omitted that would be advice to lose work. const outdated_hint = "They end on their own 30 minutes after the last one finishes. `lcc open --stop-all` is immediate, but ends them now."; fn toRow(s: sessions.Session, stale: bool) Row { @@ -167,19 +111,9 @@ fn toRow(s: sessions.Session, stale: bool) Row { }; } -/// The `--json` body, built where a test can read it. -/// -/// Split out of `emit` because this is stable surface: the keys are what every -/// slash command parsing `lcc open --json` is written against, and a rename here -/// breaks them silently. `pub` for the test at the bottom of this file. pub fn snapshotJson(gpa: std.mem.Allocator, rows: []const Row, live: bool, outdated: bool) ![]u8 { return std.json.Stringify.valueAlloc(gpa, .{ - // Whether anything is actually running these sessions right now, as - // opposed to `sessions` being the last state written to disk. .sessions_live = live, - // Always present, like every other key here. A caller that sees it - // true knows the sessions may not behave the way this binary's - // contract says — which is otherwise indistinguishable from a bug. .outdated_build = outdated, .sessions = rows, }, .{ .whitespace = .indent_2 }); @@ -194,8 +128,6 @@ fn emit(app: app_mod.App, opts: Opts, rows: []const Row, live: bool, outdated: b } if (rows.len == 0) { - // An empty dashboard is an answer, not a failure — `stats` treats "this - // one has cost nothing yet" the same way. app.ui.info("No watched sessions.", .{}); app.ui.hint("Start one with: lcc start PE-256", .{}); return; @@ -233,11 +165,6 @@ fn emit(app: app_mod.App, opts: Opts, rows: []const Row, live: bool, outdated: b } } -/// The live dashboard. -/// -/// Draw at the top, block at the bottom, recount every frame — `prompt.zig`'s -/// line discipline unchanged. Only the *trigger* for a frame differs: a -/// keystroke there, a keystroke or a second's tick here. fn dashboard(app: app_mod.App) !void { const terminal = try term.Terminal.enterRaw(); defer terminal.restore(); @@ -254,29 +181,14 @@ fn dashboard(app: app_mod.App) !void { out.flush() catch {}; } - // `app.gpa` is a process arena: a dashboard redrawing once a second for - // eight hours would grow without bound on formatted cells alone. Nothing - // per-frame may touch it. No other command in this repo has had to care, - // because no other command is long-lived. var frame_arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); defer frame_arena.deinit(); - // Held as an id, not an index: snapshots re-sort as statuses change, and an - // index would move the selection under the user's finger. var cursor_id: []const u8 = ""; - // A path, not a session id. Sixty-four bytes held an `s-00000001` with room - // to spare and silently truncated the first real worktree path, after which - // the copy never matched the row it came from and the cursor lived nowhere. var cursor_buf: [std.fs.max_path_bytes]u8 = undefined; var confirming_kill = false; var key_buf: [8]u8 = undefined; - // Tracked here rather than in a module variable: this repo has no globals, - // and the only thing that needs it is this loop. var last_cols: u16 = 0; - // Read once: `selfModified` allocates a path, and `app.gpa` is the process - // arena, so a per-frame call would grow it for as long as the dashboard is - // open. The binary under a running process does not change often enough to - // be worth a leak an hour. const built = exec.selfModified(app.gpa, app.io); while (true) { @@ -285,8 +197,6 @@ fn dashboard(app: app_mod.App) !void { const now = app_mod.nowSeconds(app.io); const dims = terminal.size(); - // A width change invalidates the line count: rows drawn at the old - // width may already have wrapped. if (dims.cols != last_cols) { screen.reset(); last_cols = dims.cols; @@ -300,10 +210,6 @@ fn dashboard(app: app_mod.App) !void { screen.eraseFrame(); var lines: usize = 0; - // Above the table, not in the footer: the footer is keys, and this is - // not one. Re-asked every frame from the registry so restarting the - // daemon under an open dashboard clears it, rather than leaving a - // warning about a process that is gone. if (outdatedDaemon(app, arena, built)) { const p = ui.palette(); out.print(" {s}⚠ {s}{s}\n", .{ @@ -316,8 +222,6 @@ fn dashboard(app: app_mod.App) !void { const widths = watch_table.fit(watch_table.measure(rows), dims.cols); if (rows.len == 0) { - // Points at the key that is already on screen rather than at a - // command to quit and run — the empty dashboard can start one. out.print(" {s}No sessions yet — press n to start one.{s}\n", .{ ui.palette().dim, ui.palette().reset, }) catch {}; @@ -333,10 +237,6 @@ fn dashboard(app: app_mod.App) !void { var fds = [_]std.posix.pollfd{ .{ .fd = terminal.fd, .events = std.posix.POLL.IN, .revents = 0 }, }; - // The timeout does three jobs, which is why it exists at all: it is the - // resize tick (no SIGWINCH), the age tick (`4s` becoming `5s`, with no - // timer machinery — this repo has none), and the staleness tick, since - // a *wedged* daemon produces no readable event to notice it by. const ready = std.posix.poll(&fds, 1000) catch 0; if (ready == 0) continue; @@ -346,9 +246,6 @@ fn dashboard(app: app_mod.App) !void { .down => cursor_id = copyId(&cursor_buf, step(rows, cursor_id, 1)), .enter => { if (findRow(rows, cursor_id)) |row| { - // Asked of the row rather than of the id: a dead daemon - // leaves its ids in the projection, and attaching to one of - // those finds nothing listening and returns in silence. const id = if (row.attachable()) row.session_id.? else blk: { @@ -362,9 +259,6 @@ fn dashboard(app: app_mod.App) !void { } }, .text => |t| { - // The key's position, not the character it printed: on a - // Cyrillic layout `n` prints `т`, and reading the character - // means every shortcut stops working on a layout switch. const key = term.layoutKey(t) orelse continue; if (confirming_kill) { confirming_kill = false; @@ -380,12 +274,6 @@ fn dashboard(app: app_mod.App) !void { 'n' => try newSession(app, &screen, terminal), 'j' => cursor_id = copyId(&cursor_buf, step(rows, cursor_id, 1)), 'k' => cursor_id = copyId(&cursor_buf, step(rows, cursor_id, -1)), - // Two keys, inline, rather than a nested raw-mode widget — - // the terminal is already owned and re-entering it for a - // yes/no would be a second redraw discipline to keep right. - // Only a row with something running has anything to kill. - // Only a row with something actually running has anything - // to kill; a dead daemon's leftover id is not a target. 'x' => confirming_kill = if (findRow(rows, cursor_id)) |row| row.attachable() else false, 'r' => {}, '1'...'9' => { @@ -405,9 +293,6 @@ fn dashboard(app: app_mod.App) !void { } } -/// Leaving and re-entering cleanly around a passthrough: the dashboard's frame -/// is erased first so the session starts on a clean screen, and the count is -/// dropped so the next frame does not try to walk back over the agent's output. fn attachTo( app: app_mod.App, screen: *term.Screen, @@ -426,15 +311,7 @@ fn attachTo( screen.reset(); } -/// Pick another issue and start it, without leaving the dashboard. -/// -/// Runs the ordinary `lcc start` with the daemon path forced on and the -/// dashboard suppressed — otherwise it would open a second one on top of this. -/// Everything else about it is unchanged, including the picker, so there is one -/// way a session comes into being rather than two that can drift. fn newSession(app: app_mod.App, screen: *term.Screen, terminal: term.Terminal) !void { - // `start` owns the terminal for its picker and its progress lines, the same - // handover an attach performs. screen.eraseFrame(); screen.out.writeAll(term.csi ++ "?25h") catch {}; screen.out.flush() catch {}; @@ -474,9 +351,6 @@ fn footer( }) catch {}; return 1; } - // "enter opens" rather than "enter attaches": on a row with no session yet - // it starts one first, and promising only the second half would make the - // first look like a surprise. out.print(" {s}{s}{s}\n", .{ p.dim, term.truncate("↑↓ move · enter opens · n new issue · x kill · q quit", cols -| 2), @@ -485,24 +359,9 @@ fn footer( return 1; } -/// Every worktree of the repo you are standing in, plus every session the -/// daemon is running — merged on the worktree path. -/// -/// One screen rather than two. `lcc open` used to list worktrees and know -/// nothing about sessions; the dashboard listed sessions and knew nothing about -/// worktrees. Each saw half of the same question — "where do I get back into -/// Claude Code" — and the half it saw depended on which command you happened to -/// type. -/// -/// Sessions outside the current repo are kept rather than filtered: an agent -/// running somewhere else is exactly the thing you must not lose sight of, and -/// this may be run from no repository at all. fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table.Row { var rows: std.ArrayList(watch_table.Row) = .empty; - // The daemon when it is up, its projection when it is not — so a dead - // daemon shows `unknown` rather than an empty screen that reads as "nothing - // was ever running here". var live: []const sessions.Session = &.{}; var stale = false; if (watch_client.snapshot(app) catch null) |list| { @@ -519,8 +378,6 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table. live = carried; } - // Worktrees first, so the order is the repo's rather than the daemon's - // registration order, which changes as sessions come and go. if (app.repo()) |repo| { for (try app_mod.worktreeChoices(app, repo)) |choice| { const branch = choice.entry.branch orelse app_mod.shortHead(choice.entry.head); @@ -568,23 +425,12 @@ fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.S return null; } -/// `PE-288` out of `feature/pe-288-…`, for a worktree the daemon has never seen. -/// -/// Upper-cased from the branch rather than asked of Linear: the dashboard -/// redraws once a second and this is a label, not a lookup. fn issueOf(gpa: std.mem.Allocator, branch: []const u8) ?[]const u8 { const ref = linear.refFromBranch(branch) orelse return null; const team = std.ascii.allocUpperString(gpa, ref.team) catch return null; return std.fmt.allocPrint(gpa, "{s}-{d}", .{ team, ref.number }) catch null; } -/// Hand an existing worktree to the daemon and return its session id. -/// -/// What `lcc open` did in the foreground, done through the daemon instead: the -/// same `--resume` when a transcript exists, the same MCP servers carried from -/// the main checkout. Asking to resume in a directory Claude Code has never run -/// in opens a picker with nothing in it, so it is only asked for once there is -/// something to resume. fn startForWorktree(app: app_mod.App, row: watch_table.Row) !watch_client.Started { var argv: std.ArrayList([]const u8) = .empty; if (app.repo()) |repo| { @@ -619,7 +465,6 @@ fn findRow(rows: []const watch_table.Row, key: []const u8) ?watch_table.Row { return null; } -/// The neighbouring session's id, wrapping. Pure, so the wrap is testable. pub fn step(rows: []const watch_table.Row, key: []const u8, delta: i2) []const u8 { if (rows.len == 0) return ""; var at: usize = 0; @@ -633,20 +478,12 @@ pub fn step(rows: []const watch_table.Row, key: []const u8, delta: i2) []const u return rows[next].key; } -/// The cursor outlives the arena its row came from, so its id is copied into a -/// buffer that does not get reset with the frame. fn copyId(buf: []u8, id: []const u8) []const u8 { const take = @min(buf.len, id.len); @memcpy(buf[0..take], id[0..take]); return buf[0..take]; } -/// What a Claude Code hook execs. Reads the hook payload on stdin, forwards it -/// to the daemon, and exits zero whatever happened. -/// -/// **Always zero.** This runs on every turn of every watched session; a handler -/// that could fail would be a handler that could disturb the work it is only -/// meant to observe. pub fn hook(app: app_mod.App, opts: HookOpts) !void { const event = opts.event orelse return; @@ -657,9 +494,6 @@ 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; - // `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, payload.permission_mode); } @@ -669,25 +503,16 @@ test "the --json keys name sessions, never the process behind them" { const body = try snapshotJson(gpa, &.{}, true, false); defer gpa.free(body); - // Renaming any of these breaks every slash command parsing this snapshot, - // and breaks it silently: a missing key reads as `null`, which reads as - // "nothing is running" rather than as a contract that moved. try std.testing.expect(std.mem.indexOf(u8, body, "\"sessions_live\"") != null); try std.testing.expect(std.mem.indexOf(u8, body, "\"outdated_build\"") != null); try std.testing.expect(std.mem.indexOf(u8, body, "\"sessions\"") != null); - // The point of the rename. A caller has sessions; the daemon holding them is - // an implementation detail it must not be able to grow a dependency on. try std.testing.expect(std.mem.indexOf(u8, body, "daemon") == null); } test "an empty snapshot still carries both flags, rather than dropping them" { const gpa = std.testing.allocator; - // The dead-daemon case, which is the one a caller is most likely to hit and - // least likely to have tested: no sessions, nothing running them. Both keys - // are still present and false, per the JSON contract in CLAUDE.md — absent - // values are `null`, never dropped. const body = try snapshotJson(gpa, &.{}, false, false); defer gpa.free(body); diff --git a/src/config.zig b/src/config.zig index 4a0bb30..282733f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,5 +1,3 @@ -//! `~/.config/lcc/config.json` — same file and same keys as the TypeScript version. - const std = @import("std"); const Io = std.Io; @@ -9,23 +7,9 @@ pub const authorize_url = "https://linear.app/oauth/authorize"; pub const token_url = "https://api.linear.app/oauth/token"; pub const default_scopes = "read,write"; -/// Public OAuth client for the `lcc` tool. client_id is non-secret by OAuth design; -/// PKCE protects the flow without requiring a client_secret. Override with -/// LCC_CLIENT_ID env var or `lcc auth setup --client-id ` for forks / self-hosted. pub const default_client_id = "6bf6dd7b761b5ce6539cf5a9ed99b4fb"; const default_worktree_template = "{repoRoot}/.lcc/worktrees/{branchLeaf}"; -/// Gitignored files worth sharing with every worktree. `.claude/settings.local.json` -/// is the permission allowlist: without it, each new worktree re-asks for approvals -/// that were already granted in the main checkout. -/// -/// `CLAUDE.md` and `CLAUDE.local.md` are here for the repos that keep theirs out of -/// git — a worktree of one hands Claude Code no project instructions at all, which is -/// the same session in a repo it knows nothing about. Claude Code walks up the parent -/// directories looking for both, so a worktree *nested* inside the repo finds them -/// without any help; the link is what covers a template that puts worktrees beside the -/// repo instead. Linking never replaces a file that is already there, so a repo that -/// commits its `CLAUDE.md` keeps the copy its branch carries. const default_link_patterns = [_][]const u8{ ".env", ".env.*", @@ -35,22 +19,11 @@ const default_link_patterns = [_][]const u8{ }; const default_link_exclude = [_][]const u8{ ".env.example", ".env.sample", ".env.template" }; const default_active_states = [_][]const u8{ "Todo", "In Progress" }; -/// On by default. A session that outlives the terminal that started it is the -/// behaviour worth having without asking for it; `--no-watch` and this key are -/// the two ways back to a plain foreground launch. pub const default_watch_by_default = true; -/// What `lcc list` may do about the PR and Linear columns. -/// -/// One setting rather than two booleans because the three states are exclusive -/// and two flags would let someone ask for both "skip the network" and "ignore -/// the cache", which has no meaning. pub const ListNetwork = enum { - /// Ask GitHub and Linear again. refresh, - /// Reuse a recent answer. cached, - /// Skip those columns entirely. local, pub fn parse(text: []const u8) ?ListNetwork { @@ -58,54 +31,32 @@ pub const ListNetwork = enum { } }; -/// What the file may contain. Every field optional: absence means "use the default". pub const Stored = struct { clientId: ?[]const u8 = null, worktreeTemplate: ?[]const u8 = null, linkPatterns: ?[]const []const u8 = null, linkExclude: ?[]const []const u8 = null, - /// Pre-nested-path names for the two above. Still read so an existing - /// config keeps working; `linkPatterns`/`linkExclude` win when both are set. envPatterns: ?[]const []const u8 = null, envExclude: ?[]const []const u8 = null, activeStates: ?[]const []const u8 = null, startTaskCommand: ?[]const u8 = null, - /// Which of the repo's local-scope MCP servers a worktree is worth handing. - /// Absent carries all of them. A server the work never calls still costs every - /// agent in the session its name and its instructions, on every turn. mcpCarry: ?[]const []const u8 = null, - /// Whether `lcc start` hands the session to the daemon rather than taking - /// over the terminal. On unless turned off — a session that survives its - /// terminal is what you want by default, and `--no-watch` covers the one-off. watchByDefault: ?bool = null, - /// Open new sessions in Claude Code's plan mode. A `--plan` file turns it - /// off regardless: the session already has a plan. planMode: ?bool = null, - /// `lcc open` resumes the worktree's last session rather than starting fresh. resumeSessions: ?bool = null, - /// The TOKENS column in `lcc list`. Off skips reading transcripts, which is - /// the whole cost of that column. showTokens: ?bool = null, - /// How `lcc list` treats the PR and Linear columns: `refresh`, `cached` or - /// `local`. Stored as text — the enum's numbering is an implementation - /// detail and must not reach disk. listNetwork: ?[]const u8 = null, - /// Offer every assigned issue in the picker, not just `activeStates`. allIssues: ?bool = null, - /// What `lcc remove` leaves behind by default. keepBranch: ?bool = null, keepDerivedData: ?bool = null, keepXcode: ?bool = null, }; -/// How a settings update changes the physical `mcpCarry` key. `all` removes the -/// key, preserving the distinction between carrying everything and carrying none. pub const McpCarry = union(enum) { all, only: []const []const u8, }; -/// Values to merge into the stored configuration. A null field is left untouched. pub const Patch = struct { clientId: ?[]const u8 = null, worktreeTemplate: ?[]const u8 = null, @@ -154,8 +105,6 @@ pub fn path(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![] return std.fs.path.join(gpa, &.{ home, ".config", "lcc", "config.json" }); } -/// Reads the file if present. Strings are allocated with `gpa` and live as long -/// as it does — callers use an arena. pub fn loadStored( gpa: std.mem.Allocator, io: Io, @@ -170,8 +119,6 @@ pub fn loadStored( }; defer gpa.free(raw); - // `alloc_always`: the default borrows slices out of `raw` where it can, and - // `raw` is freed on the way out of this function. return std.json.parseFromSliceLeaky(Stored, gpa, raw, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, @@ -200,8 +147,6 @@ pub fn load( .planMode = stored.planMode orelse true, .resumeSessions = stored.resumeSessions orelse true, .showTokens = stored.showTokens orelse true, - // An unrecognised value reads as the default rather than failing the - // whole file — a hand-edited typo should cost one setting, not the run. .listNetwork = if (stored.listNetwork) |v| (ListNetwork.parse(v) orelse .cached) else .cached, .allIssues = stored.allIssues orelse false, .keepBranch = stored.keepBranch orelse false, @@ -210,8 +155,6 @@ pub fn load( }; } -/// Merges `patch` over what is on disk, then rewrites the file. Fields left -/// null in `patch` keep their stored value. pub fn save( gpa: std.mem.Allocator, io: Io, @@ -223,7 +166,6 @@ pub fn save( if (patch.worktreeTemplate) |v| merged.worktreeTemplate = v; if (patch.linkPatterns) |v| { merged.linkPatterns = v; - // Drop the superseded key rather than leave two sources of truth on disk. merged.envPatterns = null; } if (patch.linkExclude) |v| { @@ -269,7 +211,6 @@ pub fn save( try writer.interface.flush(); } -/// The precedence `load` applies, without touching the filesystem. fn resolveLinkPatterns(stored: Stored) []const []const u8 { return stored.linkPatterns orelse stored.envPatterns orelse &default_link_patterns; } @@ -293,8 +234,6 @@ test "the defaults carry Claude Code's own files, not just secrets" { const defaults = resolveLinkPatterns(.{}); try std.testing.expectEqual(@as(usize, 5), defaults.len); - // Secrets are the obvious half; these three are what a worktree needs to be the - // same working environment as the checkout it was cut from. try std.testing.expect(hasPattern(defaults, ".claude/settings.local.json")); try std.testing.expect(hasPattern(defaults, "CLAUDE.md")); try std.testing.expect(hasPattern(defaults, "CLAUDE.local.md")); diff --git a/src/daemon.zig b/src/daemon.zig index cc78738..6bb1cfc 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -1,22 +1,3 @@ -//! The session daemon: one process, one lock, one poll loop, and no timer at -//! all when nothing is pending. -//! -//! **Single-threaded by construction, and that is a hard requirement rather -//! than a simplification.** `pty.spawn` forks, and a fork from a process with a -//! thread pool leaves the child holding locks no surviving thread will ever -//! release — libc's allocator lock among them — so it can deadlock before it -//! reaches `execve`. `Io.Group` and `Io.async` spawn detached pooled threads -//! that live for the rest of the process (`Io/Threaded.zig`), so **neither may -//! be used anywhere the daemon can reach**. `src/commands/list.zig` uses -//! `Io.Group` quite correctly for its own purposes; copying that shape into -//! here would be a heisenbug that only appears under load. -//! -//! The loop is `std.posix.poll` over the listener, every client socket, every -//! pty master and a self-pipe. That is also why a task-per-session design was -//! rejected: it would need a mutex over the ring, the client list and the -//! registry, which is synchronisation for a workload whose entire point is -//! being idle. - const std = @import("std"); const Io = std.Io; const app_mod = @import("app.zig"); @@ -32,11 +13,7 @@ const wire = @import("wire.zig"); extern "c" fn chmod(path: [*:0]const u8, mode: c_uint) c_int; pub const Options = struct { - /// Skips the double fork and the stdio redirect, so the loop can run on a - /// thread inside a test. Also what `lcc daemon --foreground` sets. foreground: bool = false, - /// Exit once nothing has been running for this long, so a laptop does not - /// accumulate daemons from months of finished work. idle_exit_seconds: i64 = 30 * 60, max_sessions: u32 = 64, max_clients_per_session: u32 = 8, @@ -48,36 +25,18 @@ pub const BindError = error{ } || watch_paths.Error || Io.File.OpenError || Io.net.UnixAddress.ListenError; pub const Bound = struct { - /// Held for the daemon's whole life. Both the single-instance guard and the - /// liveness probe: a client that could take this lock would know the socket - /// beside it is stale. - /// - /// Opened through `Io.Dir`, which sets `O_CLOEXEC`, and that is load-bearing - /// rather than incidental: `flock` is per open-file-description and - /// inherited across fork, released only when every copy closes. A lock fd - /// that leaked into a Claude Code child would answer "still held" for as - /// long as that agent ran, wedging every future daemon restart. lock_file: Io.File, server: Io.net.Server, socket_path: []const u8, pub fn deinit(self: *Bound, io: Io) void { self.server.deinit(io); - // Unlink before unlocking: the reverse order leaves a window in which - // the next daemon takes the lock and then has its fresh socket deleted - // by this one. Io.Dir.cwd().deleteFile(io, self.socket_path) catch {}; self.lock_file.unlock(io); self.lock_file.close(io); } }; -/// Take the lock, remove a stale socket, then listen — in that order, always. -/// -/// Null when another daemon holds the lock, which is the whole two-daemon -/// guard: the loser exits in milliseconds and its client connects to the -/// winner. Unlinking before locking would let a starting daemon delete a *live* -/// daemon's socket, which is the one ordering mistake here that loses sessions. pub fn bind(app: app_mod.App, opts: Options) BindError!?Bound { _ = opts; const dir = try watch_paths.dir(app.gpa, app.environ); @@ -87,9 +46,6 @@ pub fn bind(app: app_mod.App, opts: Options) BindError!?Bound { const cwd = Io.Dir.cwd(); cwd.createDirPath(app.io, dir) catch {}; - // macOS enforces the socket file's permissions on `connect`, and the - // default umask leaves it world-readable. Without this another local - // account could attach to a session and get a shell in the worktree. if (app.gpa.dupeZ(u8, dir)) |dir_z| { _ = chmod(dir_z.ptr, 0o700); } else |_| {} @@ -101,14 +57,8 @@ pub fn bind(app: app_mod.App, opts: Options) BindError!?Bound { return null; } - // The lock is ours, so whoever left this socket behind is gone. std does - // not do this: `UnixAddress.listen` goes straight to bind and fails with - // AddressInUse on a leftover path. cwd.deleteFile(app.io, socket_path) catch |err| switch (err) { error.FileNotFound => {}, - // A directory (or anything undeletable) sitting on the socket path is - // worth naming, rather than falling through into a confusing - // AddressInUse from the listen below. else => return BindError.SocketPathOccupied, }; @@ -117,29 +67,14 @@ pub fn bind(app: app_mod.App, opts: Options) BindError!?Bound { return .{ .lock_file = lock_file, .server = server, .socket_path = socket_path }; } -/// Everything the loop might have to wake up for. All optional: absent means -/// "nothing pending of this kind". pub const Deadlines = struct { - /// The debounced registry write. registry_flush_at: ?i64 = null, - /// A child that has closed its pty but not yet been reaped. reap_retry_at: ?i64 = null, - /// The listener was dropped from the poll set after EMFILE. listener_resume_at: ?i64 = null, - /// Nothing is running; exit when this passes. idle_exit_at: ?i64 = null, - /// Re-evaluate time-based status decay. decay_at: ?i64 = null, }; -/// Milliseconds for `std.posix.poll`: the nearest pending deadline, `0` when one -/// is already due, and `-1` when there is none. -/// -/// The `-1` is the point. With no sessions and no attached clients the daemon -/// blocks indefinitely and costs *zero* wakeups — not "almost no CPU", none — -/// which is the difference between a daemon worth leaving running on a laptop -/// and one that is not. A fixed tick would burn four wakeups a second forever -/// to notice nothing. pub fn nextTimeout(now_ms: i64, d: Deadlines) i32 { var soonest: ?i64 = null; inline for (@typeInfo(Deadlines).@"struct".fields) |field| { @@ -148,27 +83,17 @@ pub fn nextTimeout(now_ms: i64, d: Deadlines) i32 { } } const at = soonest orelse return -1; - // Never negative: `poll` reads a negative timeout as "block forever", so an - // off-by-one here turns an overdue registry flush into a hang. if (at <= now_ms) return 0; const delta = at - now_ms; return @intCast(@min(delta, std.math.maxInt(i32))); } -/// The write end of the self-pipe, for the SIGCHLD handler. -/// -/// The one global in this codebase, and it exists because a signal handler has -/// no other channel: it may touch nothing but an atomic and an async-signal-safe -/// call. Without it a child's death could not wake the loop at all — -/// `std.posix.poll` swallows EINTR and restarts with the *full* timeout, so a -/// signal alone is invisible to it. var wake_write_fd: std.atomic.Value(i32) = .init(-1); fn onChild(_: std.c.SIG) callconv(.c) void { const fd = wake_write_fd.load(.monotonic); if (fd < 0) return; const byte = [_]u8{0}; - // A full pipe means a wake is already queued, so the result is discarded. _ = std.c.write(fd, &byte, 1); } @@ -179,18 +104,11 @@ const Client = struct { said_hello: bool = false, dec_buf: []u8, dec: wire.Decoder, - /// Bounded by one frame: bytes are taken from the session's ring only when - /// this is empty, so a slow client costs one frame of memory, not a queue. out: std.ArrayList(u8) = .empty, - /// The session this connection is attached to, by id rather than index — - /// indices move when a session is removed. attached: ?[]const u8 = null, cursor: u64 = 0, - /// Where the scrollback that existed at attach time ends. Everything below - /// it is a replay and is framed as one. replay_until: u64 = 0, size: pty.Size = .{ .rows = 24, .cols = 80 }, - /// A connection that says nothing must not hold an fd forever. handshake_deadline: i64, dead: bool = false, @@ -205,8 +123,6 @@ fn readFd(fd: std.posix.fd_t, buf: []u8) pty.Read { if (rc > 0) return .{ .n = @intCast(rc) }; if (rc == 0) return .closed; switch (std.posix.errno(rc)) { - // Io.Threaded's SIGPIPE handler carries no SA_RESTART, so an - // interrupted read is ours to retry. .INTR => continue, .AGAIN => return .again, else => return .closed, @@ -234,16 +150,7 @@ fn setNonblocking(fd: std.posix.fd_t) void { _ = std.c.fcntl(fd, std.posix.F.SETFL, @as(c_int, @bitCast(o))); } -/// Detach from the terminal that started us. -/// -/// Two forks. The first makes us a non-leader so `setsid` can succeed — it -/// fails with EPERM for a process-group leader, which is what `lcc daemon` -/// typed into a job-control shell always is. The second makes us a non-leader -/// again, so no later `open` of a terminal can hand *us* a controlling -/// terminal: `forkpty` opens each slave in the parent, and a session-leading -/// daemon could acquire one and start receiving that session's SIGHUP. fn daemonize(app: app_mod.App) void { - // Anything still buffered would be printed again by the child. app.ui.flush(); if (std.c.fork() != 0) std.c._exit(0); @@ -263,9 +170,6 @@ const Loop = struct { app: app_mod.App, opts: Options, bound: *Bound, - /// Prepended to every session's argv as `--settings `. Held here - /// rather than recomputed per register: it is one path for the daemon's - /// whole life, and `app.gpa` is the process arena. hooks_path: []const u8, list: std.ArrayList(watch_session.Session) = .empty, clients: std.ArrayList(Client) = .empty, @@ -287,7 +191,6 @@ const Loop = struct { return null; } - /// Sessions filed by worktree, which is the key hook payloads carry. fn findByWorktree(self: *Loop, cwd: []const u8) ?*watch_session.Session { for (self.list.items) |*s| { if (std.mem.eql(u8, s.worktree, cwd)) return s; @@ -316,12 +219,6 @@ const Loop = struct { self.sendControl(client, .err, wire.ErrorBody{ .code = code, .message = message }); } - /// Tell everyone watching a session that it is over. - /// - /// Without this an attached client waits forever on a pty that will never - /// speak again, sitting on a screen that has stopped changing — which is - /// indistinguishable from a hang. The frame type existed and the client - /// handled it; nothing ever sent it. fn announceExit(self: *Loop, session: *watch_session.Session) void { for (self.clients.items) |*c| { const id = c.attached orelse continue; @@ -334,7 +231,6 @@ const Loop = struct { } } - /// Reap, and announce it if this is the moment it died. fn reapAndAnnounce(self: *Loop, session: *watch_session.Session, at: i64) bool { if (session.exit != null) return false; if (!session.reap(at)) return false; @@ -343,24 +239,11 @@ const Loop = struct { } }; -/// Runs until the last session is gone or a client asks it to stop. -/// -/// Returns immediately when another daemon already holds the lock, which is the -/// whole two-daemon guard — a concurrent `lcc start --watch` may spawn several, -/// and all but one evaporate in milliseconds. pub fn run(app: app_mod.App, opts: Options) !void { - // Before the fork, because after it there is nowhere left to complain: the - // terminal is gone and the log lives under the very directory that a path - // problem is usually about. A daemon that fails here should say so to the - // person who ran it, not vanish. _ = try watch_paths.socket(app.gpa, app.environ); if (!opts.foreground) daemonize(app); - // Stay detached: a hangup on the terminal that started us must not take the - // sessions with it. Never SIGIO or SIGPIPE — `Io.Threaded` owns those, and - // its SIGPIPE handler is what turns a write to a dead client into EPIPE - // instead of a dead daemon. const ignore: std.posix.Sigaction = .{ .handler = .{ .handler = std.c.SIG.IGN }, .mask = std.posix.sigemptyset(), @@ -385,8 +268,6 @@ pub fn run(app: app_mod.App, opts: Options) !void { }; std.posix.sigaction(.CHLD, &on_child, null); - // macOS defaults the soft limit to 256, which a handful of sessions plus - // their clients can reach. if (std.posix.getrlimit(.NOFILE)) |limit| { var raised = limit; raised.cur = @min(limit.max, 4096); @@ -407,14 +288,6 @@ pub fn run(app: app_mod.App, opts: Options) !void { try serve(&loop); } -/// The `--settings` file every session is launched with. Written once at -/// startup rather than per session, because its contents do not vary. -/// -/// Returns the path because writing the file is only half of it: nothing -/// installs these hooks unless the path also reaches `claude --settings`, and -/// for a while nothing did. The file was written, the daemon looked healthy, -/// and every session sat at the status it reached on its first byte of output -/// because not one hook had been registered in it. fn writeHookSettings(app: app_mod.App) ![]const u8 { const exe = try exec.selfPath(app.gpa, app.io); const socket_path = try watch_paths.socket(app.gpa, app.environ); @@ -432,11 +305,6 @@ fn serve(loop: *Loop) !void { var idle_since: ?i64 = loop.now(); while (loop.running) { - // Before the poll, not after. The deadlines are what the poll timeout is - // computed from, so a tick that ran afterwards would leave the first - // iteration with nothing pending — and `nextTimeout` correctly answers - // "block forever" to that, so the daemon would sleep before ever writing - // the registry that tells anyone it exists. tick(loop, loop.now(), &idle_since); fds.clearRetainingCapacity(); @@ -454,9 +322,6 @@ fn serve(loop: *Loop) !void { } const sessions_at = fds.items.len; - // Counted here, not re-read after the poll: handling one event can add - // a session or a client, and `fds` describes the world as it was when - // it was built. Indexing it by the live length walks off the end. const session_count = loop.list.items.len; for (loop.list.items) |*s| { var events: i16 = if (s.master_open) std.posix.POLL.IN else 0; @@ -468,9 +333,6 @@ fn serve(loop: *Loop) !void { const client_count = loop.clients.items.len; for (loop.clients.items) |*c| { var events: i16 = std.posix.POLL.IN; - // Armed only when there is something to send, so a caught-up client - // contributes zero wakeups — which is what keeps an idle daemon at - // zero even with clients attached. if (c.wantsWrite()) events |= std.posix.POLL.OUT; try fds.append(gpa, .{ .fd = c.fd, .events = events, .revents = 0 }); } @@ -498,8 +360,6 @@ fn serve(loop: *Loop) !void { for (0..client_count) |i| { const revents = fds.items[clients_at + i].revents; if (revents == 0) continue; - // Re-indexed rather than held across the loop: registering a - // session or accepting a peer can reallocate these lists. if (revents & std.posix.POLL.OUT != 0) flushClient(loop, &loop.clients.items[i]); if (revents & (std.posix.POLL.IN | std.posix.POLL.HUP) != 0) { readClient(loop, &loop.clients.items[i], at); @@ -516,9 +376,6 @@ fn serve(loop: *Loop) !void { fn drainWake(loop: *Loop) void { var buf: [256]u8 = undefined; while (readFd(loop.wake_r, &buf) == .n) {} - // A child died. Which one is not in the signal, so every known pid is - // offered a non-blocking reap — never `waitpid(-1)`, which would steal a - // status `std.process.Child.wait` is blocked on elsewhere and hang it. const at = loop.now(); for (loop.list.items) |*s| { if (loop.reapAndAnnounce(s, at)) loop.dirty = true; @@ -527,14 +384,10 @@ fn drainWake(loop: *Loop) void { fn acceptClient(loop: *Loop, at: i64) void { const stream = loop.bound.server.accept(loop.app.io) catch |err| switch (err) { - // A full fd table leaves the listener permanently readable, so letting - // accept keep failing would spin at 100% CPU — the exact inversion of - // this daemon's reason for existing. Drop it from the set for a second. error.ProcessFdQuotaExceeded, error.SystemFdQuotaExceeded => { loop.deadlines.listener_resume_at = (loop.now() + 1) * std.time.ms_per_s; return; }, - // The peer aborted between poll and accept. Ordinary. else => return, }; @@ -554,7 +407,6 @@ fn acceptClient(loop: *Loop, at: i64) void { .fd = fd, .dec_buf = buf, .dec = dec, - // Otherwise `nc daemon.sock` pins an fd and a slot forever. .handshake_deadline = at + 5, }) catch stream.close(loop.app.io); } @@ -593,10 +445,6 @@ fn handleFrame(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) void { return; }; if (hello.protocol != wire.protocol) { - // Named, not guessed at. `lcc` on PATH is a symlink into - // zig-out/bin, so a rebuild swaps the client under a running daemon - // routinely — and a garbled byte stream into a live session is a far - // worse outcome than a sentence. loop.fail(client, "protocol_mismatch", "This daemon speaks a different protocol version."); client.dead = true; return; @@ -635,9 +483,6 @@ fn handleFrame(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) void { .hook => { const body = wire.parse(wire.Hook, gpa, frame) catch return; const event = watch_hooks.Event.parse(body.event) orelse return; - // Keyed by worktree. A cwd the daemon does not know is a Claude Code - // 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, body.permission_mode, at)) loop.dirty = true; }, @@ -670,12 +515,6 @@ fn registerSession(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) voi const id = std.fmt.allocPrint(gpa, "s-{x:0>8}", .{loop.next_id}) catch return; loop.next_id += 1; - // The hooks go on here, not in whichever client asked for the session. - // Every session must report, and there are two register paths — `lcc start - // --watch` and the dashboard's `enter` — so a client-side flag is one that - // can be added to one of them and forgotten in the other. The daemon owns - // the socket these hooks report to and the file that names it; it owns - // handing that file over too. var argv: std.ArrayList([]const u8) = .empty; argv.appendSlice(gpa, &.{ "--settings", loop.hooks_path }) catch return; argv.appendSlice(gpa, body.argv) catch return; @@ -702,9 +541,6 @@ 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), - // `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, }); @@ -715,8 +551,6 @@ fn sendSnapshot(loop: *Loop, client: *Client) void { var rows = gpa.alloc(sessions_mod.Session, loop.list.items.len) catch return; for (loop.list.items, 0..) |*s, i| rows[i] = s.entry(); - // Cut rather than fail: a caller that sees `truncated` knows its view is - // partial, which beats a snapshot that never arrives. var truncated = false; while (rows.len > 0) { const body = std.json.Stringify.valueAlloc(gpa, wire.Snapshot{ @@ -746,8 +580,6 @@ fn attachClient(loop: *Loop, client: *Client, frame: wire.Frame) void { client.attached = session.id; client.size = .{ .rows = body.rows, .cols = body.cols }; - // Replay from the oldest byte still held, so an attaching client can - // repaint rather than staring at a blank screen until the agent next prints. client.cursor = if (body.replay) session.scrollback.oldest() else session.scrollback.written; client.replay_until = session.scrollback.written; @@ -772,8 +604,6 @@ fn detachClient(loop: *Loop, client: *Client) void { renegotiate(loop); } -/// The pty takes the smallest attached terminal. Nobody attached leaves the -/// last size in place, because a 0x0 winsize makes Ink render nothing. fn renegotiate(loop: *Loop) void { const gpa = loop.app.gpa; for (loop.list.items) |*s| { @@ -791,7 +621,6 @@ fn renegotiate(loop: *Loop) void { } } -/// Move each attached client forward through its session's ring. fn pump(loop: *Loop) void { for (loop.clients.items) |*c| { if (c.dead or c.out.items.len > 0) continue; @@ -804,8 +633,6 @@ fn pump(loop: *Loop) void { const available = parts[0].len + parts[1].len; if (available == 0) continue; - // Never span the boundary: a frame is entirely replay or entirely - // live, so the client knows which rule to apply to all of it. const room = if (c.cursor < c.replay_until) @min(available, c.replay_until - c.cursor) else @@ -830,8 +657,6 @@ fn flushClient(loop: *Loop, client: *Client) void { client.out.shrinkRetainingCapacity(client.out.items.len - n); }, .again => return, - // EPIPE. `Io.Threaded` installs a SIGPIPE handler, so this is an - // error return rather than a dead daemon. .closed => { client.dead = true; return; @@ -844,12 +669,10 @@ fn flushClient(loop: *Loop, client: *Client) void { fn tick(loop: *Loop, at: i64, idle_since: *?i64) void { for (loop.list.items) |*s| { if (s.tick(at)) loop.dirty = true; - // A session whose pty closed but whose child has not been reaped yet. if (!s.master_open and loop.reapAndAnnounce(s, at)) loop.dirty = true; } if (loop.dirty and loop.deadlines.registry_flush_at == null) { - // Debounced, so a burst of events costs one write rather than one each. loop.deadlines.registry_flush_at = (at + 1) * std.time.ms_per_s; } if (loop.deadlines.registry_flush_at) |due| { @@ -895,7 +718,6 @@ fn flushRegistry(loop: *Loop, at: i64) void { loop.dirty = false; } -/// Drop dead clients, and sessions whose linger has expired. fn reapDead(loop: *Loop) void { var i: usize = 0; while (i < loop.clients.items.len) { @@ -915,9 +737,6 @@ fn reapDead(loop: *Loop) void { const testing = std.testing; test "with nothing pending the daemon blocks rather than ticking" { - // The zero-wakeup requirement, expressed as a pure function so it can be - // pinned. If this ever returns a number, an idle daemon starts costing - // wakeups forever to discover that nothing happened. try testing.expectEqual(@as(i32, -1), nextTimeout(1_000, .{})); } @@ -926,23 +745,17 @@ test "the nearest deadline wins, and one already past is zero, never negative" { try testing.expectEqual(@as(i32, 500), nextTimeout(now, .{ .registry_flush_at = now + 500 })); - // Whichever comes first, regardless of which field it is in. try testing.expectEqual(@as(i32, 250), nextTimeout(now, .{ .registry_flush_at = now + 500, .reap_retry_at = now + 250, .idle_exit_at = now + 900_000, })); - // Exactly due, and overdue, are both "run now". A negative return would - // mean *block forever* to poll, so an overdue flush would never happen. try testing.expectEqual(@as(i32, 0), nextTimeout(now, .{ .registry_flush_at = now })); try testing.expectEqual(@as(i32, 0), nextTimeout(now, .{ .registry_flush_at = now - 5_000 })); } test "a deadline beyond i32 milliseconds is clamped, not wrapped" { - // idle_exit_at can sit half an hour out, and a clock jump could put a - // deadline much further. Wrapping into a negative would block the daemon - // forever; clamping just wakes it early to find nothing to do. const huge = nextTimeout(0, .{ .idle_exit_at = std.math.maxInt(i64) }); try testing.expectEqual(std.math.maxInt(i32), huge); try testing.expect(huge > 0); @@ -963,8 +776,6 @@ test "bind takes the lock, and a second bind on the same directory declines" { try environ.put("LCC_WATCH_DIR", base); const socket_path = watch_paths.socket(arena, &environ) catch |err| switch (err) { - // A deep tmpDir can exceed Darwin's 104-byte sun_path. Skipping beats - // failing on something that says nothing about the code under test. error.SocketPathTooLong => return error.SkipZigTest, else => return err, }; @@ -984,9 +795,6 @@ test "bind takes the lock, and a second bind on the same directory declines" { var first = (try bind(app, .{})) orelse return error.TestExpectedEqual; defer first.deinit(io); - // The two-daemon guard. A second daemon must decline in milliseconds and - // leave the live one's socket alone, rather than unlinking it and listening - // on top — which would strand every session the first one owns. const before = try Io.Dir.cwd().statFile(io, first.socket_path, .{}); try testing.expect((try bind(app, .{})) == null); const after = try Io.Dir.cwd().statFile(io, first.socket_path, .{}); @@ -1012,9 +820,6 @@ test "a stale socket left by a killed daemon is replaced, not tripped over" { else => return err, }; - // What SIGKILL leaves behind: the kernel drops the flock but the socket - // file stays. std's `listen` does not unlink, so without the step in `bind` - // every restart after a crash would fail with AddressInUse forever. try Io.Dir.cwd().writeFile(io, .{ .sub_path = socket_path, .data = "stale" }); var out_buf: [1024]u8 = undefined; @@ -1032,9 +837,6 @@ test "a stale socket left by a killed daemon is replaced, not tripped over" { defer bound.deinit(io); } -/// A blocking framed exchange over a raw socket fd, for the end-to-end test -/// below. Deliberately not `watch_client`: a test that used the client would -/// pass just as happily if both sides shared the same misunderstanding. const TestConn = struct { fd: std.posix.fd_t, buf: []u8, @@ -1063,8 +865,6 @@ const TestConn = struct { if (payload.len > 0) _ = std.c.write(self.fd, payload.ptr, payload.len); } - /// Deadlined, because the failure mode without one is a CI job that hangs - /// instead of a test that fails. fn recv(self: *TestConn, want: wire.Type, budget_ms: *i32) !wire.Frame { while (budget_ms.* > 0) { if (try self.dec.next()) |frame| { @@ -1099,24 +899,13 @@ fn runForTest(app: app_mod.App, opts: Options) void { run(app, opts) catch {}; } -/// A stand-in for `claude`, written into the test's own tmpdir. -/// -/// `/bin/cat` played this part directly until the daemon started launching -/// sessions the way real ones are launched — behind `--settings` — at which -/// point it began exiting with "illegal option" before a test could type a -/// byte into it. A script ignores what it is handed, which is exactly what a -/// stand-in for a program with flags has to do. fn standIn(arena: std.mem.Allocator, io: Io, base: []const u8, script: []const u8) ![]const u8 { const path = try std.fs.path.join(arena, &.{ base, "claude-stand-in" }); try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = script }); - // 0o755: `writeFile` leaves it 0o644, and the daemon execs this. if (chmod(try arena.dupeZ(u8, path), 0o755) != 0) return error.Unexpected; return path; } -/// Echoes on a pty and ends on Ctrl-D, the way `cat` does — `exec`, so the -/// session's pid is the one doing the echoing and nothing about the process -/// tree changes. const stand_in_cat = "#!/bin/sh\nexec cat\n"; test "a registered session runs, echoes, and its output survives a reconnect" { @@ -1137,8 +926,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { else => return err, }; - // The daemon gets its own arena: an `ArenaAllocator` is not threadsafe, and - // this test is the one place two threads allocate at once. var daemon_arena: std.heap.ArenaAllocator = .init(gpa); defer daemon_arena.deinit(); var out_buf: [4096]u8 = undefined; @@ -1152,13 +939,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { .ui = .{ .io = io, .out = &out_w, .err = &err_w }, }; - // `foreground` exists for this: the loop has to run somewhere a test can - // reach it, and a double fork would take it out of this process entirely. - // - // Running it on a thread also means every `pty.spawn` below forks from a - // multithreaded process — precisely the hazard this module's header is - // about — so this doubles as proof that the code between the fork and the - // execve stays inside what is safe there. const thread = try std.Thread.spawn(.{}, runForTest, .{ daemon_app, Options{ .foreground = true, .idle_exit_seconds = 3600, @@ -1177,9 +957,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { var b: i32 = 15_000; try conn.hello(arena, &b); - // `/bin/cat` stands in for `claude`: it is a real program on a real pty - // whose behaviour is entirely predictable, which is what makes the - // assertions below about the daemon rather than about Claude Code. try conn.send(arena, .register, wire.Register{ .worktree = base, .branch = "feature/pe-1-test", @@ -1196,7 +973,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { try testing.expect(body.session_id.len > 0); try testing.expect(body.pid > 0); - // Attach, type, and read it back through the pty's echo. try conn.send(arena, .hello, wire.Hello{ .role = "attach", .pid = 0 }); var attach_conn = try TestConn.open(arena, io, socket_path); defer attach_conn.close(); @@ -1222,11 +998,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { } try testing.expect(std.mem.indexOf(u8, seen.items, "hello-from-lcc") != null); - // A hook report, keyed by worktree exactly as `lcc watch-hook` sends - // it. This is the last link in the status path and the only one neither - // side's unit tests can reach: `watch_status` proves the transition and - // `watch_hooks` proves the payload, but nothing else proves the daemon - // finds the right session from a cwd and applies it. try conn.send(arena, .hook, wire.Hook{ .cwd = base, .session_id = "irrelevant", @@ -1242,9 +1013,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { } try testing.expect(waited > 0); - // A cwd the daemon has never heard of is ignored, not an error: the - // hook fires for anything launched with these settings, and a stale one - // must not be able to make the daemon fail. try conn.send(arena, .hook, wire.Hook{ .cwd = "/nowhere-at-all", .session_id = "x", @@ -1255,9 +1023,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { 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", @@ -1275,9 +1040,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { try testing.expect(waited > 0); } - // Everything above is gone — both connections closed, as if the terminal - // had been shut. The session must still be there, and its scrollback with - // it. This is the whole feature in one assertion. { var conn = try TestConn.open(arena, io, socket_path); defer conn.close(); @@ -1303,8 +1065,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { }); _ = try attach_conn.recv(.attached, &ab); - // Replayed from the ring, not produced again: nothing has typed into - // this session since the first connection closed. var replay: std.ArrayList(u8) = .empty; defer replay.deinit(gpa); while (ab > 0) { @@ -1319,11 +1079,6 @@ test "a registered session runs, echoes, and its output survives a reconnect" { } test "a hook reports to the socket it was handed, not to the one its environment names" { - // The bug this pins: `--socket` was parsed into `HookOpts` and then never - // read, so every report went to whatever `watch_paths.socket` derived from - // the environment. Those two agree on a default install, which is why it - // survived — a daemon under `LCC_WATCH_DIR` got no reports at all, silently, - // and every session it held sat at the status it registered with. const gpa = testing.allocator; const io = testing.io; var arena_state: std.heap.ArenaAllocator = .init(gpa); @@ -1370,10 +1125,6 @@ test "a hook reports to the socket it was handed, not to the one its environment defer conn.close(); var b: i32 = 15_000; try conn.hello(arena, &b); - // Deferred rather than written at the end: `thread.join()` above waits for - // the loop to leave, and only `.stop` makes it. Without this a failed - // assertion below would hang the run instead of reporting — which is the - // failure mode `TestConn.recv`'s budget exists to avoid in the first place. defer conn.send(arena, .stop, wire.Stop{ .force = true }) catch {}; try conn.send(arena, .register, wire.Register{ @@ -1390,8 +1141,6 @@ test "a hook reports to the socket it was handed, not to the one its environment const body = try wire.parse(wire.Registered, arena, try conn.recv(.registered, &b)); try testing.expectEqualStrings("starting", body.status); - // A hook's environment is the *session's* — whichever shell started it — so - // this stands in for one that names a directory holding no daemon. var elsewhere: std.process.Environ.Map = .init(arena); try elsewhere.put("LCC_WATCH_DIR", try std.fs.path.join(arena, &.{ base, "no-daemon-here" })); var hook_out: Io.Writer = .fixed(&out_buf); @@ -1411,15 +1160,10 @@ test "a hook reports to the socket it was handed, not to the one its environment } }.get; - // 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", ""); 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", ""); var waited: i32 = 5_000; while (waited > 0) : (waited -= 100) { @@ -1430,12 +1174,6 @@ test "a hook reports to the socket it was handed, not to the one its environment } test "a session is launched with the hook settings, read back off the real process" { - // Asserted against the child's actual command line rather than against the - // argv this test handed in, because the bug was the gap between the two: - // `writeHookSettings` wrote the file and nothing ever passed it to - // `claude --settings`. Every unit here passed. The session simply had no - // hooks in it, reached `idle` on its first byte of output, and stayed there - // through hours of work. const gpa = testing.allocator; const io = testing.io; var arena_state: std.heap.ArenaAllocator = .init(gpa); @@ -1485,9 +1223,6 @@ test "a session is launched with the hook settings, read back off the real proce try conn.hello(arena, &b); defer conn.send(arena, .stop, wire.Stop{ .force = true }) catch {}; - // No `exec` in this one: the shell has to stay as the session's process so - // `ps` can still read the argv it was launched with. An `exec` would - // replace the image and take the command line with it. try conn.send(arena, .register, wire.Register{ .worktree = base, .branch = "feature/pe-3-settings", @@ -1504,15 +1239,10 @@ test "a session is launched with the hook settings, read back off the real proce const pid = try std.fmt.allocPrint(arena, "{d}", .{body.pid}); const line = try exec.capture(arena, io, &.{ "ps", "-p", pid, "-ww", "-o", "command=" }, null); - // The file the daemon wrote, by the path the daemon wrote it to. A flag - // naming some other file would install no hooks at all. const flag = try std.fmt.allocPrint(arena, "--settings {s}", .{hooks_path}); try testing.expect(std.mem.indexOf(u8, line, flag) != null); try Io.Dir.cwd().access(io, hooks_path, .{}); - // The client's own arguments survive, in order and unmangled: carrying the - // repo's local MCP servers and resuming a transcript are what `lcc open` - // asks for, and dropping either would trade one silent failure for another. try testing.expect(std.mem.indexOf(u8, line, "--mcp-config /tmp/carried.json --resume") != null); } @@ -1591,9 +1321,6 @@ test "a child that exits tells the attached client instead of leaving it on a de }); _ = try attach_conn.recv(.attached, &ab); - // Typed first, then ended — the order that matters. Someone types `/exit` - // into the agent, so the frame that reports the exit is never the first - // thing this client has exchanged. try attach_conn.raw(.input, "hello\n"); var echoed: std.ArrayList(u8) = .empty; defer echoed.deinit(gpa); @@ -1603,13 +1330,8 @@ test "a child that exits tells the attached client instead of leaving it on a de if (std.mem.indexOf(u8, echoed.items, "hello") != null) break; } - // Ctrl-D at the start of a line: the tty driver hands `cat` EOF and it - // exits of its own accord, which is what `/exit` looks like to the daemon. try attach_conn.raw(.input, &.{0x04}); - // The assertion the attach loop's only exit condition rests on. Without - // this frame the client polls a session that will never speak again and the - // person inside it can do nothing but detach — a hang with no explanation. _ = attach_conn.recv(.exited, &ab) catch |err| { std.debug.print( "no `exited` frame arrived within {d}ms of the child ending: {s}.\n" ++ @@ -1671,11 +1393,6 @@ test "a grandchild still holding the pty cannot hide the child's exit" { var b: i32 = 15_000; try conn.hello(arena, &b); - // Claude Code's real shape, reduced: it leaves MCP servers, shells and - // watchers behind it, and every one of them inherits the pty slave. The - // master therefore does *not* report EOF when the agent itself exits, so a - // daemon that learned about exits only by reading the pty would never learn - // about this one. `sleep` outlives `cat` by design. try conn.send(arena, .register, wire.Register{ .worktree = base, .branch = "feature/pe-3-grandchild", @@ -1758,7 +1475,5 @@ test "a directory sitting on the socket path is a named error, not a panic" { .ui = .{ .io = io, .out = &out_w, .err = &err_w }, }; - // Naming it beats falling through to an AddressInUse that sends the reader - // looking for another daemon that does not exist. try testing.expectError(BindError.SocketPathOccupied, bind(app, .{})); } diff --git a/src/derived_data.zig b/src/derived_data.zig index 5bdd8f2..b933310 100644 --- a/src/derived_data.zig +++ b/src/derived_data.zig @@ -1,32 +1,21 @@ -//! Xcode DerivedData: locating it, matching folders to worktrees, and -//! reclaiming the ones whose project is gone. - const std = @import("std"); const Io = std.Io; const disk = @import("disk.zig"); const exec = @import("exec.zig"); pub const Entry = struct { - /// Absolute path of the folder, e.g. `.../DerivedData/MyApp-abcdef…`. path: []const u8, - /// Folder name — the same string Xcode shows in its build locations. name: []const u8, - /// Project or workspace this folder was built for, from `info.plist`. workspace_path: []const u8, }; pub const Sized = struct { entry: Entry, - /// Disk usage in bytes. size: u64, }; pub const Error = error{RefusingToDelete} || std.mem.Allocator.Error; -/// Where Xcode keeps DerivedData. Honours a custom *absolute* location from Xcode's -/// preferences; a relative one ("Relative to Workspace") lives inside the project and -/// is already removed along with the worktree, so the default root still applies. -/// `LCC_DERIVED_DATA` overrides both. pub fn root( gpa: std.mem.Allocator, io: Io, @@ -49,16 +38,11 @@ pub fn root( "defaults", "read", "com.apple.dt.Xcode", "IDECustomDerivedDataLocation", }, null)) |custom| { if (custom.len > 0 and std.fs.path.isAbsolute(custom)) return custom; - } else |_| { - // Key unset — Xcode is using the default location. - } + } else |_| {} return std.fs.path.join(gpa, &.{ home, "Library", "Developer", "Xcode", "DerivedData" }); } -/// Every per-project folder under `dir_path`. Xcode's own shared caches -/// (`ModuleCache.noindex`, `SDKStatCaches.noindex`, …) carry no `info.plist` and are -/// skipped — they belong to no project and must never be treated as removable. pub fn list(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) ![]Entry { var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return &.{}; defer dir.close(io); @@ -81,21 +65,18 @@ fn readWorkspacePath(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) !?[]c if (try matchWorkspacePath(gpa, raw)) |value| return value; - // Some Xcode versions write a binary plist — convert before matching. const converted = exec.capture(gpa, io, &.{ "plutil", "-convert", "xml1", "-o", "-", plist, }, null) catch return null; return matchWorkspacePath(gpa, converted); } -/// Pulls `WorkspacePath` out of the plist XML. fn matchWorkspacePath(gpa: std.mem.Allocator, xml: []const u8) !?[]const u8 { const key = "WorkspacePath"; const key_at = std.mem.indexOf(u8, xml, key) orelse return null; const after = xml[key_at + key.len ..]; const open_at = std.mem.indexOf(u8, after, "") orelse return null; - // Only whitespace may sit between the key and its value. for (after[0..open_at]) |c| { if (!std.ascii.isWhitespace(c)) return null; } @@ -133,15 +114,12 @@ fn decodeEntities(gpa: std.mem.Allocator, value: []const u8) ![]const u8 { return out.toOwnedSlice(gpa); } -/// Folders whose project lives inside `worktree_path`. A worktree can own more than -/// one (an `.xcodeproj` and a `Package.swift`, say), so all matches come back. pub fn forWorktree( gpa: std.mem.Allocator, io: Io, entries: []const Entry, worktree_path: []const u8, ) ![]Entry { - // Xcode records the resolved path; `git worktree list` does not resolve symlinks. const resolved = disk.realPath(gpa, io, worktree_path); var matched: std.ArrayList(Entry) = .empty; @@ -151,7 +129,6 @@ pub fn forWorktree( return matched.toOwnedSlice(gpa); } -/// Entries whose project is gone from disk — the worktree was removed long ago. pub fn orphans(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Entry { var dead: std.ArrayList(Entry) = .empty; for (entries) |entry| { @@ -163,7 +140,6 @@ pub fn orphans(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Entry return dead.toOwnedSlice(gpa); } -/// Attaches disk usage to each entry. pub fn withSizes(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Sized { const paths = try gpa.alloc([]const u8, entries.len); for (entries, 0..) |entry, i| paths[i] = entry.path; @@ -174,8 +150,6 @@ pub fn withSizes(gpa: std.mem.Allocator, io: Io, entries: []const Entry) ![]Size return sized; } -/// Delete one DerivedData folder. Refuses anything that is not a direct child of -/// `dir_path`, so the root itself and Xcode's shared caches can never be hit. pub fn remove(gpa: std.mem.Allocator, io: Io, entry: Entry, dir_path: []const u8) !void { _ = gpa; if (entry.name.len == 0) return Error.RefusingToDelete; diff --git a/src/disk.zig b/src/disk.zig index e44a56d..92ece4b 100644 --- a/src/disk.zig +++ b/src/disk.zig @@ -1,18 +1,7 @@ -//! Paths and sizes on disk: containment checks, symlink resolution, and `du`. -//! -//! Shared by everything lcc reclaims — Xcode build data and Claude Code session -//! transcripts both need "is this inside that worktree?" and "how big is it?". - const std = @import("std"); const Io = std.Io; const exec = @import("exec.zig"); -/// Disk usage in bytes for each path, in the order given. One `du` covers every -/// path — walking millions of build artifacts in-process is far slower, and one -/// child process beats one per folder. -/// -/// Unmeasurable paths come back as 0 rather than an error: a size is decoration -/// on a confirmation prompt, never a reason to refuse the operation. pub fn usage(gpa: std.mem.Allocator, io: Io, paths: []const []const u8) ![]u64 { const sizes = try gpa.alloc(u64, paths.len); @memset(sizes, 0); @@ -22,7 +11,6 @@ pub fn usage(gpa: std.mem.Allocator, io: Io, paths: []const []const u8) ![]u64 { try argv.appendSlice(gpa, &.{ "du", "-sk" }); for (paths) |path| try argv.append(gpa, path); - // `du` exits non-zero on unreadable subdirectories but still prints totals. const out = exec.run(gpa, io, argv.items, null) catch return sizes; defer out.deinit(gpa); @@ -41,20 +29,15 @@ pub fn usage(gpa: std.mem.Allocator, io: Io, paths: []const []const u8) ![]u64 { return sizes; } -/// True when `child` sits at or below `parent`. Both must be absolute. pub fn isInside(gpa: std.mem.Allocator, parent: []const u8, child: []const u8) bool { const rel = std.fs.path.relativePosix(gpa, parent, parent, child) catch return false; return rel.len != 0 and !std.mem.startsWith(u8, rel, "..") and !std.fs.path.isAbsolute(rel); } -/// `target` with symlinks resolved, or `target` itself when it cannot be resolved. -/// Xcode and Claude Code both record resolved paths; `git worktree list` does not. pub fn realPath(gpa: std.mem.Allocator, io: Io, target: []const u8) []const u8 { return Io.Dir.cwd().realPathFileAlloc(io, target, gpa) catch target; } -/// Refuses anything that is not a direct child of `parent`, so a root directory -/// and its siblings can never be hit by a caller's delete. pub fn removeChild(io: Io, parent: []const u8, path: []const u8) !void { const dirname = std.fs.path.dirname(path) orelse return error.RefusingToDelete; const trimmed = std.mem.trimEnd(u8, parent, "/"); @@ -63,8 +46,6 @@ pub fn removeChild(io: Io, parent: []const u8, path: []const u8) !void { try Io.Dir.cwd().deleteTree(io, path); } -/// `$HOME/x/y` shown as `~/x/y`. Purely cosmetic — for table columns that would -/// otherwise spend 15 columns on a home directory the user already knows. pub fn abbreviate(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, path: []const u8) []const u8 { const home = environ.get("HOME") orelse return path; if (home.len == 0 or !std.mem.startsWith(u8, path, home)) return path; @@ -111,6 +92,5 @@ test "abbreviate only collapses a whole path segment" { try std.testing.expectEqualStrings("~/Projects/x", abbreviate(arena, &environ, "/Users/me/Projects/x")); try std.testing.expectEqualStrings("~", abbreviate(arena, &environ, "/Users/me")); - // `/Users/mercury` shares the prefix but is a different user. try std.testing.expectEqualStrings("/Users/mercury/x", abbreviate(arena, &environ, "/Users/mercury/x")); } diff --git a/src/exec.zig b/src/exec.zig index ba62590..15d4766 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -1,12 +1,8 @@ -//! Thin wrapper over `std.process` — the `execa` replacement. - const std = @import("std"); const Io = std.Io; pub const Error = error{ - /// The program could not be started (missing binary, bad cwd, …). SpawnFailed, - /// The program ran but exited non-zero. CommandFailed, } || std.mem.Allocator.Error; @@ -24,7 +20,6 @@ pub const Output = struct { return self.code == 0; } - /// stdout without surrounding whitespace — what every caller actually wants. pub fn trimmed(self: Output) []const u8 { return std.mem.trim(u8, self.stdout, " \t\r\n"); } @@ -41,7 +36,6 @@ fn exitCode(term: std.process.Child.Term) u8 { }; } -/// Runs to completion, capturing both streams. Caller owns the result. pub fn run(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: ?[]const u8) Error!Output { const result = std.process.run(gpa, io, .{ .argv = argv, @@ -54,8 +48,6 @@ pub fn run(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: ?[]con }; } -/// Trimmed stdout, or `CommandFailed` when the program exits non-zero. -/// Caller owns the returned memory. pub fn capture(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: ?[]const u8) Error![]u8 { const out = try run(gpa, io, argv, cwd); defer out.deinit(gpa); @@ -63,16 +55,12 @@ pub fn capture(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: ?[ return gpa.dupe(u8, out.trimmed()); } -/// True when the program exits zero. Used for git's `--quiet` probes, where the -/// exit status *is* the answer. pub fn succeeds(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: ?[]const u8) bool { const out = run(gpa, io, argv, cwd) catch return false; defer out.deinit(gpa); return out.ok(); } -/// Runs with stdio wired to the terminal, for commands whose output belongs to -/// the user (`git worktree add`) or that take over the session (`claude`). pub fn inherit(io: Io, argv: []const []const u8, cwd: ?[]const u8) Error!u8 { var child = std.process.spawn(io, .{ .argv = argv, @@ -85,62 +73,35 @@ pub fn inherit(io: Io, argv: []const []const u8, cwd: ?[]const u8) Error!u8 { return exitCode(term); } -/// Spawns and never waits, for a process meant to outlive this one. -/// -/// stdio goes to `log_path` rather than being discarded: a daemon that dies -/// during startup has no terminal to complain to, and without a log the only -/// symptom is a client reporting that nothing answered. pub fn detached(io: Io, argv: []const []const u8, log_path: []const u8) Error!void { const log = Io.Dir.cwd().createFile(io, log_path, .{ .truncate = false }) catch null; defer if (log) |f| f.close(io); const sink: std.process.SpawnOptions.StdIo = if (log) |f| .{ .file = f } else .ignore; - // No `pgid`: `setpgid(0, 0)` would make the child a process-group leader, - // and `setsid` fails with EPERM for a leader — which is exactly what the - // daemon does first. var child = std.process.spawn(io, .{ .argv = argv, .stdin = .ignore, .stdout = sink, .stderr = sink, }) catch return Error.SpawnFailed; - // Deliberately not waited on. The first of the daemon's two forks exits - // immediately, so this reaps in milliseconds and the grandchild is - // reparented to launchd. _ = child.wait(io) catch {}; } -/// The absolute path of the running binary. -/// -/// `std.fs.selfExePath` is gone in 0.16, and `argv[0]` will not do: `lcc` on -/// PATH is a symlink into `zig-out/bin`, so re-execing it would run whichever -/// build the symlink points at now rather than the one that is running. pub fn selfPath(gpa: std.mem.Allocator, io: Io) ![]const u8 { var buf: [std.fs.max_path_bytes]u8 = undefined; var size: u32 = buf.len; if (std.c._NSGetExecutablePath(&buf, &size) != 0) return error.NameTooLong; const raw = std.mem.sliceTo(&buf, 0); - // `_NSGetExecutablePath` can return a path with symlinks and `..` still in - // it; resolving keeps the hook command stable across rebuilds. return Io.Dir.cwd().realPathFileAlloc(io, raw, gpa) catch gpa.dupe(u8, raw); } -/// When the running binary was last written, in whole seconds. -/// -/// Null when it cannot be determined, and callers must treat that as "no -/// opinion": the one thing this feeds is a warning, and a warning invented from -/// a failed stat is worse than none. pub fn selfModified(gpa: std.mem.Allocator, io: Io) ?i64 { const path = selfPath(gpa, io) catch return null; const info = Io.Dir.cwd().statFile(io, path, .{}) catch return null; - // Nanoseconds are `i96` at the source, and seconds are what the registry - // records its own timestamps in — comparing the two needs one unit. const ns = std.math.cast(i64, info.mtime.nanoseconds) orelse return null; return @divFloor(ns, std.time.ns_per_s); } -/// Combined output, trimmed — for error messages that should quote what the -/// failing command said. pub fn message(out: Output) []const u8 { const err = std.mem.trim(u8, out.stderr, " \t\r\n"); if (err.len > 0) return err; diff --git a/src/fold.zig b/src/fold.zig index 9cd5e22..9d76009 100644 --- a/src/fold.zig +++ b/src/fold.zig @@ -1,37 +1,17 @@ -//! Case-insensitive matching over UTF-8. -//! -//! `std.ascii.indexOfIgnoreCase` only folds A-Z, which silently made search -//! case-sensitive for Ukrainian and Russian issue titles. This folds the three -//! alphabets lcc actually meets: ASCII, Latin-1 Supplement, and Cyrillic. -//! -//! Deliberately *not* a general Unicode implementation. Latin Extended-A -//! (Polish, Czech, Turkish) has irregular case pairs and multi-codepoint -//! foldings; covering it half-correctly would be worse than leaving it out, -//! and none of it shows up in this workspace. - const std = @import("std"); -/// Simple lowercase mapping for the covered ranges; identity elsewhere. pub fn foldCodepoint(cp: u21) u21 { if (cp < 0x80) return std.ascii.toLower(@intCast(cp)); - // Latin-1 Supplement: À-Þ → à-þ. U+00D7 (×) sits inside the range and is - // not a letter. if (cp >= 0x00C0 and cp <= 0x00DE and cp != 0x00D7) return cp + 0x20; - // Cyrillic supplement block: Ѐ-Џ → ѐ-џ, which is where Ё, Є, І and Ї live. if (cp >= 0x0400 and cp <= 0x040F) return cp + 0x50; - // Cyrillic basic: А-Я → а-я. if (cp >= 0x0410 and cp <= 0x042F) return cp + 0x20; - // Historic and non-Slavic Cyrillic, including Ukrainian Ґ (U+0490): - // strictly even/odd upper/lower pairs. if (cp >= 0x0460 and cp <= 0x04FF and cp % 2 == 0) return cp + 1; return cp; } -/// Decodes UTF-8, degrading to one codepoint per byte on malformed input so a -/// search over odd data can never fail or loop. const Codepoints = struct { bytes: []const u8, i: usize = 0, @@ -65,7 +45,6 @@ fn matchesAt(haystack: []const u8, start: usize, needle: []const u8) bool { return true; } -/// Byte offset of the first case-insensitive occurrence of `needle`. pub fn indexOf(haystack: []const u8, needle: []const u8) ?usize { if (needle.len == 0) return 0; var it: Codepoints = .{ .bytes = haystack }; @@ -101,11 +80,8 @@ test "ascii folding" { } test "cyrillic folding covers russian and ukrainian letters" { - // The regression this module exists for: a lowercase query against an - // uppercase title. try std.testing.expect(contains("ВИПРАВИТИ ПАДІННЯ", "виправити")); try std.testing.expect(contains("виправити падіння", "ПАДІННЯ")); - // Ї, І, Є, Ґ are in the supplement block, not the basic one. try std.testing.expect(contains("ЇЖАК", "їжак")); try std.testing.expect(contains("ІНДЕКС", "індекс")); try std.testing.expect(contains("ЄДНІСТЬ", "єдність")); @@ -117,7 +93,6 @@ test "cyrillic folding covers russian and ukrainian letters" { test "latin-1 accents fold" { try std.testing.expect(contains("CAFÉ", "café")); try std.testing.expect(contains("Über", "ÜBER")); - // Multiplication sign must not be treated as a letter. try std.testing.expectEqual(@as(u21, 0x00D7), foldCodepoint(0x00D7)); } diff --git a/src/git.zig b/src/git.zig index 2652bfe..bd85d24 100644 --- a/src/git.zig +++ b/src/git.zig @@ -1,6 +1,3 @@ -//! Everything lcc does with git. Same shell-outs as the TypeScript version — -//! `execa` becomes `std.process`, nothing else changes. - const std = @import("std"); const Io = std.Io; const exec = @import("exec.zig"); @@ -11,14 +8,6 @@ pub const Error = error{ GitFailed, } || std.mem.Allocator.Error; -/// Absolute path of the *main* worktree of the repository containing `cwd` (or -/// the process cwd). -/// -/// Deliberately not `--show-toplevel`: inside a linked worktree that answers the -/// worktree itself, and everything lcc derives from the root — the worktree path -/// template, `.git/info/exclude`, the repo-root `.env` files — has to hang off -/// the main checkout. `--git-common-dir` is the shared `.git` either way, so its -/// parent is the main worktree. pub fn repoRoot(gpa: std.mem.Allocator, io: Io, cwd: ?[]const u8) Error![]u8 { const toplevel = exec.capture(gpa, io, &.{ "git", "rev-parse", "--show-toplevel" }, cwd) catch return Error.NotAGitRepository; @@ -28,8 +17,6 @@ pub fn repoRoot(gpa: std.mem.Allocator, io: Io, cwd: ?[]const u8) Error![]u8 { }, cwd) catch return toplevel; defer gpa.free(common); - // `/.git` is the only shape whose parent is the checkout; a - // bare repo or `--separate-git-dir` puts the common dir somewhere unrelated. if (!std.mem.eql(u8, std.fs.path.basename(common), ".git")) return toplevel; const main_root = std.fs.path.dirname(common) orelse return toplevel; gpa.free(toplevel); @@ -39,13 +26,8 @@ pub fn repoRoot(gpa: std.mem.Allocator, io: Io, cwd: ?[]const u8) Error![]u8 { pub const Repo = struct { gpa: std.mem.Allocator, io: Io, - /// Main worktree — the anchor for derived paths and `.git` writes. root: []const u8, - /// Where lcc was actually invoked, which is what "the current branch" means - /// when that place is a linked worktree. Null inherits the process cwd. cwd: ?[]const u8 = null, - /// stdout is carrying a machine-readable payload, so no child may write to it. - /// Set by `--json`, where a stray line of git progress would corrupt the output. stdout_reserved: bool = false, fn captureIn(self: Repo, cwd: ?[]const u8, argv: []const []const u8) ?[]u8 { @@ -60,8 +42,6 @@ pub const Repo = struct { return exec.succeeds(self.gpa, self.io, argv, self.root); } - /// origin/HEAD when it is set, else a local `main`/`master`, else whatever - /// HEAD currently points at. pub fn defaultBranch(self: Repo) Error![]const u8 { if (self.capture(&.{ "git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD" })) |out| { if (std.mem.startsWith(u8, out, "origin/")) return out["origin/".len..]; @@ -75,9 +55,6 @@ pub const Repo = struct { return Error.GitFailed; } - /// The branch checked out where lcc was invoked — not `root`'s, which is a - /// different branch whenever the user is standing in a linked worktree. - /// Null when HEAD is detached. pub fn currentBranch(self: Repo) Error!?[]const u8 { const out = self.captureIn(self.cwd, &.{ "git", "rev-parse", "--abbrev-ref", "HEAD" }) orelse return Error.GitFailed; @@ -85,10 +62,9 @@ pub const Repo = struct { return out; } - /// Local and remote branches, `origin/` stripped, deduplicated, sorted. pub fn listBranches(self: Repo) Error![][]const u8 { const out = self.capture(&.{ - "git", "for-each-ref", + "git", "for-each-ref", "--format=%(refname:short)", "refs/heads/", "refs/remotes/", }) orelse return Error.GitFailed; @@ -107,9 +83,6 @@ pub const Repo = struct { return names; } - /// One `for-each-ref` for every local branch: its upstream, how far the two - /// have drifted, and when the tip was committed. A single call, so the cost - /// does not grow with the number of worktrees on screen. pub fn branchStatuses(self: Repo) Error![]BranchStatus { const out = self.capture(&.{ "git", @@ -144,12 +117,7 @@ pub const Repo = struct { return statuses.toOwnedSlice(self.gpa); } - /// Number of entries `git status --porcelain` reports in a worktree, or null - /// when the worktree cannot be inspected — a prunable one whose directory - /// has already been deleted, say. pub fn dirtyCount(self: Repo, worktree_path: []const u8) ?u32 { - // Not `capture`: that runs in `root`, the main worktree, which would report - // the same status for every row on the dashboard. const out = self.captureIn(worktree_path, &.{ "git", "status", "--porcelain" }) orelse return null; @@ -205,9 +173,6 @@ pub const Repo = struct { return .{ .path = worktree_path, .branch = branch, .created = .new }; } - /// git's progress belongs to the user, so its stdio is the terminal's — unless - /// stdout is spoken for, in which case the run is captured and only its failure - /// is passed on, through `last_error`. fn runInherit(self: Repo, argv: []const []const u8) Error!void { if (self.stdout_reserved) { const out = exec.run(self.gpa, self.io, argv, self.root) catch return Error.GitFailed; @@ -221,8 +186,6 @@ pub const Repo = struct { if (code != 0) return Error.GitFailed; } - /// Adds the worktree's top-level directory to `.git/info/exclude`, so a - /// worktree nested inside the repo does not show up as untracked. fn ensureLocalIgnore(self: Repo, worktree_path: []const u8) !void { const rel = std.fs.path.relativePosix(self.gpa, self.root, self.root, worktree_path) catch return; if (rel.len == 0 or std.mem.startsWith(u8, rel, "..") or std.fs.path.isAbsolute(rel)) return; @@ -304,17 +267,7 @@ pub const Repo = struct { return slice; } - /// Brings the remote-tracking refs up to date and drops the ones whose remote - /// branch is gone. - /// - /// Every signal a removal decision rests on is read out of those refs, and - /// none of them move on their own: `origin/master` only grows once something - /// fetches it, and `%(upstream:track)` cannot say `[gone]` until the pruning - /// happens. Without this, `lcc` is deciding what to delete from whatever the - /// last `git pull` happened to leave behind. pub fn fetchPrune(self: Repo) Error!void { - // Captured rather than inherited: this runs under a progress line, and - // fetch's own output would break it apart for no gain. const out = exec.run(self.gpa, self.io, &.{ "git", "fetch", "--prune" }, self.root) catch return Error.GitFailed; if (!out.ok()) { @@ -323,16 +276,6 @@ pub const Repo = struct { } } - /// Decide whether a branch can be deleted along with its worktree. - /// - /// Being an ancestor of the default branch is the plain case. A *gone* upstream is - /// the other one: the branch was pushed and the remote branch has since been - /// deleted, which is what a squash-merged PR looks like locally — the commits are - /// in the default branch under different SHAs, so ancestry can never prove it. - /// - /// Both are read from local refs, so both are only as current as the last fetch, - /// and neither can speak for a squash-merged branch whose remote branch is still - /// there. `BranchDisposition.withMergedPr` covers that case from GitHub. pub fn branchDisposition(self: Repo, branch: []const u8) Error!BranchDisposition { const base = try self.defaultBranch(); if (std.mem.eql(u8, branch, base)) { @@ -360,12 +303,6 @@ pub const Repo = struct { return std.mem.eql(u8, out, "[gone]"); } - /// Commits `to_ref` has that `from_ref` does not — `git rev-list --count A..B`. - /// - /// Null, not zero, when the range cannot be resolved. Zero is a real answer - /// meaning "nothing new", and the two are opposites wherever the *smallest* - /// count wins: a base whose ref is missing would otherwise come back as the - /// nearest one. Each caller states its own policy for the null. pub fn countAhead(self: Repo, from_ref: []const u8, to_ref: []const u8) ?u32 { const range = std.fmt.allocPrint(self.gpa, "{s}..{s}", .{ from_ref, to_ref }) catch return null; const out = self.capture(&.{ "git", "rev-list", "--count", range }) orelse return null; @@ -377,33 +314,16 @@ pub const Repo = struct { for ([_][]const u8{ origin_base, base }) |ref| { if (self.countAhead(ref, branch)) |count| return count; } - // Deliberate, and now visibly so rather than hidden in an `orelse continue` - // chain that looked like an answer: this number goes into a confirmation - // line, where "unknown" and "nothing unmerged" both mean say nothing alarming. return 0; } - /// The commit HEAD points at *where lcc was invoked*, which is a different - /// commit from `root`'s HEAD whenever the user is standing in a linked - /// worktree. Resolved once so every count afterwards can name it explicitly - /// and still run in `root`. - /// - /// Null on an empty repository, or a worktree directory that is no longer there. pub fn headSha(self: Repo) ?[]const u8 { return self.captureIn(self.cwd, &.{ "git", "rev-parse", "HEAD" }); } - /// The live `origin/release/*` branches with their tip dates, `origin/` - /// stripped. - /// - /// Remote only, and deliberately not `listBranches`: that merges local and - /// remote into one deduplicated set, so a stale local `release/2.4.1` left - /// behind by a shipped release is indistinguishable there from one still being - /// stabilised — and the release-project veto would read it as "trunk is past - /// v2.4.1", which is the one conclusion it must not draw. pub fn remoteReleaseBranches(self: Repo) Error![]RemoteBranch { const out = self.capture(&.{ - "git", "for-each-ref", + "git", "for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/remotes/origin/release/", }) orelse return Error.GitFailed; @@ -424,12 +344,6 @@ pub const Repo = struct { return found.toOwnedSlice(self.gpa); } - /// Every tag, in git's own order — which is lexical, so `v2.10.0` comes before - /// `v2.9.0` and ordering them is the caller's job. - /// - /// `for-each-ref` rather than `git tag`, because `git tag` honours `column.ui` - /// and `tag.sort` from the user's config: the shape of its output is not lcc's - /// to depend on. This is one line per ref whatever the machine is configured like. pub fn listTags(self: Repo) Error![][]const u8 { const out = self.capture(&.{ "git", "for-each-ref", "--format=%(refname:short)", "refs/tags/", @@ -455,20 +369,8 @@ pub const Repo = struct { } } - /// Deletes a branch this repo has already vouched for, and does not take git's - /// "not fully merged" for an answer when lcc's own check says otherwise. True - /// when it took the force to do it. - /// - /// `-d` re-checks the merge against HEAD and the branch's upstream, and those two - /// are the whole of what it can see. A branch merged into `origin/` - /// while local `` is behind sits in neither — and `lcc remove`'s own - /// `fetch --prune` is what takes the upstream away, so the ordinary case (pull - /// request merged, remote branch deleted with it, nothing pulled since) is - /// refused. Ancestry against `origin/` already proved those commits - /// survive, so the retry spends nothing but a second opinion that was blind. pub fn deleteVerified(self: Repo, d: BranchDisposition) Error!bool { self.deleteBranch(d.branch, d.needsForce()) catch |err| { - // Nothing left to escalate to: `-D` is what already failed. if (d.needsForce()) return err; try self.deleteBranch(d.branch, true); return true; @@ -490,18 +392,12 @@ pub const Repo = struct { } }; -/// stderr of the last failed `removeWorktree`, so the caller can show git's own -/// explanation before offering `--force`. pub var last_error: []const u8 = ""; pub const Strategy = enum { reused_local, tracking_remote, new }; -/// A branch that exists on `origin`, named without the remote prefix. pub const RemoteBranch = struct { - /// `release/2.5.2` — `origin/` already stripped. branch: []const u8, - /// Tip commit date, Unix seconds. Not part of any decision; it lets a caller - /// say how stale its local view of `origin` is. committed_at: i64, }; @@ -520,9 +416,6 @@ pub const WorktreeEntry = struct { is_main: bool, }; -/// The worktree `branch` is checked out in, wherever it sits. Not derived from the -/// path template: a worktree created before the template changed, or by hand, is -/// still the one place that branch can be checked out — git allows only one. pub fn worktreeForBranch(entries: []const WorktreeEntry, branch: []const u8) ?WorktreeEntry { for (entries) |entry| { const name = entry.branch orelse continue; @@ -533,22 +426,15 @@ pub fn worktreeForBranch(entries: []const WorktreeEntry, branch: []const u8) ?Wo pub const BranchStatus = struct { branch: []const u8, - /// `origin/feature/x`, or null when the branch was never pushed. upstream: ?[]const u8, ahead: u32, behind: u32, - /// The upstream existed and has since been deleted — what a squash-merged PR - /// looks like locally. gone: bool, - /// Commit timestamp of the branch tip, Unix seconds. 0 when unknown. committed_at: i64, }; pub const Drift = struct { ahead: u32 = 0, behind: u32 = 0, gone: bool = false }; -/// `%(upstream:track)` renders as `[ahead 2]`, `[behind 3]`, `[ahead 2, behind 3]`, -/// `[gone]`, or nothing at all — the last meaning either in sync or no upstream, -/// which `%(upstream:short)` tells apart. pub fn parseTrack(track: []const u8) Drift { const body = std.mem.trim(u8, track, " \t[]"); if (body.len == 0) return .{}; @@ -573,26 +459,11 @@ pub const DispositionReason = enum { merged, merged_pr, upstream_gone, unmerged, pub const BranchDisposition = struct { branch: []const u8, - /// True when the commits survive elsewhere, so deleting the branch loses nothing. safe: bool, - /// Commits on the branch that the default branch does not have. unmerged: u32, reason: DispositionReason, - /// The pull request that vouched for the branch, when that is what did. pr: u32 = 0, - /// The same verdict, with GitHub's answer folded in: it says the branch's pull - /// request is merged. - /// - /// That vouches for commits local ancestry never can. A squash merge rewrites - /// them, so `merge-base --is-ancestor` will keep failing however long you wait, - /// and the `[gone]` upstream that stands in for it only appears once the remote - /// branch has been deleted *and* pruned. A merged pull request is the state - /// both of those are trying to infer. - /// - /// Never overrides a verdict that already stands: the default branch stays - /// undeletable whatever a pull request says, and a plain merge is a better - /// reason than this one. pub fn withMergedPr(self: BranchDisposition, number: u32) BranchDisposition { if (self.safe or self.reason == .default_branch) return self; return .{ @@ -604,21 +475,11 @@ pub const BranchDisposition = struct { }; } - /// Whether git has to be told not to re-check the merge itself. - /// - /// It re-checks against HEAD and the branch's own upstream, and that is the - /// whole of what it can see. `upstream_gone` and `merged_pr` are both ways of - /// surviving a rewrite those two cannot describe, so they need `-D` outright. - /// A plain merge asks for `-d` and keeps git's second opinion — which it can - /// still withhold, since ancestry here is judged against `origin/` as - /// well, and that is a ref `-d` never consults. `deleteVerified` is where that - /// refusal is dealt with. pub fn needsForce(self: BranchDisposition) bool { return self.reason != .merged; } }; -/// `PE-42/some-title` from Linear becomes `feature/some-title`. pub fn rewriteBranchName(gpa: std.mem.Allocator, linear_branch: []const u8, prefix: []const u8) ![]u8 { const tail = if (std.mem.indexOfScalar(u8, linear_branch, '/')) |slash| linear_branch[slash + 1 ..] @@ -647,12 +508,7 @@ pub fn renderWorktreePath( if (std.mem.indexOfScalarPos(u8, template, i, '}')) |close| { const key = template[i + 1 .. close]; const value: ?[]const u8 = - if (std.mem.eql(u8, key, "repoRoot")) repo_root - else if (std.mem.eql(u8, key, "repoParent")) repo_parent - else if (std.mem.eql(u8, key, "repoName")) repo_name - else if (std.mem.eql(u8, key, "branch")) branch - else if (std.mem.eql(u8, key, "branchLeaf")) branch_leaf - else null; + if (std.mem.eql(u8, key, "repoRoot")) repo_root else if (std.mem.eql(u8, key, "repoParent")) repo_parent else if (std.mem.eql(u8, key, "repoName")) repo_name else if (std.mem.eql(u8, key, "branch")) branch else if (std.mem.eql(u8, key, "branchLeaf")) branch_leaf else null; if (value) |v| { try out.appendSlice(gpa, v); i = close + 1; @@ -666,17 +522,11 @@ pub fn renderWorktreePath( return out.toOwnedSlice(gpa); } -/// The leading part of `template` that does not depend on the branch, i.e. the -/// prefix shared by every path it renders. Tells worktrees lcc created from ones -/// added by hand. Comes back empty for a template that opens with a branch -/// placeholder — such a template attributes nothing. pub fn worktreePathPrefix( gpa: std.mem.Allocator, template: []const u8, repo_root: []const u8, ) ![]u8 { - // NUL cannot appear in a path, so it cannot collide with whatever the - // repo placeholders expand to. const rendered = try renderWorktreePath(gpa, template, repo_root, "\x00"); const cut = std.mem.indexOfScalar(u8, rendered, 0) orelse return rendered; defer gpa.free(rendered); @@ -716,7 +566,6 @@ test "parseTrack covers every shape for-each-ref emits" { Drift{ .ahead = 5, .behind = 14 }, parseTrack("[ahead 5, behind 14]"), ); - // Junk must read as "no information", never as a bogus count. try std.testing.expectEqual(Drift{}, parseTrack("[ahead]")); } @@ -731,12 +580,10 @@ test "worktreePathPrefix cuts the template at the branch" { defer gpa.free(nested); try std.testing.expectEqualStrings("/tmp/proj/.lcc/worktrees/", nested); - // A branch baked into the directory name, not just a segment of its own. const infix = try worktreePathPrefix(gpa, "{repoRoot}/wt-{branch}-x", "/tmp/proj"); defer gpa.free(infix); try std.testing.expectEqualStrings("/tmp/proj/wt-", infix); - // No branch placeholder at all: the whole rendered path is the prefix. const fixed = try worktreePathPrefix(gpa, "{repoRoot}/wt", "/tmp/proj"); defer gpa.free(fixed); try std.testing.expectEqualStrings("/tmp/proj/wt", fixed); @@ -749,8 +596,6 @@ test "worktreePathPrefix cuts the template at the branch" { test "worktreeForBranch matches on the branch, not the path" { const entries = [_]WorktreeEntry{ .{ .path = "/r", .branch = "main", .head = "a", .locked = false, .prunable = false, .is_main = true }, - // The path is no longer what the template would render — a renamed issue, - // or a worktree added by hand. The branch is what settles it. .{ .path = "/elsewhere/old", .branch = "feature/pe-1-x", .head = "b", .locked = false, .prunable = false, .is_main = false }, .{ .path = "/r/detached", .branch = null, .head = "c", .locked = false, .prunable = false, .is_main = false }, }; @@ -758,7 +603,6 @@ test "worktreeForBranch matches on the branch, not the path" { try std.testing.expectEqualStrings("/elsewhere/old", worktreeForBranch(&entries, "feature/pe-1-x").?.path); try std.testing.expect(worktreeForBranch(&entries, "main").?.is_main); try std.testing.expect(worktreeForBranch(&entries, "feature/pe-2-y") == null); - // A prefix of a branch name is a different branch. try std.testing.expect(worktreeForBranch(&entries, "feature/pe-1") == null); } @@ -786,17 +630,12 @@ test "repoRoot answers the main worktree from inside a linked worktree" { const from_linked = try repoRoot(gpa, io, linked); defer gpa.free(from_linked); - // The bug this pins: `--show-toplevel` answers the worktree, so a template - // like `{repoParent}/{repoName}.worktrees/…` nested itself one level deeper - // on every start from inside a worktree. const toplevel = try exec.capture(gpa, io, &.{ "git", "rev-parse", "--show-toplevel" }, linked); defer gpa.free(toplevel); try std.testing.expect(!std.mem.eql(u8, toplevel, from_linked)); try std.testing.expectEqualStrings(from_main, from_linked); - // `root` anchors derived paths, but "the current branch" is still the one - // checked out where lcc was invoked — `start` offers it as the base. const repo: Repo = .{ .gpa = gpa, .io = io, .root = from_main, .cwd = linked }; const branch = (try repo.currentBranch()).?; defer gpa.free(branch); @@ -824,13 +663,10 @@ test "release branches are read off origin, and a stale local one does not count try runGit(gpa, io, proj, &.{ "remote", "add", "origin", origin }); try runGit(gpa, io, proj, &.{ "push", "-q", "-u", "origin", "main" }); - // One release still being stabilised, pushed. try runGit(gpa, io, proj, &.{ "checkout", "-q", "-b", "release/2.5.2" }); try runGit(gpa, io, proj, &.{ "commit", "-q", "--allow-empty", "-m", "stabilise" }); try runGit(gpa, io, proj, &.{ "push", "-q", "-u", "origin", "release/2.5.2" }); - // …and one left behind locally after its release shipped. `listBranches` - // cannot tell these two apart, which is the whole reason for the new call. try runGit(gpa, io, proj, &.{ "checkout", "-q", "-b", "release/2.4.1", "main" }); try runGit(gpa, io, proj, &.{ "checkout", "-q", "main" }); @@ -846,8 +682,6 @@ test "release branches are read off origin, and a stale local one does not count try std.testing.expectEqualStrings("release/2.5.2", live[0].branch); try std.testing.expect(live[0].committed_at > 0); - // The confusion this exists to avoid: the shipped release's branch is still - // sitting there locally, and `listBranches` reports it identically. const merged_view = try repo.listBranches(); var saw_stale = false; for (merged_view) |name| { @@ -855,16 +689,11 @@ test "release branches are read off origin, and a stale local one does not count } try std.testing.expect(saw_stale); - // Tags come back whole, in git's lexical order — ordering them is the caller's - // job, and `v2.10.0` before `v2.9.0` is why. const tags = try repo.listTags(); try std.testing.expectEqual(@as(usize, 3), tags.len); - // Null, not zero, for a ref that is not there. Zero means "nothing new", and - // in a contest for the *smallest* count a missing ref would otherwise win. try std.testing.expect(repo.countAhead("origin/release/9.9.9", "HEAD") == null); try std.testing.expectEqual(@as(u32, 0), repo.countAhead("HEAD", "HEAD").?); - // main is one commit behind the release branch's tip. try std.testing.expectEqual(@as(u32, 1), repo.countAhead("origin/main", "origin/release/2.5.2").?); const head = repo.headSha().?; @@ -896,8 +725,6 @@ test "a squash-merged branch reads as unmerged until a pull request vouches for try runGit(gpa, io, proj, &.{ "commit", "-q", "--allow-empty", "-m", "the work" }); try runGit(gpa, io, proj, &.{ "push", "-q", "-u", "origin", "feature/x" }); - // The squash: master gains the same work under a different SHA, and the remote - // branch stays — the repo setting that deletes it on merge is off. try runGit(gpa, io, proj, &.{ "checkout", "-q", "master" }); try runGit(gpa, io, proj, &.{ "commit", "-q", "--allow-empty", "-m", "squashed feature/x" }); try runGit(gpa, io, proj, &.{ "push", "-q", "origin", "master" }); @@ -905,10 +732,6 @@ test "a squash-merged branch reads as unmerged until a pull request vouches for const repo: Repo = .{ .gpa = arena, .io = io, .root = proj }; try repo.fetchPrune(); - // The bug this pins: the work is safely in master, and neither local signal can - // say so. Ancestry fails because the SHA changed, and the upstream is not gone - // because the remote branch is still there — so `lcc remove --merged` offered - // nothing right after the merge, which is exactly when it gets run. const local = try repo.branchDisposition("feature/x"); try std.testing.expect(!local.safe); try std.testing.expectEqual(DispositionReason.unmerged, local.reason); @@ -918,10 +741,7 @@ test "a squash-merged branch reads as unmerged until a pull request vouches for try std.testing.expect(vouched.safe); try std.testing.expectEqual(DispositionReason.merged_pr, vouched.reason); try std.testing.expectEqual(@as(u32, 412), vouched.pr); - // `-d` would still refuse: the commits are not in master's ancestry and never - // will be. try std.testing.expect(vouched.needsForce()); - // The count survives the upgrade — it is what the confirmation shows. try std.testing.expectEqual(@as(u32, 1), vouched.unmerged); } @@ -952,11 +772,7 @@ test "a branch merged where only origin can see it still gets deleted" { try runGit(gpa, io, proj, &.{ "push", "-q", "-u", "origin", "feature/x" }); try runGit(gpa, io, proj, &.{ "checkout", "-q", "master" }); - // The merge happens elsewhere, the way it really does: on GitHub, followed by - // the remote branch being deleted. Nothing pulls master here afterwards. try runGit(gpa, io, base, &.{ "clone", "-q", origin, "other" }); - // A bare repo's HEAD names whatever `init.defaultBranch` says, which need not be - // the branch that was pushed — so the clone is put on master explicitly. try runGit(gpa, io, other, &.{ "checkout", "-q", "-B", "master", "origin/master" }); try runGit(gpa, io, other, &.{ "merge", "-q", "--no-ff", "origin/feature/x", "-m", "merge" }); try runGit(gpa, io, other, &.{ "push", "-q", "origin", "HEAD:master" }); @@ -965,13 +781,10 @@ test "a branch merged where only origin can see it still gets deleted" { const repo: Repo = .{ .gpa = arena, .io = io, .root = proj }; try repo.fetchPrune(); - // lcc sees the merge, because it looks at origin/master too. const d = try repo.branchDisposition("feature/x"); try std.testing.expect(d.safe); try std.testing.expectEqual(DispositionReason.merged, d.reason); - // git does not: local master is behind, and the upstream that could have - // vouched was pruned a moment ago by lcc's own fetch. `-d` alone refuses. try std.testing.expectError(Error.GitFailed, repo.deleteBranch("feature/x", false)); try std.testing.expect(std.mem.indexOf(u8, last_error, "not fully merged") != null); @@ -981,11 +794,9 @@ test "a branch merged where only origin can see it still gets deleted" { test "withMergedPr never overrides a verdict that already stands" { const merged: BranchDisposition = .{ .branch = "b", .safe = true, .unmerged = 0, .reason = .merged }; - // A plain merge is the better reason, and it is the one `-d` accepts. try std.testing.expectEqual(DispositionReason.merged, merged.withMergedPr(1).reason); try std.testing.expect(!merged.needsForce()); - // The default branch is not deletable, whatever pull request came off it. const base: BranchDisposition = .{ .branch = "master", .safe = false, .unmerged = 0, .reason = .default_branch }; const still_base = base.withMergedPr(2); try std.testing.expectEqual(DispositionReason.default_branch, still_base.reason); @@ -999,12 +810,14 @@ test "withMergedPr never overrides a verdict that already stands" { fn runGit(gpa: std.mem.Allocator, io: Io, cwd: []const u8, args: []const []const u8) !void { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(gpa); - // The test repo must not depend on the developer's global git config. try argv.appendSlice(gpa, &.{ "git", - "-c", "user.email=lcc@example.com", - "-c", "user.name=lcc", - "-c", "commit.gpgsign=false", + "-c", + "user.email=lcc@example.com", + "-c", + "user.name=lcc", + "-c", + "commit.gpgsign=false", }); try argv.appendSlice(gpa, args); @@ -1047,7 +860,7 @@ test "ensureLocalIgnore adds the worktree root once" { const arena_repo: Repo = .{ .gpa = arena_state.allocator(), .io = io, .root = root }; try arena_repo.ensureLocalIgnore(worktree); - try arena_repo.ensureLocalIgnore(worktree); // second call must be a no-op + try arena_repo.ensureLocalIgnore(worktree); const exclude = try std.fs.path.join(gpa, &.{ root, ".git", "info", "exclude" }); defer gpa.free(exclude); diff --git a/src/github.zig b/src/github.zig index da3208c..fbf0ad0 100644 --- a/src/github.zig +++ b/src/github.zig @@ -1,24 +1,7 @@ -//! Pull-request state for the dashboard, read through the `gh` CLI. -//! -//! `gh` is treated as optional: it may be absent, unauthenticated, or pointed at -//! a repo with no remote. Any of those yields an empty list rather than an error — -//! one missing column must never fail `lcc list`. -//! -//! Asked branch by branch rather than as "every pull request in this repo". The -//! flat listing is one request only until a repo passes a hundred PRs, after -//! which GitHub pages and `gh` pays a second round trip — and it was already -//! answering with two hundred rows to fill six cells. One aliased connection per -//! branch is a single request whose size tracks the worktrees on screen instead -//! of the repo's history, and it stops silently missing the branch whose PR is -//! older than the last two hundred. - const std = @import("std"); const Io = std.Io; const exec = @import("exec.zig"); -/// Pull requests fetched per branch, newest first. A branch has one, occasionally -/// a second after a botched first attempt; ten is headroom, and `forBranch` -/// decides which of them the column shows. const per_branch = 10; pub const State = enum { @@ -26,8 +9,6 @@ pub const State = enum { merged, closed, - /// Lower sorts first: an open PR is the one worth showing when a branch has - /// several, and a merged one beats an abandoned one. fn rank(self: State) u8 { return switch (self) { .open => 0, @@ -40,13 +21,10 @@ pub const State = enum { pub const PullRequest = struct { number: u32, branch: []const u8, - /// What the pull request merges *into*. The one fact that says which release - /// a feature branch was cut for, and it rides on a request lcc already makes. base: []const u8 = "", state: State, draft: bool, - /// `#412 open`, `#412 draft`, `#398 merged`. pub fn describe(self: PullRequest, gpa: std.mem.Allocator) []const u8 { const label = if (self.draft and self.state == .open) "draft" else @tagName(self.state); return std.fmt.allocPrint(gpa, "#{d} {s}", .{ self.number, label }) catch label; @@ -61,56 +39,35 @@ const RawPr = struct { isDraft: bool = false, }; -/// One aliased `pullRequests` connection per branch, so the whole column is one -/// request. The keys are positional (`b0`, `b1`, …) and never read back: each node -/// carries its own `headRefName`, which is what `forBranch` matches on. const Alias = struct { nodes: []const RawPr = &.{}, }; const Envelope = struct { data: ?struct { - /// Null when the repo could not be resolved — a remote `gh` cannot see. repository: ?std.json.ArrayHashMap(Alias) = null, } = null, errors: ?[]const struct { message: []const u8 = "" } = null, }; -/// The pull requests of `branches`, whatever state they are in. Null — not an -/// empty slice — when `gh` could not answer at all, so a branch with no pull -/// request is not reported as a missing or unauthenticated `gh`. -/// -/// The filter is `headRefName` on the connection rather than a lookup of the ref -/// itself, and that distinction is the whole reason this can replace asking for -/// every pull request in the repo: a merged PR outlives the branch it came from, -/// so `ref(qualifiedName:)` goes null the moment the remote branch is deleted -/// while the connection still answers. pub fn forBranches( gpa: std.mem.Allocator, io: Io, repo_root: []const u8, branches: []const []const u8, ) ?[]const PullRequest { - // Nothing to ask about is not a failure: a repo whose worktrees are all - // detached has no branch to match a pull request against, and no round trip - // would turn that into an answer. if (branches.len == 0) return &.{}; const query = buildQuery(gpa, branches) catch return null; const query_field = std.fmt.allocPrint(gpa, "query={s}", .{query}) catch return null; const raw = exec.capture(gpa, io, &.{ - "gh", "api", "graphql", - // `-F` substitutes gh's `:owner`/`:repo` placeholders for the current - // repo's; `-f` does not. The query itself has to go through `-f`, so gh - // does not try to read it as a typed value. - "-F", "owner=:owner", - "-F", "name=:repo", - "-f", query_field, + "gh", "api", "graphql", + "-F", "owner=:owner", "-F", + "name=:repo", "-f", query_field, }, repo_root) catch return null; const envelope = std.json.parseFromSliceLeaky(Envelope, gpa, raw, .{ .ignore_unknown_fields = true, - // `std.json.ArrayHashMap` has nowhere to put its keys without this. .allocate = .alloc_always, }) catch return null; @@ -139,9 +96,6 @@ fn buildQuery(gpa: std.mem.Allocator, branches: []const []const u8) ![]const u8 var q: std.ArrayList(u8) = .empty; try q.appendSlice(gpa, "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){"); for (branches, 0..) |branch, i| { - // A GraphQL string literal is a JSON string, and a git branch name may - // legally contain a quote — so let the JSON encoder produce the literal - // rather than wrapping it in quotes and hoping. const literal = try std.json.Stringify.valueAlloc(gpa, branch, .{}); try q.appendSlice(gpa, try std.fmt.allocPrint( gpa, @@ -160,8 +114,6 @@ fn parseState(raw: []const u8) State { return .open; } -/// The pull request worth showing for `branch`: open beats merged beats closed, -/// and the highest number wins within a state (the most recent attempt). pub fn forBranch(prs: []const PullRequest, branch: []const u8) ?PullRequest { var best: ?PullRequest = null; for (prs) |pr| { @@ -179,11 +131,6 @@ pub fn forBranch(prs: []const PullRequest, branch: []const u8) ?PullRequest { return best; } -/// The number of the merged pull request that vouches for `branch`, if one does. -/// -/// Deliberately built on `forBranch`, so an open pull request shadows a merged -/// one rather than being ignored: a branch that was merged and then reopened has -/// work in flight again, and nothing about the old merge makes deleting it safe. pub fn mergedFor(prs: []const PullRequest, branch: []const u8) ?u32 { const pr = forBranch(prs, branch) orelse return null; return if (pr.state == .merged) pr.number else null; @@ -198,17 +145,12 @@ test "buildQuery aliases one connection per branch and quotes them as JSON" { const q = try buildQuery(arena, &.{ "feature/a", "release/2.4.1" }); - // One alias per branch, numbered positionally, and both branch names present - // as GraphQL string literals. try std.testing.expect(std.mem.indexOf(u8, q, "b0:pullRequests(headRefName:\"feature/a\"") != null); try std.testing.expect(std.mem.indexOf(u8, q, "b1:pullRequests(headRefName:\"release/2.4.1\"") != null); - // Every node carries the ref, which is what `forBranch` matches on — the alias - // names are never read back. try std.testing.expect(std.mem.indexOf(u8, q, "headRefName }") != null or std.mem.indexOf(u8, q, "state isDraft headRefName") != null); try std.testing.expect(std.mem.endsWith(u8, q, "}}")); - // A quote is legal in a git branch name and must not end the literal early. const nasty = try buildQuery(arena, &.{"feature/say-\"hi\""}); try std.testing.expect(std.mem.indexOf(u8, nasty, "\\\"hi\\\"") != null); } @@ -220,8 +162,6 @@ test "forBranches asks nothing when there is no branch to ask about" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // An empty result, not null: a repo of detached worktrees has no head ref to - // match, which is an answer rather than a failure to reach `gh`. const out = forBranches(arena, std.testing.io, ".", &.{}); try std.testing.expect(out != null); try std.testing.expectEqual(@as(usize, 0), out.?.len); @@ -250,8 +190,6 @@ test "mergedFor answers only for a branch whose newest word is a merge" { const prs = [_]PullRequest{ .{ .number = 398, .branch = "feature/shipped", .state = .merged, .draft = false }, .{ .number = 400, .branch = "feature/abandoned", .state = .closed, .draft = false }, - // Merged once, then someone pushed to the branch and opened a new PR. The - // old merge says nothing about work that is in flight again. .{ .number = 401, .branch = "feature/again", .state = .merged, .draft = false }, .{ .number = 402, .branch = "feature/again", .state = .open, .draft = false }, }; @@ -259,7 +197,6 @@ test "mergedFor answers only for a branch whose newest word is a merge" { try std.testing.expectEqual(@as(?u32, 398), mergedFor(&prs, "feature/shipped")); try std.testing.expectEqual(@as(?u32, null), mergedFor(&prs, "feature/abandoned")); try std.testing.expectEqual(@as(?u32, null), mergedFor(&prs, "feature/again")); - // No pull request at all is not a merge either. try std.testing.expectEqual(@as(?u32, null), mergedFor(&prs, "feature/unknown")); } @@ -276,7 +213,6 @@ test "describe labels a draft distinctly from an open PR" { try std.testing.expectEqualStrings("#412 open", open.describe(arena)); try std.testing.expectEqualStrings("#413 draft", draft.describe(arena)); - // gh keeps isDraft set on a PR that was merged out of draft; state wins. try std.testing.expectEqualStrings("#398 merged", merged.describe(arena)); } @@ -284,6 +220,5 @@ test "parseState maps gh's uppercase names" { try std.testing.expectEqual(State.merged, parseState("MERGED")); try std.testing.expectEqual(State.closed, parseState("CLOSED")); try std.testing.expectEqual(State.open, parseState("OPEN")); - // Anything unrecognised reads as open — the state that shows the most detail. try std.testing.expectEqual(State.open, parseState("SOMETHING_NEW")); } diff --git a/src/keychain.zig b/src/keychain.zig index dad42dc..76c28f5 100644 --- a/src/keychain.zig +++ b/src/keychain.zig @@ -1,17 +1,5 @@ -//! macOS Keychain access through the modern SecItem* API. -//! -//! This replaces `@napi-rs/keyring` in the TypeScript version. The npm package -//! also covers Linux (libsecret) and Windows (Credential Manager); this is -//! macOS-only, which is the whole cross-platform cost of the port. - const std = @import("std"); -// Deliberately narrow includes. The `CoreFoundation/CoreFoundation.h` and -// `Security/Security.h` umbrella headers both fail translate-c on the -// macOS 26.5 SDK: the former drags in mach headers whose bitfield structs -// become opaque and then trip their own `_Static_assert`s, the latter drags in -// `xpc.h`, which puts nullability attributes on the non-pointer `uuid_t`. -// These five headers cover every symbol used below. const c = @cImport({ @cInclude("CoreFoundation/CFBase.h"); @cInclude("CoreFoundation/CFString.h"); @@ -22,9 +10,7 @@ const c = @cImport({ }); pub const Error = error{ - /// SecItem* returned an OSStatus we do not translate individually. KeychainFailed, - /// CoreFoundation refused to allocate an object. CoreFoundationFailed, OutOfMemory, }; @@ -35,8 +21,6 @@ const err_sec_duplicate_item: c.OSStatus = -25299; const err_sec_auth_failed: c.OSStatus = -25293; const err_sec_user_canceled: c.OSStatus = -128; -/// Last raw OSStatus, so callers can report something actionable when a -/// keychain call fails for a reason we do not model. pub var last_status: c.OSStatus = err_sec_success; fn cfString(s: []const u8) Error!c.CFStringRef { @@ -58,8 +42,6 @@ fn opaquePtr(ref: anytype) ?*const anyopaque { return @ptrCast(ref); } -/// Builds `{ class: generic password, service, account }` — the identity of a -/// single keychain item. Caller releases. fn identityQuery(service: c.CFStringRef, account: c.CFStringRef) Error!c.CFMutableDictionaryRef { const dict = c.CFDictionaryCreateMutable( null, @@ -73,8 +55,6 @@ fn identityQuery(service: c.CFStringRef, account: c.CFStringRef) Error!c.CFMutab return dict; } -/// Returns the stored secret, or null when the item does not exist. -/// Caller owns the returned memory. pub fn get(gpa: std.mem.Allocator, service: []const u8, account: []const u8) Error!?[]u8 { const service_ref = try cfString(service); defer c.CFRelease(opaquePtr(service_ref)); @@ -104,7 +84,6 @@ pub fn get(gpa: std.mem.Allocator, service: []const u8, account: []const u8) Err return out; } -/// Creates the item, or overwrites it when it already exists. pub fn set(service: []const u8, account: []const u8, secret: []const u8) Error!void { const service_ref = try cfString(service); defer c.CFRelease(opaquePtr(service_ref)); @@ -116,8 +95,6 @@ pub fn set(service: []const u8, account: []const u8, secret: []const u8) Error!v const query = try identityQuery(service_ref, account_ref); defer c.CFRelease(opaquePtr(query)); - // Update first: SecItemAdd on an existing item fails with errSecDuplicateItem - // rather than replacing it. const attrs = c.CFDictionaryCreateMutable( null, 0, @@ -132,7 +109,6 @@ pub fn set(service: []const u8, account: []const u8, secret: []const u8) Error!v if (update_status == err_sec_success) return; if (update_status != err_sec_item_not_found) return Error.KeychainFailed; - // Item does not exist yet — add it. const add_query = try identityQuery(service_ref, account_ref); defer c.CFRelease(opaquePtr(add_query)); c.CFDictionarySetValue(add_query, opaquePtr(c.kSecValueData), opaquePtr(secret_ref)); @@ -142,7 +118,6 @@ pub fn set(service: []const u8, account: []const u8, secret: []const u8) Error!v if (add_status != err_sec_success) return Error.KeychainFailed; } -/// Removes the item. A missing item is not an error. pub fn delete(service: []const u8, account: []const u8) Error!void { const service_ref = try cfString(service); defer c.CFRelease(opaquePtr(service_ref)); @@ -158,7 +133,6 @@ pub fn delete(service: []const u8, account: []const u8) Error!void { return Error.KeychainFailed; } -/// Human-readable form of `last_status`, for error messages. pub fn describeStatus(status: c.OSStatus) []const u8 { return switch (status) { err_sec_success => "success", diff --git a/src/linear.zig b/src/linear.zig index 82a38dc..3f7edf2 100644 --- a/src/linear.zig +++ b/src/linear.zig @@ -1,6 +1,3 @@ -//! GraphQL against api.linear.app over std.http.Client, plus the state -//! filtering and ordering that `issues.ts` did on top of the SDK. - const std = @import("std"); const Io = std.Io; const fold = @import("fold.zig"); @@ -76,8 +73,6 @@ pub const Issue = struct { state_type: []const u8, priority: i64, url: []const u8, - /// ISO 8601 as Linear returns it. Compared lexicographically, which is - /// exact for a fixed-format UTC timestamp and avoids parsing dates. updated_at: []const u8, assignee_name: ?[]const u8, team_key: ?[]const u8, @@ -88,22 +83,9 @@ pub const Project = struct { name: []const u8, }; -/// Everything a detail read asks for, for the one issue a caller named. -/// -/// A type of its own rather than more fields on `Issue`: `active_issues_query` and -/// `issue_statuses_query` do not select a project, labels or a description, so a -/// null on `Issue` would mean "not asked for" in one place and "there is none" in -/// another — one field carrying two answers. pub const Detail = struct { issue: Issue, - /// The current state's own id, so a caller that goes on to write already knows - /// whether the write would be a no-op. Empty when the response carried no - /// state at all, which no id can equal — so a comparison against it decides to - /// write rather than to skip. state_id: []const u8, - /// The UUID every mutation that names a team needs. Linear refuses the human - /// key `PE` there, and reading the id off the issue is what keeps the key from - /// having a path into one. team_id: ?[]const u8, project: ?Project, labels: []const []const u8, @@ -124,7 +106,7 @@ pub const Error = error{ pub var last_status: u16 = 0; pub var last_message: []const u8 = ""; -const max_pages = 10; // 250 x 10 = 2500 issues; safety cap to avoid runaway loops +const max_pages = 10; pub const FetchResult = struct { matched: []Issue, @@ -133,14 +115,10 @@ pub const FetchResult = struct { }; fn authHeader(gpa: std.mem.Allocator, token: oauth.Token) ![]u8 { - // Personal API tokens go in raw, OAuth access tokens as a bearer — the two - // modes the Linear SDK exposes as `apiKey` and `accessToken`. if (token.is_pat orelse false) return gpa.dupe(u8, token.access_token); return std.fmt.allocPrint(gpa, "Bearer {s}", .{token.access_token}); } -/// One GraphQL round trip. `body` is the already-serialised request; the raw -/// response body comes back, with `last_status`/`last_message` set either way. fn post(gpa: std.mem.Allocator, io: Io, token: oauth.Token, body: []const u8) Error![]const u8 { var client: std.http.Client = .{ .allocator = gpa, .io = io }; defer client.deinit(); @@ -172,8 +150,6 @@ fn post(gpa: std.mem.Allocator, io: Io, token: oauth.Token, body: []const u8) Er return raw; } -/// Parses a GraphQL envelope, turning both transport-level `errors` and a -/// missing `data` into `GraphQLFailed` with the message worth showing. fn unwrap(comptime T: type, gpa: std.mem.Allocator, raw: []const u8) Error!T { const Wrapper = struct { data: ?T = null, @@ -197,13 +173,6 @@ fn unwrap(comptime T: type, gpa: std.mem.Allocator, raw: []const u8) Error!T { }; } -/// One read: serialise, post, unwrap. Every caller below did those three lines by -/// hand, which is three places each to get a variable name wrong and find out from -/// a 400 against a live workspace. -/// -/// `emit_null_optional_fields` stays at its default here, unlike on a write: in a -/// *filter* a null variable means "do not filter", which is what `$after: null` on -/// the first page of `fetchAllRaw` relies on. fn query( comptime T: type, gpa: std.mem.Allocator, @@ -215,8 +184,6 @@ fn query( return unwrap(T, gpa, try post(gpa, io, token, try queryBody(gpa, text, variables))); } -/// The request bytes for a read. Split from `query` so the shape `variables` takes -/// is settled by a test rather than by a live workspace refusing it. fn queryBody(gpa: std.mem.Allocator, text: []const u8, variables: anytype) Error![]u8 { return std.json.Stringify.valueAlloc(gpa, .{ .query = text, @@ -224,10 +191,6 @@ fn queryBody(gpa: std.mem.Allocator, text: []const u8, variables: anytype) Error }, .{}) catch Error.HttpFailed; } -/// Every Linear mutation payload is `{ success, }`, and -/// every mutation names that thing differently. Aliasing the payload to `payload` -/// and the thing to `entity` in the mutation text is what lets one Zig shape read -/// all of them — the trick `github.zig` uses to alias one connection per branch. fn Mutation(comptime T: type) type { return struct { payload: struct { @@ -237,17 +200,6 @@ fn Mutation(comptime T: type) type { }; } -/// One write. Three things separate this from `query`, and each of them is a -/// silent failure the hand-rolled version would have to remember not to make: -/// -/// * `emit_null_optional_fields` is off. Linear reads `"projectId": null` in a -/// mutation input as *clear the project*, so an optional left at its default -/// erases a field nobody named. -/// * `success` is checked. A refused write arrives as `success: false` *inside* -/// `data` with no `errors[]` beside it, which `unwrap` passes straight through: -/// an unchecked mutation is a no-op that reports itself as a write. -/// * The aliases are required at compile time, so a mutation written without them -/// fails the build instead of failing to parse against a live workspace. fn mutate( comptime T: type, gpa: std.mem.Allocator, @@ -267,8 +219,6 @@ fn mutate( return readMutation(T, gpa, try post(gpa, io, token, try mutationBody(gpa, text, variables))); } -/// The request bytes for a write. Split from `mutate` so the null-dropping rule is -/// settled by a test rather than by a field being quietly erased in Linear. fn mutationBody(gpa: std.mem.Allocator, text: []const u8, variables: anytype) Error![]u8 { return std.json.Stringify.valueAlloc(gpa, .{ .query = text, @@ -276,9 +226,6 @@ fn mutationBody(gpa: std.mem.Allocator, text: []const u8, variables: anytype) Er }, .{ .emit_null_optional_fields = false }) catch Error.HttpFailed; } -/// What a write's response means, decided from the bytes alone — so the two -/// answers that carry no `errors[]` to give them away are testable without a -/// network. fn readMutation(comptime T: type, gpa: std.mem.Allocator, raw: []const u8) Error!T { const data = try unwrap(Mutation(T), gpa, raw); if (!data.payload.success) { @@ -291,11 +238,6 @@ fn readMutation(comptime T: type, gpa: std.mem.Allocator, raw: []const u8) Error }; } -/// Variables for a query that takes none. -/// -/// An empty *struct*, and deliberately not the `.{}` that reads naturally in its -/// place: `.{}` is an empty tuple, `Stringify` writes a tuple as a JSON array, and -/// Linear answers `variables in a POST body must be an object if provided`. const NoVariables = struct {}; fn fromRaw(issue: RawIssue) Issue { @@ -314,10 +256,6 @@ fn fromRaw(issue: RawIssue) Issue { }; } -/// Everything `Issue` carries, for the one issue a caller named. Filtering on the -/// number and the team key both narrows it to a unique row and lets Linear answer -/// from the team's index — the cost note on `issue_statuses_query` applies here -/// too, which is why `first` is 1 rather than a round number. const issue_query = \\query LccIssue($number: Float!, $team: String!) { \\ issues( @@ -342,17 +280,12 @@ const issue_query = const OneIssueData = struct { issues: struct { nodes: []RawIssue } }; -/// The issue `PE-256` names. Neither the assignee nor the `activeStates` filter -/// applies: naming an issue outright is a more specific answer than either, so a -/// backlog issue or one assigned to somebody else still resolves. Null when the -/// team has no such number. pub fn fetchIssue( gpa: std.mem.Allocator, io: Io, token: oauth.Token, ref: Ref, ) Error!?Issue { - // Linear stores team keys uppercase; `pe-256` on a command line is the same issue. const team = try std.ascii.allocUpperString(gpa, ref.team); const data = try query(OneIssueData, gpa, io, token, issue_query, .{ @@ -363,11 +296,6 @@ pub fn fetchIssue( return fromRaw(data.issues.nodes[0]); } -/// Everything one issue carries, for a caller that named it. Same filter as -/// `issue_query`, and for the same reason — number plus team key is unique and lets -/// Linear answer from the team's own index — with the longer field list a detail -/// view reads. `$labels` is a variable rather than a round number because the cost -/// note on `issue_statuses_query` applies to sub-connections too. const issue_detail_query = \\query LccIssueDetail($number: Float!, $team: String!, $labels: Int!) { \\ issues( @@ -393,16 +321,10 @@ const issue_detail_query = \\} ; -/// The taxonomy this is read for is three label groups plus an optional `area/*`. -/// Twenty is headroom over that, not a guess at a page size. const max_labels = 20; const DetailState = struct { id: []const u8, name: []const u8, type: []const u8 }; const DetailTeam = struct { id: []const u8, key: []const u8 }; -/// A grouped label answers `name` with its leaf alone — `bug`, not `type/bug` — -/// and names its group through `parent`. Both halves are needed: the group is what -/// tells `type/bug` from an `area/bug`, and a caller dispatching on `type/` has -/// nothing to match without it. const RawLabel = struct { name: []const u8, parent: ?Named = null, @@ -410,9 +332,6 @@ const RawLabel = struct { const LabelConnection = struct { nodes: []RawLabel }; -/// The detail query's own wire shape. Separate from `RawIssue` because the two -/// select different fields, and a shared type would have to make every one of them -/// optional to say so. const RawDetail = struct { id: []const u8, identifier: []const u8, @@ -431,22 +350,17 @@ const RawDetail = struct { const DetailData = struct { issues: struct { nodes: []RawDetail } }; -/// A label the way it is written down and talked about — `type/bug` — rather than -/// the way Linear's API splits it. An ungrouped label is its own whole name. fn labelName(gpa: std.mem.Allocator, label: RawLabel) ![]const u8 { const parent = label.parent orelse return label.name; return std.fmt.allocPrint(gpa, "{s}/{s}", .{ parent.name, label.name }); } -/// The issue `PE-256` names, with the project, labels and description `fetchIssue` -/// does not pay for. Null when the team has no such number. pub fn fetchIssueDetail( gpa: std.mem.Allocator, io: Io, token: oauth.Token, ref: Ref, ) Error!?Detail { - // Linear stores team keys uppercase; `pe-256` on a command line is the same issue. const team = try std.ascii.allocUpperString(gpa, ref.team); const data = try query(DetailData, gpa, io, token, issue_detail_query, .{ @@ -487,11 +401,6 @@ pub fn fetchIssueDetail( }; } -/// The input is a GraphQL object literal naming exactly the fields being written, -/// with a variable for each value — not a serialised struct. A struct with a -/// second, optional field would put `"…": null` on the wire, and Linear reads that -/// as *clear it*. There is no field here that can carry a null, so there is -/// nothing to remember. const add_comment_mutation = \\mutation LccAddComment($issue: String!, $body: String!) { \\ payload: commentCreate(input: { issueId: $issue, body: $body }) { @@ -513,11 +422,6 @@ const RawComment = struct { createdAt: []const u8, }; -/// A comment on an issue. -/// -/// Not idempotent, and cannot be: calling it twice posts two comments. lcc has no -/// way to tell a retry from a second thought, and deduping on the body would -/// silently swallow a deliberate repeat. pub fn addComment( gpa: std.mem.Allocator, io: Io, @@ -535,18 +439,10 @@ pub fn addComment( pub const WorkflowState = struct { id: []const u8, name: []const u8, - /// Linear's `statusType`: `backlog`, `unstarted`, `started`, `completed`, - /// `canceled`. Reported, and used to *narrow* a match — never to make one. - /// See `resolveState`. type: []const u8, - /// Where the state sits on the board, so a list of them reads in the order a - /// human sees rather than the order the API happened to answer in. position: f64, }; -/// The issue and every state its team offers, in one request. The two are -/// otherwise two round trips — the id to write to, then the ids that may be -/// written — and there is no moment between them where the answer changes. const issue_state_context_query = \\query LccIssueStateContext($number: Float!, $team: String!, $states: Int!) { \\ issues( @@ -571,10 +467,6 @@ const issue_state_context_query = \\} ; -/// A team's workflow is five to eight states in practice and unusable past a -/// couple of dozen. Fifty is the ceiling that keeps a runaway workspace from -/// pricing this request like a full scan — and `hasNextPage` is what turns passing -/// it into something reported rather than silently answered as "no such state". const max_team_states = 50; const StateConnection = struct { @@ -605,31 +497,19 @@ pub const StateContext = struct { current: WorkflowState, team_key: []const u8, team_id: []const u8, - /// In board order, so a message listing them reads the way the board does. states: []const WorkflowState, - /// The team has more states than `max_team_states`. "No such state" is then a - /// claim this cannot honestly make. truncated: bool, }; -/// Linear hands state names back with trailing whitespace often enough that -/// `fromRaw` has trimmed them since the first version. Doing it once here means -/// every name that leaves this module is already the one a human typed. fn trimNames(states: []WorkflowState) void { for (states) |*state| state.name = std.mem.trim(u8, state.name, " \t"); } -/// Where a status type sits on a board, left to right. Linear's own column order, -/// and the missing half of sorting: `position` is assigned *within* a type, so -/// ordering on it alone interleaves the groups — a real PE board came back as -/// `… In Progress, Done, Canceled, Duplicate, In Review, In Build, In Production`. fn typeRank(state_type: []const u8) u8 { const order = [_][]const u8{ "backlog", "unstarted", "started", "completed", "canceled" }; for (order, 0..) |candidate, i| { if (fold.eql(candidate, state_type)) return @intCast(i); } - // An unknown type sorts last rather than first: whatever it is, it is not one - // of the five the workflow is built from. return order.len; } @@ -679,21 +559,10 @@ pub fn fetchStateContext( pub const StateMatch = union(enum) { found: WorkflowState, - /// No state on this team carries that name. The caller already holds the full - /// list, so it can say what does exist rather than only what does not. unknown, - /// Two states share the name — Linear permits it across groups. Naming one is - /// then not an instruction, and guessing is how work lands in the wrong group. ambiguous: []const WorkflowState, }; -/// The state `wanted` names, matched on the name alone. -/// -/// Nothing here searches on `type`, and that is the design rather than an -/// omission: matching on the type is what let `Canceled` resolve to `Duplicate`, -/// because a status type has as many holders as the board has columns in that -/// group. `want_type` filters an already-matching set and does nothing else — it -/// may narrow a name that matched, and it can never find one that did not. pub fn resolveState( gpa: std.mem.Allocator, states: []const WorkflowState, @@ -739,12 +608,6 @@ const RawUpdatedIssue = struct { state: ?WorkflowState = null, }; -/// Move an issue to a state, by the state's own id. -/// -/// The mutation re-selects the state out of its own response rather than echoing -/// what was asked for, so the report says what Linear ended up with. That is the -/// "after a save, always check the status you got back" rule, enforced instead of -/// written down. pub fn setIssueState( gpa: std.mem.Allocator, io: Io, @@ -761,18 +624,6 @@ pub fn setIssueState( return .{ .id = raw.id, .identifier = raw.identifier, .state = state }; } -/// The team's release projects, split into the ones still open and the ones -/// already shipped. -/// -/// Reached through the **root** `projects` connection with an `accessibleTeams` -/// filter, deliberately not through `teams(filter:).projects`: that traversal came -/// back empty on a workspace whose issues are demonstrably in projects, while the -/// same projects list fine here and report `teams: [PE]` themselves. -/// -/// Both halves are narrowed before they are capped, which is what makes a small -/// `first` defensible at all under the cost note above: `startsWith: "v"` and the -/// status filter mean the rows that *could* come back are release projects, not -/// the team's whole board. const release_projects_query = \\query LccReleaseProjects($team: String!, $open: Int!, $done: Int!) { \\ open: projects( @@ -794,35 +645,20 @@ const release_projects_query = \\} ; -/// The open half has to be able to hold *every* unshipped release project, or -/// "the lowest open version" is not a provable answer — Linear cannot order a -/// project connection by name, so the window is the whole population or nothing. -/// Twenty unshipped releases at once is a board problem the resolver must not -/// paper over, and `hasNextPage` is what turns hitting this into something said -/// out loud rather than answered wrongly. const max_open_projects = 20; -/// The shipped half is read for the *highest* version alone, and only as one of -/// three baselines the next-minor rule takes the maximum of — the other two being -/// git tags and live release branches, both exact. Ten survives releases completed -/// out of order. const max_done_projects = 10; pub const ReleaseProject = struct { id: []const u8, name: []const u8, - /// `backlog`, `planned`, `started`, `paused`, `completed`, `canceled` — the - /// status *type*, which is stable, rather than what the column is called. status_type: []const u8, - /// What the column is called on this board, for a human line. status_name: []const u8, }; pub const ReleaseProjects = struct { - /// Everything neither completed nor cancelled, whatever its version. open: []const ReleaseProject, completed: []const ReleaseProject, - /// The open window was full, so the lowest open version is not provable. truncated: bool, }; @@ -893,7 +729,6 @@ const RawProjectAssignment = struct { project: ?Project = null, }; -/// Put an issue in a project, by the project's own id. pub fn setIssueProject( gpa: std.mem.Allocator, io: Io, @@ -908,15 +743,6 @@ pub fn setIssueProject( return raw.project orelse Error.GraphQLFailed; } -/// `teamIds` is where the UUID goes, and there is exactly one source for it: the -/// team read off the issue being assigned. No function here takes a team *key*, so -/// there is no path by which `PE` reaches an argument that wants a UUID — the -/// mistake is unreachable rather than documented. -/// -/// The icon and the project lead are deliberately not set. `ProjectCreateInput` -/// does accept both, but on a mutation that only fires when a project is missing -/// they buy a wider request in exchange for decoration; the Linear UI is where a -/// release board gets dressed. const create_project_mutation = \\mutation LccCreateProject($name: String!, $team: String!, $description: String!) { \\ payload: projectCreate(input: { @@ -972,8 +798,6 @@ const SortContext = struct { return 99; } - /// Preserve the order from `activeStates` (so Todo before In Progress, etc.), - /// then most recently updated first within each state. fn lessThan(self: SortContext, a: Issue, b: Issue) bool { const ai = self.stateRank(a.state_name); const bi = self.stateRank(b.state_name); @@ -1018,7 +842,6 @@ pub fn fetchActiveIssues( const issues = try matched.toOwnedSlice(gpa); std.mem.sort(Issue, issues, SortContext{ .order = active_states }, SortContext.lessThan); - // Most-skipped state first, matching the TypeScript breakdown line. const counts = try gpa.alloc(StateCount, skipped.count()); for (skipped.keys(), skipped.values(), 0..) |name, count, i| { counts[i] = .{ .name = name, .count = count }; @@ -1037,20 +860,11 @@ pub const Me = struct { email: []const u8, }; -/// `client.viewer` — used by `lcc auth` to confirm who the token belongs to. pub fn viewer(gpa: std.mem.Allocator, io: Io, token: oauth.Token) Error!Me { const data = try query(struct { viewer: Me }, gpa, io, token, "query { viewer { name email } }", NoVariables{}); return data.viewer; } -/// Both halves of the filter matter for speed as well as correctness: numbers are -/// only unique within a team, and filtering on the team key too lets Linear answer -/// from the team's own index instead of every issue the token can see. -/// -/// `first` is a variable, and the field list is as short as the caller needs, because -/// Linear prices a request on how much it *could* return: asking for a fixed 100 rows -/// of every field made a five-branch lookup swing between a third of a second and a -/// minute. const issue_statuses_query = \\query LccIssueStatuses($numbers: [Float!], $teams: [String!], $limit: Int) { \\ issues( @@ -1079,23 +893,16 @@ pub const IssueStatus = struct { state_type: []const u8, }; -/// `PE-224` as it appears inside a branch name. pub const Ref = struct { team: []const u8, number: u32, }; -/// Longest team key lcc will believe. Linear's own keys are a handful of letters; -/// the cap is what keeps `checkout-3` from being read as issue 3 of team CHECKOUT. const max_team_key = 8; -/// The issue a branch belongs to, read out of names like `feature/pe-224-do-thing`. -/// Only the shape is checked here — whether `PE-224` is a real issue is settled by -/// matching the identifiers Linear returns, so a false positive costs nothing. pub fn refFromBranch(branch: []const u8) ?Ref { var i: usize = 0; while (i < branch.len) { - // The key has to start a word, so `feature/pe-224` matches and `2pe-3` does not. if (i > 0 and std.ascii.isAlphanumeric(branch[i - 1])) { i += 1; continue; @@ -1118,9 +925,6 @@ pub fn refFromBranch(branch: []const u8) ?Ref { return null; } -/// State and title for the issues a set of branches names, in one request. -/// Team keys and numbers are sent as independent sets, so the response can hold -/// issues no branch asked about — callers settle it with `statusForBranch`. pub fn fetchIssueStatuses( gpa: std.mem.Allocator, io: Io, @@ -1136,15 +940,12 @@ pub fn fetchIssueStatuses( if (seen == ref.number) break; } else try numbers.append(gpa, ref.number); - // Linear stores keys uppercase; branch names carry them lowercased. const key = try std.ascii.allocUpperString(gpa, ref.team); for (teams.items) |seen| { if (std.mem.eql(u8, seen, key)) break; } else try teams.append(gpa, key); } - // A number can exist in every team asked about, so that product is the ceiling - // on rows; anything more would be paying for rows that cannot come back. const limit = numbers.items.len * teams.items.len; const data = try query(StatusData, gpa, io, token, issue_statuses_query, .{ @@ -1164,20 +965,12 @@ pub fn fetchIssueStatuses( return out; } -/// Whether two branch names belong to the same issue. Linear derives `branchName` -/// from the title, so renaming an issue renames the branch it suggests — but the -/// work stays on the branch that was cut from the old name. The `PE-224` part is -/// the half that cannot drift. -/// -/// A name with no issue ref in it never matches, not even another one like it: -/// `master` and `release/2.4.1` are not the same issue, they are no issue at all. pub fn sameIssue(a: []const u8, b: []const u8) bool { const left = refFromBranch(a) orelse return false; const right = refFromBranch(b) orelse return false; return left.number == right.number and fold.eql(left.team, right.team); } -/// The issue whose identifier matches `PE-224` for this branch, case-insensitively. pub fn statusForBranch(statuses: []const IssueStatus, branch: []const u8) ?IssueStatus { const ref = refFromBranch(branch) orelse return null; for (statuses) |status| { @@ -1199,16 +992,9 @@ test "a read with no variables sends an object, because an array is refused" { const body = try queryBody(arena, text, NoVariables{}); try std.testing.expect(std.mem.indexOf(u8, body, "\"variables\":{}") != null); - // The literal that reads naturally in `NoVariables{}`'s place, and is wrong: - // `.{}` is an empty tuple, which `Stringify` writes as `[]`. Linear answers - // that with `variables in a POST body must be an object if provided`, so the - // difference is a 400 rather than something a type checker would catch. const tuple = try queryBody(arena, text, .{}); try std.testing.expect(std.mem.indexOf(u8, tuple, "\"variables\":[]") != null); - // A null variable still travels on a read, and has to: `$after: null` is how - // `fetchAllRaw` asks for the first page. This is the half of the - // `emit_null_optional_fields` question that a write answers the other way. const paged = try queryBody(arena, text, .{ .after = @as(?[]const u8, null) }); try std.testing.expect(std.mem.indexOf(u8, paged, "\"after\":null") != null); } @@ -1220,22 +1006,16 @@ test "a write Linear declined is a failure, not a silent no-op" { const Entity = struct { id: []const u8 }; - // No `errors[]` anywhere. `unwrap` alone hands this back as a success, and the - // caller reports a change that never happened. const declined = "{\"data\":{\"payload\":{\"success\":false,\"entity\":null}}}"; try std.testing.expectError(Error.GraphQLFailed, readMutation(Entity, arena, declined)); try std.testing.expect(std.mem.indexOf(u8, last_message, "success: false") != null); - // Success with nothing in it: the write may well have landed, but nothing came - // back to report, and echoing the request as if it were the response is how a - // report stops being evidence. const hollow = "{\"data\":{\"payload\":{\"success\":true,\"entity\":null}}}"; try std.testing.expectError(Error.GraphQLFailed, readMutation(Entity, arena, hollow)); const good = "{\"data\":{\"payload\":{\"success\":true,\"entity\":{\"id\":\"uuid-1\"}}}}"; try std.testing.expectEqualStrings("uuid-1", (try readMutation(Entity, arena, good)).id); - // Errors still win over `success`, and still carry their own message. const errored = \\{"data":null,"errors":[{"message":"Entity not found: Issue"}]} ; @@ -1250,9 +1030,6 @@ test "a write drops the fields it was not asked to change, and escapes the ones const text = "mutation { payload: x { success entity: y { id } } }"; - // The trap: at `Stringify`'s default, `.project = null` reaches Linear as - // `"project": null`, which it reads as *clear the project* — an erasure nobody - // asked for, from a request that only meant to set a state. const body = try mutationBody(arena, text, .{ .id = "uuid-1", .state = "state-1", @@ -1261,9 +1038,6 @@ test "a write drops the fields it was not asked to change, and escapes the ones try std.testing.expect(std.mem.indexOf(u8, body, "\"state\":\"state-1\"") != null); try std.testing.expect(std.mem.indexOf(u8, body, "project") == null); - // A plan comment is the most escape-hostile thing lcc will ever send: quotes, a - // backslash and the newlines of a markdown document, all inside one GraphQL - // string variable. const comment = try mutationBody(arena, text, .{ .body = "He said \"go\"\n- a\\b" }); try std.testing.expect(std.mem.indexOf(u8, comment, "\\\"go\\\"") != null); try std.testing.expect(std.mem.indexOf(u8, comment, "\\n") != null); @@ -1279,41 +1053,25 @@ test "every mutation carries the aliases its reader needs and names its input fi }) |text| { try std.testing.expect(std.mem.indexOf(u8, text, "payload:") != null); try std.testing.expect(std.mem.indexOf(u8, text, "entity:") != null); - // Inputs are object literals with one variable per field, so no optional - // ever reaches the wire and the `null means clear it` trap has no carrier. try std.testing.expect(std.mem.indexOf(u8, text, "input: {") != null); } - // Every `first` on a read is a variable or a literal 1 — never a round number, - // because Linear prices a request on what it could return. try std.testing.expect(std.mem.indexOf(u8, issue_detail_query, "first: $labels") != null); try std.testing.expect(std.mem.indexOf(u8, issue_statuses_query, "first: $limit") != null); try std.testing.expect(std.mem.indexOf(u8, issue_state_context_query, "first: $states") != null); try std.testing.expect(std.mem.indexOf(u8, release_projects_query, "first: $open") != null); try std.testing.expect(std.mem.indexOf(u8, release_projects_query, "first: $done") != null); - // The state write re-reads the state out of its own response, so the report is - // evidence rather than an echo of what was asked for. try std.testing.expect(std.mem.indexOf(u8, set_state_mutation, "state { id name type") != null); - // Both halves of the project board are narrowed *before* they are capped — - // without these two filters a small `first` would be a guess rather than a - // ceiling, which is the cost mistake this whole convention exists to avoid. try std.testing.expect(std.mem.indexOf(u8, release_projects_query, "startsWith: \"v\"") != null); try std.testing.expect(std.mem.indexOf(u8, release_projects_query, "accessibleTeams") != null); - // `Project.state` does not exist — it is `status { name type }`. Asking for the - // field the prose assumed would 400 the whole query. try std.testing.expect(std.mem.indexOf(u8, release_projects_query, "status { name type }") != null); - // Creating a project takes the team's UUID and nothing else could reach it: - // no function here accepts a team key, so `PE` has no path into `teamIds`. try std.testing.expect(std.mem.indexOf(u8, create_project_mutation, "teamIds: [$team]") != null); } test "states list in board order, which position alone does not give" { - // Positions as a real PE board returned them: numbered within each status - // type, so `In Review` at 0 sorts ahead of `In Progress` at 2 on position - // alone, and `Done` at 0 lands in the middle of the started column. var board = [_]WorkflowState{ .{ .id = "s-done", .name = "Done", .type = "completed", .position = 0 }, .{ .id = "s-review", .name = "In Review", .type = "started", .position = 1 }, @@ -1330,8 +1088,6 @@ test "states list in board order, which position alone does not give" { }; for (expected, board) |want, got| try std.testing.expectEqualStrings(want, got.name); - // A type nobody recognises sorts last rather than first — whatever it is, it - // is not one of the five a workflow is built from. try std.testing.expect(typeRank("something-new") > typeRank("canceled")); } @@ -1340,8 +1096,6 @@ test "a state resolves on its name, so a shared status type cannot answer for it defer arena_state.deinit(); const arena = arena_state.allocator(); - // A real PE board: two states share the `canceled` type, which is the whole - // collision. Asking for `Canceled` must not be answerable by `Duplicate`. const board = [_]WorkflowState{ .{ .id = "s-backlog", .name = "Backlog", .type = "backlog", .position = 0 }, .{ .id = "s-progress", .name = "In Progress", .type = "started", .position = 1 }, @@ -1353,30 +1107,20 @@ test "a state resolves on its name, so a shared status type cannot answer for it "s-canceled", (try resolveState(arena, &board, "Canceled", null)).found.id, ); - // Case and stray whitespace are how a name arrives from a command line. try std.testing.expectEqualStrings( "s-progress", (try resolveState(arena, &board, " in progress ", null)).found.id, ); - // A type that matches nothing narrows the match to nothing. It may only - // narrow: it can never reach a state the name did not already find. try std.testing.expectEqual(StateMatch.unknown, try resolveState(arena, &board, "Canceled", "completed")); try std.testing.expectEqual(StateMatch.unknown, try resolveState(arena, &board, "In Reviewing", null)); - // A status type is not a name. `started` is the type of `In Progress`, and - // naming it reaches nothing — which is the half that stopped `Canceled` from - // being answerable by `Duplicate`. try std.testing.expectEqual(StateMatch.unknown, try resolveState(arena, &board, "started", null)); - // A board that really does carry the same name twice: refuse, and hand back - // both, because guessing is how work lands in the wrong group. const collided = [_]WorkflowState{ .{ .id = "s-done-a", .name = "Done", .type = "completed", .position = 4 }, .{ .id = "s-done-b", .name = "Done", .type = "canceled", .position = 5 }, }; const ambiguous = try resolveState(arena, &collided, "Done", null); try std.testing.expectEqual(@as(usize, 2), ambiguous.ambiguous.len); - // …and `--type` is the escape that is deterministic, because `(name, type)` is - // the pair that is unique. A picker would not be: it needs a human. try std.testing.expectEqualStrings( "s-done-a", (try resolveState(arena, &collided, "Done", "completed")).found.id, @@ -1388,13 +1132,10 @@ test "a grouped label is named with its group, because that is what callers matc defer arena_state.deinit(); const arena = arena_state.allocator(); - // Linear answers `name` with the leaf alone, so a caller dispatching a pipeline - // on `type/` sees `bug` and matches nothing. The group is not decoration. try std.testing.expectEqualStrings( "type/bug", try labelName(arena, .{ .name = "bug", .parent = .{ .name = "type" } }), ); - // An ungrouped label is already whole. try std.testing.expectEqualStrings( "needs-design", try labelName(arena, .{ .name = "needs-design", .parent = null }), @@ -1408,9 +1149,6 @@ test "unwrap reads a 200 that carries errors as a failure, in Linear's own words const Viewer = struct { viewer: struct { name: []const u8 } }; - // GraphQL answers 200 and puts the refusal in the body, so the HTTP status - // check in `post` waves this through. The message is the one Linear really - // sends when a team key reaches an argument that wanted a UUID. const refused = \\{"data":null,"errors":[{"message":"Argument Validation Error - teamId must be a UUID"}]} ; @@ -1420,18 +1158,12 @@ test "unwrap reads a 200 that carries errors as a failure, in Linear's own words last_message, ); - // No data and nothing said about why. try std.testing.expectError(Error.GraphQLFailed, unwrap(Viewer, arena, "{\"data\":null}")); try std.testing.expectEqualStrings("Linear API returned no data", last_message); - // An empty `errors` array is not an error. The `errs.len > 0` guard is what - // says so, and this is what keeps it from being dropped as redundant. const empty_errors = "{\"data\":{\"viewer\":{\"name\":\"x\"}},\"errors\":[]}"; try std.testing.expectEqualStrings("x", (try unwrap(Viewer, arena, empty_errors)).viewer.name); - // A body that is not JSON at all — a proxy's error page rather than an API - // response — comes back with its first bytes quoted, so the failure names - // what actually arrived instead of only that parsing did not work. try std.testing.expectError(Error.GraphQLFailed, unwrap(Viewer, arena, "502 Bad Gateway")); try std.testing.expect(std.mem.indexOf(u8, last_message, "502") != null); } @@ -1449,34 +1181,25 @@ test "refFromBranch finds the issue key wherever it sits" { try std.testing.expectEqualStrings("eng", nested.team); try std.testing.expectEqual(@as(u32, 7), nested.number); - // The key can sit after another dashed segment. const later = refFromBranch("feature/abc-pe-224-thing").?; try std.testing.expectEqualStrings("pe", later.team); try std.testing.expectEqual(@as(u32, 224), later.number); try std.testing.expect(refFromBranch("master") == null); try std.testing.expect(refFromBranch("release/2.4.1") == null); - // The key has to start a word. try std.testing.expect(refFromBranch("2pe-3") == null); - // And be short enough to be a team key, so ordinary words are not mistaken - // for one: `typewriter-2` is a branch name, not issue 2 of team TYPEWRITER. try std.testing.expect(refFromBranch("typewriter-2") == null); } test "sameIssue survives a renamed issue and refuses branches with no issue" { - // The case from a real repo: PE-250's title changed, so Linear suggests a new - // branch name while the work sits on the one cut from the old title. try std.testing.expect(sameIssue( "feature/pe-250-fix-clvisit-handling-dedupe-arrivaldeparture-double-writes", "feature/pe-250-fix-clvisit-capture-dropped-visits-lost-headless-writes-no", )); - // Case differs between Linear's key and the branch name. try std.testing.expect(sameIssue("feature/PE-250-a", "feature/pe-250-b")); try std.testing.expect(!sameIssue("feature/pe-250-a", "feature/pe-251-a")); - // Same number, different team. try std.testing.expect(!sameIssue("feature/pe-250-a", "feature/eng-250-a")); - // No ref at all is not an issue, so it matches nothing — including itself. try std.testing.expect(!sameIssue("master", "master")); try std.testing.expect(!sameIssue("release/2.4.1", "feature/pe-250-a")); } diff --git a/src/link.zig b/src/link.zig index eeb1afd..d810fab 100644 --- a/src/link.zig +++ b/src/link.zig @@ -1,15 +1,6 @@ -//! Finding gitignored files in the repo and symlinking them into a worktree. -//! -//! A pattern is a relative path whose every segment may glob: `.env`, `.env.*`, -//! `.claude/settings.local.json`, `*/credentials.plist`. `*` and `?` never cross -//! a separator, so the walk only ever visits the directories a pattern names — -//! it never descends the whole repository. - const std = @import("std"); const Io = std.Io; -/// Single path segment: `*` matches any run of characters, `?` exactly one. -/// Segments never contain a separator, so no path semantics are needed here. pub fn globMatch(pattern: []const u8, name: []const u8) bool { var p: usize = 0; var n: usize = 0; @@ -36,8 +27,6 @@ pub fn globMatch(pattern: []const u8, name: []const u8) bool { return p == pattern.len; } -/// Whole-path match: segment counts must agree and every segment must glob-match. -/// `.env` therefore matches only a root-level `.env`, never `config/.env`. pub fn matchPath(pattern: []const u8, rel_path: []const u8) bool { var pattern_segments = std.mem.splitScalar(u8, pattern, '/'); var path_segments = std.mem.splitScalar(u8, rel_path, '/'); @@ -51,15 +40,10 @@ pub fn matchPath(pattern: []const u8, rel_path: []const u8) bool { } pub const Found = struct { - /// Path relative to the repo root — the same path the symlink gets in the - /// worktree, so `.claude/settings.local.json` lands in `.claude/`. rel: []const u8, - /// Absolute path of the source file. abs: []const u8, }; -/// Every file in `repo_root` matched by `patterns` and not by `exclude`, sorted -/// by relative path and deduplicated (two patterns may name the same file). pub fn findFiles( gpa: std.mem.Allocator, io: Io, @@ -94,9 +78,6 @@ fn isExcluded(rel: []const u8, exclude: []const []const u8) bool { return false; } -/// Splits a pattern into segments, or null when it is not a safe relative path. -/// An absolute pattern, or one with a `.`/`..`/empty segment, is skipped rather -/// than clamped: silently linking the wrong file is worse than linking nothing. fn splitPattern(gpa: std.mem.Allocator, pattern: []const u8) !?[]const []const u8 { if (pattern.len == 0 or std.fs.path.isAbsolute(pattern)) return null; @@ -110,8 +91,6 @@ fn splitPattern(gpa: std.mem.Allocator, pattern: []const u8) !?[]const []const u return try segments.toOwnedSlice(gpa); } -/// Walks one pattern segment by segment, collecting the relative paths of the -/// files the last segment matches. fn resolve( gpa: std.mem.Allocator, io: Io, @@ -119,7 +98,6 @@ fn resolve( segments: []const []const u8, out: *std.StringArrayHashMapUnmanaged(void), ) !void { - // Directories reached so far, relative to the repo root; "" is the root. var current: std.ArrayList([]const u8) = .empty; try current.append(gpa, ""); @@ -167,21 +145,16 @@ fn expandGlob( if (dirent.kind != .file and dirent.kind != .sym_link) continue; } else { if (dirent.kind != .directory and dirent.kind != .sym_link) continue; - // A glob must never wander into the object database. if (std.mem.eql(u8, dirent.name, ".git")) continue; } try out.append(gpa, try joinRel(gpa, base, dirent.name)); } } -/// Whether `rel` exists and is the kind this position in the pattern needs: -/// a file (or a symlink to one) at the end, a directory anywhere before it. fn accepts(gpa: std.mem.Allocator, io: Io, repo_root: []const u8, rel: []const u8, last: bool) bool { const abs = std.fs.path.join(gpa, &.{ repo_root, rel }) catch return false; if (last) { - // Do not follow: a symlink to a file is itself worth linking, and the - // symlink is what the worktree should point at. const stat = Io.Dir.cwd().statFile(io, abs, .{ .follow_symlinks = false }) catch return false; return stat.kind == .file or stat.kind == .sym_link; } @@ -206,15 +179,11 @@ pub const LinkStatus = enum { linked, skipped_exists }; pub const LinkResult = struct { source: []const u8, - /// Path relative to the worktree — what the user is told was linked. rel: []const u8, target: []const u8, status: LinkStatus, }; -/// Symlinks each file into the worktree at the same relative path, creating the -/// parent directories it needs. An existing entry of any kind is left alone — -/// the worktree's own file wins. pub fn linkFiles( gpa: std.mem.Allocator, io: Io, @@ -240,8 +209,6 @@ pub fn linkFiles( else => return err, } - // Only matters for nested patterns: `.claude/` may not exist in a fresh - // worktree, and `symLink` will not create it. if (std.fs.path.dirname(target)) |parent| { try cwd.createDirPath(io, parent); } @@ -263,7 +230,6 @@ test "glob matches env patterns" { test "matchPath is segment-wise and a glob never crosses a separator" { try std.testing.expect(matchPath(".env", ".env")); - // A bare name is root-level only. try std.testing.expect(!matchPath(".env", "config/.env")); try std.testing.expect(!matchPath("*", ".claude/settings.local.json")); @@ -331,7 +297,6 @@ test "findFiles resolves nested patterns and honours exclusions" { ".env.*", ".claude/settings.local.json", "*/credentials.plist", - // Must not reach into .git even though the glob would match the name. "*/config", }, &.{".env.example"}); @@ -371,7 +336,6 @@ test "linkFiles creates parent directories and never overwrites" { .sub_path = try std.fs.path.join(arena, &.{ repo, ".env" }), .data = "K=V", }); - // Already present in the worktree — must be left alone. try cwd.writeFile(io, .{ .sub_path = try std.fs.path.join(arena, &.{ worktree, ".env" }), .data = "MINE=1", @@ -386,8 +350,6 @@ test "linkFiles creates parent directories and never overwrites" { try std.testing.expectEqualStrings(".env", results[1].rel); try std.testing.expectEqual(LinkStatus.skipped_exists, results[1].status); - // The nested link resolves to the repo's file, and .env still has the - // worktree's own contents. const linked = try cwd.readFileAlloc( io, try std.fs.path.join(arena, &.{ worktree, ".claude", "settings.local.json" }), diff --git a/src/main.zig b/src/main.zig index 2f9fbff..a159586 100644 --- a/src/main.zig +++ b/src/main.zig @@ -129,8 +129,6 @@ pub fn main(init: std.process.Init) !void { } fn dispatch(app: app_mod.App, args: []const []const u8) !void { - // No arguments prints the command list. Every action is named explicitly — - // there is no default command, so `lcc` alone never touches a repository. if (args.len == 0) { app.ui.info("{s}", .{usage}); return; @@ -155,11 +153,6 @@ fn dispatch(app: app_mod.App, args: []const []const u8) !void { if (eq(first, "issue")) return issueCommand(app, args[1..]); if (eq(first, "start")) return startCommand(app, args[1..]); if (eq(first, "stats")) return statsCommand(app, args[1..]); - // Neither is in `usage`, and neither is an oversight. `watch-hook` is what a - // Claude Code hook execs, and `daemon` is what `watch_client` re-execs to - // bring the session host up — plumbing a user of lcc has no reason to type, - // and every reason not to have to reason about. Both stay dispatchable so - // that plumbing works and so there is something to run when debugging it. if (eq(first, "watch-hook")) return watchHookCommand(app, args[1..]); if (eq(first, "daemon")) return daemonCommand(app, args[1..]); if (std.mem.startsWith(u8, first, "-")) return error.UnknownOption; @@ -214,8 +207,6 @@ fn startCommand(app: app_mod.App, args: []const []const u8) !void { opts.plan_mode = orConfig(plan_mode, cfg.planMode); opts.watch = orConfig(watch, cfg.watchByDefault); - // stdout belongs to the payload in machine mode; the progress lines still go - // somewhere a human can see them. var machine = app; machine.ui.divert = opts.json; return start_cmd.run(machine, opts); @@ -230,7 +221,6 @@ fn issueCommand(app: app_mod.App, args: []const []const u8) !void { }; var opts: issue_cmd.Opts = .{ .sub = issue_cmd.Sub.empty(verb) }; - // Past the verb, the way `auth setup` starts at 1. var i: usize = 1; while (i < args.len) : (i += 1) { const arg = args[i]; @@ -291,7 +281,6 @@ fn issueCommand(app: app_mod.App, args: []const []const u8) !void { if (opts.issue == null) { opts.issue = arg; } else switch (opts.sub) { - // The only verb with a second positional. .state => |*sub| { if (sub.name != null) return error.TooManyArguments; sub.name = arg; @@ -313,7 +302,6 @@ fn issueCommand(app: app_mod.App, args: []const []const u8) !void { }, } - // stdout belongs to the payload in machine mode, same as `start --json`. var machine = app; machine.ui.divert = opts.json; return issue_cmd.run(machine, opts); @@ -376,20 +364,12 @@ fn openCommand(app: app_mod.App, args: []const []const u8) !void { std.process.exit(1); }; - // Xcode keeps the old picker: it opens a worktree in an editor and has - // nothing to do with sessions. Claude Code is the dashboard now — a - // worktree and whether something is running in it are one question, and - // which half you got used to depend on which of two commands you typed. if (target == .claude) { - // stdout belongs to the payload in machine mode, same as `start --json`. var machine = app; machine.ui.divert = watch_opts.json; return watch_cmd.run(machine, watch_opts); } - // Session flags mean nothing to an editor, and silently ignoring one is how - // `lcc open xcode --stop-all` becomes a bug report about sessions that did - // not stop. if (watch_opts.json or watch_opts.stop_all or watch_opts.force) return error.UnknownOption; const cfg = try config.load(app.gpa, app.io, app.environ); @@ -406,7 +386,6 @@ fn listCommand(app: app_mod.App, args: []const []const u8) !void { } else if (eq(arg, "--refresh")) { network = .refresh; } else if (eq(arg, "--cached")) { - // The way back from a stored `listNetwork` of local or refresh. network = .cached; } else if (eq(arg, "--no-tokens")) { tokens = false; @@ -433,7 +412,6 @@ fn statsCommand(app: app_mod.App, args: []const []const u8) !void { } else return error.UnknownOption; } - // stdout belongs to the payload in machine mode, same as `start --json`. var machine = app; machine.ui.divert = opts.json; return stats_cmd.run(machine, opts); @@ -501,8 +479,6 @@ fn configCommand(app: app_mod.App, args: []const []const u8) !void { } else if (opts.key == null) { opts.key = arg; } else if (opts.value == null) { - // Everything after the setting is its value, whitespace and all — - // `startTaskCommand` is a sentence, not a token. opts.value = arg; } else return error.TooManyArguments; } @@ -526,9 +502,6 @@ fn watchHookCommand(app: app_mod.App, args: []const []const u8) !void { opts.event = args[i]; } else return error.UnknownOption; } - // Never fails. A Claude Code hook runs on every turn of every watched - // session, and one that could report an error would be one that could - // disturb the work it exists only to observe. watch_cmd.hook(app, opts) catch {}; } @@ -549,12 +522,6 @@ fn daemonCommand(app: app_mod.App, args: []const []const u8) !void { return daemon_cmd.run(machine, opts); } -/// A flag that may not have been given, resolved against what the file says. -/// -/// Defaults live here rather than inside each command because this is the only -/// layer that can tell "not passed" from "passed false" — an `Opts` field is a -/// plain bool by the time a command sees it, which is what keeps the commands -/// and their many helpers free of tri-state. fn orConfig(flag: ?bool, configured: bool) bool { return flag orelse configured; } @@ -564,27 +531,12 @@ fn eq(a: []const u8, b: []const u8) bool { } test "the help text offers no daemon and no watch command" { - // The help text is the whole surface a new user reads, so it is the one - // place the vocabulary can regress without anyone noticing. Both words are - // banned for a reason: - // - // `daemon` — a process they neither started nor can act on. Every fact they - // need about it is a fact about their sessions, and is phrased that way. - // `lcc daemon` still dispatches; it is just not advertised. - // - // `watch` — the command is gone, absorbed by `lcc open`. A line here - // pointing at it would send someone to `error.UnknownCommand`. try std.testing.expect(std.mem.indexOf(u8, usage, "daemon") == null); try std.testing.expect(std.mem.indexOf(u8, usage, "lcc watch") == null); - // Not `"watch"` outright: `--watch` and `--no-watch` are still how a session - // is put in the background or kept out of it, and `watchByDefault` is the - // setting behind them. try std.testing.expect(std.mem.indexOf(u8, usage, " watch ") == null); } test { - // Zig only collects tests from files the root references during test - // analysis, so name every module here or `zig build test` runs nothing. _ = @import("ansi.zig"); _ = @import("app.zig"); _ = @import("claude.zig"); diff --git a/src/mcp.zig b/src/mcp.zig index 5db641f..5e13416 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -1,38 +1,16 @@ -//! Local-scope MCP servers, carried into a worktree. -//! -//! `claude mcp add` without `-s user` stores a server in `~/.claude.json` under -//! `projects[""].mcpServers` — the key *is* the directory. A worktree -//! is a different directory, so it inherits none of them: the checkout where -//! `linear-server` was added is the only place it exists. No amount of symlinking -//! fixes that, because the servers are not in the repository at all. -//! -//! So lcc reads them and hands them to Claude Code at launch. `--mcp-config ` -//! loads servers from a file, and without `--strict-mcp-config` they add to the user, -//! global and plugin scopes rather than replacing them. -//! -//! Read-only, deliberately. `~/.claude.json` is Claude Code's own working state — -//! session ids, costs, per-project history — and it rewrites the whole file from -//! memory when a session ends. A write from the outside survives only until the next -//! session exits, which is the worst kind of bug: it works when you test it. - const std = @import("std"); const Io = std.Io; const claude_projects = @import("claude_projects.zig"); const config = @import("config.zig"); const disk = @import("disk.zig"); -/// `~/.claude.json` runs to ~100 KB of session state in practice. The ceiling is -/// here so a pathological file cannot be read into memory unbounded. const claude_json_limit = 32 * 1024 * 1024; pub const Carried = struct { - /// The generated file, for `claude --mcp-config `. path: []const u8, - /// Server names, in the order `~/.claude.json` lists them. names: []const []const u8, }; -/// Claude Code's config file. `LCC_CLAUDE_JSON` overrides it. pub fn claudeJsonPath( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -45,16 +23,6 @@ pub fn claudeJsonPath( return std.fs.path.join(gpa, &.{ home, ".claude.json" }); } -/// The local-scope servers `repo_root` owns, written where `claude --mcp-config` -/// can read them. Null when the repo has none or `mcpCarry` excludes them all — then -/// there is no flag to pass. A malformed or unreadable `~/.claude.json` is also null: -/// MCP servers are a convenience, and failing a worktree over them would be worse -/// than launching without. -/// -/// `mcpCarry` in lcc's own config narrows the set. The allow-list is read here rather -/// than passed in because this is already the one place that knows a worktree needs -/// servers handed to it at all, and both call sites would otherwise thread a list they -/// have no other use for. pub fn carry( gpa: std.mem.Allocator, io: Io, @@ -68,7 +36,6 @@ pub fn carry( const servers = if (stored.mcpCarry) |allow| try only(gpa, found, allow) else found; if (servers.count() == 0) return null; - // `{"mcpServers": {…}}` — the shape `--mcp-config` reads, same as `.mcp.json`. const body = try std.json.Stringify.valueAlloc( gpa, .{ .mcpServers = std.json.Value{ .object = servers } }, @@ -83,9 +50,6 @@ pub fn carry( return .{ .path = path, .names = try gpa.dupe([]const u8, servers.keys()) }; } -/// `servers` reduced to the names `allow` lists, in the file's order so the hint lcc -/// prints still matches what Claude Code loads. A name in `allow` this repo does not -/// have is not an error: the list is written once and outlives any one repo's set. fn only( gpa: std.mem.Allocator, servers: std.json.ObjectMap, @@ -103,9 +67,6 @@ fn only( return kept; } -/// One file per repository, named after it the way Claude Code names its project -/// directories: every non-alphanumeric byte becomes `-`. Lossy, and that is fine -/// here — this is a cache keyed by a path, not a path to be recovered. fn filePath( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -118,9 +79,6 @@ fn filePath( return std.fs.path.join(gpa, &.{ dir, "mcp", name }); } -/// `projects[repo_root].mcpServers`, or null when the file, the project entry or the -/// key is missing. The resolved path is tried as well: Claude Code records the cwd it -/// was handed, which is the resolved one, while `repo_root` comes from git. fn readServers( gpa: std.mem.Allocator, io: Io, @@ -130,8 +88,6 @@ fn readServers( const path = claudeJsonPath(gpa, environ) catch return null; const raw = Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(claude_json_limit)) catch return null; - // `alloc_always`: the parsed value must outlive `raw`, which the caller's arena - // would otherwise be sharing slices with. const root = std.json.parseFromSliceLeaky(std.json.Value, gpa, raw, .{ .allocate = .alloc_always, }) catch return null; @@ -153,8 +109,6 @@ fn readServers( } const testing = struct { - /// A `~/.claude.json` shaped like the real one: two repos with local-scope - /// servers, plus the session-state keys that must not leak into what lcc writes. const claude_json = \\{ \\ "numStartups": 412, @@ -197,13 +151,11 @@ test "carry writes the repo's own servers and nothing else" { const carried = (try carry(arena, io, &environ, "/repo/app")).?; - // Order is the file's order, so the hint lcc prints matches what was added. try std.testing.expectEqual(@as(usize, 2), carried.names.len); try std.testing.expectEqualStrings("linear-server", carried.names[0]); try std.testing.expectEqualStrings("xcode", carried.names[1]); const written = try Io.Dir.cwd().readFileAlloc(io, carried.path, arena, .limited(1 << 20)); - // The shape `--mcp-config` expects, holding this repo's servers only. const Schema = struct { mcpServers: struct { @"linear-server": struct { type: []const u8, url: []const u8 }, @@ -214,13 +166,11 @@ test "carry writes the repo's own servers and nothing else" { try std.testing.expectEqualStrings("https://mcp.linear.app/mcp", parsed.mcpServers.@"linear-server".url); try std.testing.expectEqualStrings("mcpbridge", parsed.mcpServers.xcode.args[0]); - // Another repo's servers, the global ones, and the session state stay out. try std.testing.expect(std.mem.indexOf(u8, written, "sentry") == null); try std.testing.expect(std.mem.indexOf(u8, written, "context7") == null); try std.testing.expect(std.mem.indexOf(u8, written, "lastSessionId") == null); try std.testing.expect(std.mem.indexOf(u8, written, "numStartups") == null); - // One file per repo, under lcc's own config dir — never inside the repo. try std.testing.expect(std.mem.startsWith(u8, carried.path, base)); try std.testing.expect(std.mem.indexOf(u8, carried.path, "-repo-app.json") != null); } @@ -244,12 +194,9 @@ test "carry is null when there is nothing to carry" { try environ.put("HOME", base); try environ.put("LCC_CLAUDE_JSON", json_path); - // A repo Claude Code has never run in, and one whose `mcpServers` is empty: - // both mean "no flag to pass", not "pass an empty config". try std.testing.expect((try carry(arena, io, &environ, "/repo/unknown")) == null); try std.testing.expect((try carry(arena, io, &environ, "/repo/bare")) == null); - // A missing or unparsable file is a launch without MCP, not a failed launch. try environ.put("LCC_CLAUDE_JSON", try std.fs.path.join(arena, &.{ base, "gone.json" })); try std.testing.expect((try carry(arena, io, &environ, "/repo/app")) == null); @@ -278,8 +225,6 @@ test "mcpCarry narrows what a worktree is handed" { try environ.put("HOME", base); try environ.put("LCC_CLAUDE_JSON", json_path); - // The list matches names case-insensitively but keeps the source file's order. - // A name this repo does not have is simply not found. try config.save(arena, io, &environ, .{ .mcpCarry = .{ .only = &.{ "XCODE", "linear-server", "notion" } } }); const narrowed = (try carry(arena, io, &environ, "/repo/app")).?; try std.testing.expectEqual(@as(usize, 2), narrowed.names.len); @@ -290,15 +235,12 @@ test "mcpCarry narrows what a worktree is handed" { try std.testing.expect(std.mem.indexOf(u8, written, "linear-server") != null); try std.testing.expect(std.mem.indexOf(u8, written, "xcode") != null); - // An allow-list that matches nothing is "launch without MCP", like an empty repo. try config.save(arena, io, &environ, .{ .mcpCarry = .{ .only = &.{"clickup"} } }); try std.testing.expect((try carry(arena, io, &environ, "/repo/app")) == null); - // An empty list is not a way to say "all of them" — that is what leaving it out does. try config.save(arena, io, &environ, .{ .mcpCarry = .{ .only = &.{} } }); try std.testing.expect((try carry(arena, io, &environ, "/repo/app")) == null); - // `all` clears the key and restores the default of carrying every local server. try config.save(arena, io, &environ, .{ .mcpCarry = .all }); const all = (try carry(arena, io, &environ, "/repo/app")).?; try std.testing.expectEqual(@as(usize, 2), all.names.len); diff --git a/src/oauth.zig b/src/oauth.zig index 6f6f629..09fe3cf 100644 --- a/src/oauth.zig +++ b/src/oauth.zig @@ -1,5 +1,3 @@ -//! Linear OAuth 2.0 with PKCE — browser flow, local callback, token refresh. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); @@ -28,10 +26,8 @@ pub const Error = error{ CallbackTimedOut, } || std.mem.Allocator.Error; -/// How long to hold the callback port open before giving up on the browser. pub const callback_timeout_ms: i64 = 5 * 60 * 1000; -/// Detail for the last failure, for messages worth reading. pub var last_detail: []const u8 = ""; pub fn nowSeconds(io: Io) i64 { @@ -44,7 +40,6 @@ fn nowMillis(io: Io) i64 { return @intCast(@divTrunc(ts.nanoseconds, std.time.ns_per_ms)); } -/// Blocks until the socket has a connection waiting, or `deadline` passes. fn waitReadable(io: Io, handle: std.posix.fd_t, deadline: i64) Error!void { while (true) { const remaining = deadline - nowMillis(io); @@ -60,8 +55,6 @@ fn waitReadable(io: Io, handle: std.posix.fd_t, deadline: i64) Error!void { return Error.CallbackFailed; }; if (ready > 0) return; - // Zero means the poll timed out; loop so a signal-interrupted wait - // still honours the full deadline. } } @@ -95,8 +88,6 @@ pub fn generateState(gpa: std.mem.Allocator, io: Io) ![]u8 { return base64url(gpa, &raw); } -/// Percent-encodes everything outside the RFC 3986 unreserved set, which is -/// what `URLSearchParams` effectively does for these values. fn encodeQueryComponent(w: *Io.Writer, value: []const u8) !void { for (value) |c| { switch (c) { @@ -153,7 +144,6 @@ fn errorHtml(gpa: std.mem.Allocator, message: []const u8) ![]u8 { , .{message}); } -/// Percent-decodes in place semantics: returns a freshly allocated string. fn decodeComponent(gpa: std.mem.Allocator, value: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; var i: usize = 0; @@ -212,9 +202,6 @@ pub const Callback = struct { state: []const u8, }; -/// Serves 127.0.0.1:39126 until Linear redirects the browser back to it. -/// Requests for anything but the callback path get a 404 and are ignored, so a -/// stray favicon fetch cannot end the flow. pub fn awaitCallback(gpa: std.mem.Allocator, io: Io, expected_state: []const u8) Error!Callback { const address: Io.net.IpAddress = .{ .ip4 = .loopback(config.redirect_port) }; var server = address.listen(io, .{ .reuse_address = true }) catch |err| { @@ -227,8 +214,6 @@ pub fn awaitCallback(gpa: std.mem.Allocator, io: Io, expected_state: []const u8) const deadline = nowMillis(io) + callback_timeout_ms; while (true) { - // Bound the wait, so an abandoned browser tab does not leave lcc - // holding the port forever. try waitReadable(io, server.socket.handle, deadline); var stream = server.accept(io) catch |err| { @@ -351,8 +336,6 @@ fn postToken(gpa: std.mem.Allocator, io: Io, fields: []const [2][]const u8) Erro return .{ .access_token = parsed.access_token, .refresh_token = parsed.refresh_token, - // Same minute of slack as the TypeScript version, so a token is never - // used in the last seconds of its life. .expires_at = if (parsed.expires_in) |secs| nowSeconds(io) + secs - 60 else null, .scope = parsed.scope, .token_type = parsed.token_type, @@ -386,7 +369,6 @@ pub fn refreshAccessToken( .{ "refresh_token", refresh_token }, .{ "client_id", client_id }, }); - // Linear may omit refresh_token on refresh; preserve previous value if (token.refresh_token == null) token.refresh_token = refresh_token; return token; } @@ -411,7 +393,6 @@ pub fn clearToken() void { keychain.delete(service, account) catch {}; } -/// Returns a usable token, refreshing it first when it has expired. pub fn ensureFreshToken(gpa: std.mem.Allocator, io: Io, client_id: []const u8) Error!Token { const token = getToken(gpa) orelse return Error.NotAuthenticated; if (token.is_pat orelse false) return token; diff --git a/src/prompt.zig b/src/prompt.zig index 11e9400..f8a3e3b 100644 --- a/src/prompt.zig +++ b/src/prompt.zig @@ -1,28 +1,14 @@ -//! Raw-mode terminal prompts — the `@inquirer/prompts` replacement. -//! -//! Covers the four widgets lcc uses: `search`, `confirm`, `checkbox`, `input`. -//! Cancelling (Ctrl-C or Esc) returns null; callers exit 130, as the -//! TypeScript version did on inquirer's ExitPromptError. - const std = @import("std"); const Io = std.Io; const fold = @import("fold.zig"); const term_mod = @import("term.zig"); const ui = @import("ui.zig"); -/// Named here as well as in `term.zig` because it is what `main.zig`'s -/// `describe` maps and what every caller catches. The terminal mechanics moved; -/// the error a caller sees did not. pub const Error = term_mod.Error || std.mem.Allocator.Error; pub const Item = struct { - /// Rendered as-is. Must be plain text: the prompt pads and truncates it, - /// which ANSI escapes would throw off. label: []const u8, - /// Matched against the query in `search`. Ignored by the other widgets. haystack: []const u8 = "", - /// Shown dimmed beneath the list while this row is highlighted, the way - /// inquirer's `search` renders a choice description. description: []const u8 = "", }; @@ -31,18 +17,11 @@ const Terminal = term_mod.Terminal; const readKey = term_mod.readKey; const truncate = term_mod.truncate; -/// How many rows of choices a picker shows. Stays here rather than moving to -/// `term.zig` with the rest: `rows - 4` is this widget's chrome budget — the -/// prompt line, the description and the footer — not a fact about the terminal. fn pageSize(term: Terminal) usize { const rows = term.size().rows; return @max(@as(usize, 5), @min(@as(usize, 30), rows -| 4)); } -/// Every whitespace-separated token must appear, as in the TypeScript version. -/// The score then orders the survivors: a token landing at the very start of -/// the haystack (the issue identifier) outranks one starting a word, which -/// outranks one buried mid-word. fn score(haystack: []const u8, query: []const u8) ?i32 { var total: i32 = 0; var tokens = std.mem.tokenizeAny(u8, query, " \t"); @@ -58,7 +37,6 @@ fn score(haystack: []const u8, query: []const u8) ?i32 { return total; } -/// True when the byte before `at` is a separator rather than part of a word. fn startsWord(haystack: []const u8, at: usize) bool { if (at == 0) return true; return switch (haystack[at - 1]) { @@ -71,8 +49,6 @@ const Ranked = struct { index: usize, score: i32, - /// Best score first; ties keep the order the caller supplied, which is the - /// state-then-recency ordering the issue list arrives in. fn better(_: void, a: Ranked, b: Ranked) bool { if (a.score != b.score) return a.score > b.score; return a.index < b.index; @@ -80,14 +56,10 @@ const Ranked = struct { }; test "a confirmation answers to the key, not to the letter it printed" { - // `lcc remove` gates a deletion behind this. On a Ukrainian layout `y` and - // `n` print `н` and `т`, so reading the character left no way to answer at - // all — not yes, not no. try std.testing.expectEqual(@as(u8, 'y'), term_mod.layoutKey(firstCodepoint("н")).?); try std.testing.expectEqual(@as(u8, 'n'), term_mod.layoutKey(firstCodepoint("т")).?); try std.testing.expectEqual(@as(u8, 'y'), term_mod.layoutKey(firstCodepoint("y")).?); try std.testing.expectEqual(@as(u8, 'n'), term_mod.layoutKey(firstCodepoint("N")).?); - // And something that is neither is still neither. try std.testing.expect(term_mod.layoutKey(firstCodepoint("ю")) != 'y'); } @@ -95,7 +67,6 @@ test "every token must match" { const hay = "PE-247 Fix EXC_BAD_ACCESS data race feature/pe-247 Todo"; try std.testing.expect(score(hay, "fix race") != null); try std.testing.expect(score(hay, "fix nonsense") == null); - // An empty query keeps everything, at a neutral score. try std.testing.expectEqual(@as(?i32, 0), score(hay, "")); } @@ -111,11 +82,8 @@ test "identifier beats word start beats mid-word" { const word_start = "PE-100 Something pe-ish here"; const mid_word = "PE-100 Nope typewriter"; - // Leading match on the identifier. try std.testing.expectEqual(@as(?i32, 100), score(identifier, "PE-247")); - // "pe-ish" starts a word. try std.testing.expectEqual(@as(?i32, 50), score(word_start, "pe-i")); - // "pe" inside "typewriter" is buried. try std.testing.expectEqual(@as(?i32, 10), score(mid_word, "pew")); } @@ -129,12 +97,10 @@ test "ranking puts the identifier match first and is stable otherwise" { std.mem.sort(Ranked, &items, {}, Ranked.better); try std.testing.expectEqual(@as(usize, 1), items[0].index); try std.testing.expectEqual(@as(usize, 3), items[1].index); - // Equal scores keep their original relative order. try std.testing.expectEqual(@as(usize, 0), items[2].index); try std.testing.expectEqual(@as(usize, 2), items[3].index); } -/// Drops one whole codepoint from the end of a growable buffer. fn popCodepoint(buf: *std.ArrayList(u8)) void { if (buf.items.len == 0) return; var i = buf.items.len - 1; @@ -279,7 +245,6 @@ pub fn search( } } -/// `message` may span several lines; the y/n hint goes after the last one. pub fn confirm( gpa: std.mem.Allocator, io: Io, @@ -304,8 +269,6 @@ pub fn confirm( const hint = if (default_yes) "(Y/n)" else "(y/N)"; var key_buf: [8]u8 = undefined; - // Typed, then submitted with Enter — the same two-step inquirer's confirm - // uses, so muscle memory of "y⏎" does not leak a stray newline. var typed: std.ArrayList(u8) = .empty; while (true) { @@ -336,12 +299,8 @@ pub fn confirm( if (typed.items.len == 0) { answer = default_yes; } else switch (term_mod.layoutKey(firstCodepoint(typed.items)) orelse 0) { - // By key position: `y` and `n` print `н` and `т` on a - // Cyrillic layout, which left no way at all to answer the - // confirmation `lcc remove` puts in front of a deletion. 'y' => answer = true, 'n' => answer = false, - // Anything else is not an answer: clear and ask again. else => typed.clearRetainingCapacity(), } }, @@ -353,8 +312,8 @@ pub fn confirm( if (answer) |value| { screen.eraseFrame(); out.print("{s}✓{s} {s} {s}{s}{s}\n", .{ - p.green, p.reset, firstLine(message), - p.cyan, if (value) "yes" else "no", p.reset, + p.green, p.reset, firstLine(message), + p.cyan, if (value) "yes" else "no", p.reset, }) catch {}; out.flush() catch {}; return value; @@ -362,8 +321,6 @@ pub fn confirm( } } -/// The first whole codepoint, so a two-byte Cyrillic letter is matched as one -/// character rather than by its leading byte. fn firstCodepoint(text: []const u8) []const u8 { if (text.len == 0) return text; const len = std.unicode.utf8ByteSequenceLength(text[0]) catch 1; @@ -375,7 +332,6 @@ fn firstLine(message: []const u8) []const u8 { return message[0..nl]; } -/// Multi-select. Returns the indices that were checked when Enter was pressed. pub fn checkbox( gpa: std.mem.Allocator, io: Io, @@ -474,7 +430,6 @@ pub fn checkbox( } } -/// Single-line text entry. Enter on an untouched field keeps `default_value`. pub fn input( gpa: std.mem.Allocator, io: Io, @@ -498,7 +453,6 @@ pub fn input( while (true) { const width: usize = @max(@as(usize, 20), term.size().cols); - // Single line — clearing and redrawing in place needs no line counting. out.writeAll("\r" ++ csi ++ "2K") catch {}; if (touched) { const shown = truncate(value.items, width -| (ui.displayWidth(message) + 4)); @@ -525,8 +479,6 @@ pub fn input( }, .backspace => { if (!touched) { - // First edit starts from an empty field, like inquirer's - // default handling. touched = true; value.clearRetainingCapacity(); } else { diff --git a/src/pty.zig b/src/pty.zig index d5d2a6d..21e9713 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -1,28 +1,5 @@ -//! A pseudo-terminal with a real session and controlling terminal behind it. -//! -//! `forkpty`, not `openpty` plus a spawn. `std.process.SpawnOptions.stdin` -//! accepts an arbitrary fd, so handing a child the slave end would give it -//! something `isatty` agrees is a terminal — but not a *controlling* terminal, -//! because 0.16 offers no `setsid` option and no pre-exec hook. Without one the -//! kernel never delivers SIGWINCH, and Claude Code is an Ink program: it -//! repaints on resize and on nothing else. `forkpty` does fork, `setsid`, -//! `TIOCSCTTY` and the three `dup2`s in one call, and the test at the bottom of -//! this file is what keeps that true. -//! -//! Nothing here takes an `Io`. Every call is a libc call with no `Io` vtable -//! entry behind it, and threading a fake one through would be a lie about what -//! is being awaited. `oauth.zig`'s `waitReadable` sets the precedent. -//! -//! **The daemon that owns this must stay single-threaded.** `fork` in a process -//! with a thread pool leaves the child holding locks no surviving thread will -//! release, and libc's allocator lock is one of them — so everything between -//! the fork and the `execve` below is async-signal-safe only, and every -//! allocation happens before the fork. - const std = @import("std"); -/// ``. Not in std — grep it and you get nothing — but it lives in -/// libSystem, so `link_libc` resolves it and `build.zig` needs no new linkage. extern "c" fn forkpty( amaster: *c_int, name: ?[*:0]u8, @@ -30,17 +7,8 @@ extern "c" fn forkpty( winp: ?*const std.posix.winsize, ) c_int; -/// Declared here rather than taken from `std.c.ioctl` for the request type: -/// std types it `c_int`, and `TIOCSWINSZ` has bit 31 set. Same shape -/// `prompt.zig` used for `TIOCGWINSZ`. extern "c" fn ioctl(fd: c_int, request: c_ulong, ...) c_int; -/// `_IOW('t', 103, struct winsize)`, spelled out because Darwin's `std.c.T` -/// defines only `IOCGWINSZ` — the `0x80087467` that turns up when grepping std -/// is in the FreeBSD arm. It matches, since both come from the same BSD macro, -/// but borrowing another platform's constant is not a thing to do quietly. -/// -/// IOC_IN | ((sizeof(winsize) & IOCPARM_MASK) << 16) | ('t' << 8) | 103 const TIOCSWINSZ: c_ulong = 0x80000000 | (@sizeOf(std.posix.winsize) << 16) | ('t' << 8) | 103; pub const Error = error{ PtyUnavailable, ForkFailed } || std.mem.Allocator.Error; @@ -48,17 +16,8 @@ pub const Error = error{ PtyUnavailable, ForkFailed } || std.mem.Allocator.Error pub const Size = struct { rows: u16, cols: u16 }; pub const Spec = struct { - /// Absolute. A `PATH` search must not happen on the child side of a fork, - /// so the caller resolves it first — `claude.resolvePath` exists for this. program: []const u8, - /// The arguments *after* `argv[0]`, which is `program`. Mirrors - /// `claude.launch`'s `extra_args`, so the one mistake that produces a - /// confusing failure — an argv missing its zeroth element, silently eating - /// the first real argument — cannot be made here. argv: []const []const u8, - /// `"K=V"` pairs, the whole environment. Must be the *client's*, never the - /// daemon's own: a daemon started days ago by one shell would otherwise - /// give every later session that shell's `PATH`. env: []const []const u8, cwd: []const u8, size: Size, @@ -69,25 +28,9 @@ pub const Spawned = struct { pid: std.posix.pid_t, }; -/// The signals a parent may have set to `SIG_IGN`, which — unlike a handler — -/// survives `execve`. A daemon that ignores these to stay detached would -/// otherwise hand Claude Code a process that cannot be stopped with `^C`. -/// -/// `KILL` and `STOP` are deliberately absent: `sigaction` on either is EINVAL, -/// which `std.posix.sigaction` treats as `unreachable`. const reset_to_default = [_]std.c.SIG{ .HUP, .INT, .QUIT, .PIPE, .TSTP, .TTIN, .TTOU, .CHLD, .IO }; pub fn spawn(gpa: std.mem.Allocator, spec: Spec) Error!Spawned { - // Everything the child needs is built here, before the fork, because after - // it an allocation can deadlock against a lock no thread is left to unlock. - // - // An arena, released on the way out: the child gets a copy-on-write image - // of all of this and reads it until `execve` replaces the address space, so - // freeing the parent's copy after the fork cannot reach it. The `defer` - // runs only in the parent — the child leaves this scope through `execve` or - // `_exit` and never reaches the end of it. Without this a daemon holding a - // process arena would keep one copy of every session's environment for as - // long as it lived. var scratch: std.heap.ArenaAllocator = .init(gpa); defer scratch.deinit(); const a = scratch.allocator(); @@ -114,13 +57,6 @@ pub fn spawn(gpa: std.mem.Allocator, spec: Spec) Error!Spawned { if (pid < 0) return Error.ForkFailed; if (pid == 0) { - // `login_tty` has already run inside forkpty: fds 0/1/2 are the slave, - // we lead a new session, and the pty is our controlling terminal. - // Async-signal-safe only from here to the execve. - // - // The mask is inherited across exec, so a daemon that blocks SIGCHLD - // around a critical section would otherwise start every agent with it - // blocked. var empty = std.posix.sigemptyset(); std.posix.sigprocmask(std.c.SIG.SETMASK, &empty, null); const dfl: std.posix.Sigaction = .{ @@ -132,17 +68,10 @@ pub fn spawn(gpa: std.mem.Allocator, spec: Spec) Error!Spawned { if (std.c.chdir(cwd_z.ptr) != 0) std.c._exit(126); _ = std.c.execve(program_z.ptr, argv_z.ptr, envp_z.ptr); - // 127 is the shell's convention for "not found", and the caller reads - // it back through `reap` to tell a missing binary from a crash. std.c._exit(127); } - // CLOEXEC so a *later* session's fork cannot inherit this master and hold - // the pty device open past our own close — which would keep the slave from - // ever reporting EOF, and with it hide that the child had died. _ = std.c.fcntl(master, std.posix.F.SETFD, @as(c_int, std.posix.FD_CLOEXEC)); - // NONBLOCK so one child that has stopped reading cannot stall the single - // poll loop every other session shares. const flags = std.c.fcntl(master, std.posix.F.GETFL, @as(c_int, 0)); if (flags >= 0) { var o: std.posix.O = @bitCast(@as(u32, @intCast(flags))); @@ -153,7 +82,6 @@ pub fn spawn(gpa: std.mem.Allocator, spec: Spec) Error!Spawned { return .{ .master = master, .pid = pid }; } -/// Best-effort: a resize is decoration on a session, never a reason to fail one. pub fn resize(master: std.posix.fd_t, size: Size) void { const ws: std.posix.winsize = .{ .row = size.rows, @@ -166,9 +94,7 @@ pub fn resize(master: std.posix.fd_t, size: Size) void { pub const Read = union(enum) { n: usize, - /// Nothing buffered right now. Not an error, and not a closed pty. again, - /// The slave is gone: the child has exited or closed its last handle. closed, }; @@ -178,14 +104,8 @@ pub fn read(master: std.posix.fd_t, buf: []u8) Read { if (rc > 0) return .{ .n = @intCast(rc) }; if (rc == 0) return .closed; switch (std.posix.errno(rc)) { - // `Io.Threaded`'s SIGPIPE handler carries no SA_RESTART, so an - // interrupted read is ours to retry — nothing else will. .INTR => continue, .AGAIN => return .again, - // Darwin reports a hung-up pty master as EIO rather than EOF, and - // reads it as an error where every other platform reads it as the - // end. Treating it as anything but "closed" leaves dead sessions - // in the table forever. .IO => return .closed, else => return .closed, } @@ -208,34 +128,24 @@ pub fn write(master: std.posix.fd_t, bytes: []const u8) Write { pub const Exit = union(enum) { code: u8, signal: u8 }; -/// `waitpid(WNOHANG)`. Null while the child is still running. -/// -/// Only ever called with a pid this module produced. A wildcard `waitpid(-1, …)` -/// would steal a status `std.process.Child.wait` is blocked on elsewhere in the -/// process and hang it. pub fn reap(pid: std.posix.pid_t) ?Exit { var status: c_int = 0; while (true) { - const rc = std.c.waitpid(pid, &status, 1); // WNOHANG - if (rc == 0) return null; // still running + const rc = std.c.waitpid(pid, &status, 1); + if (rc == 0) return null; if (rc < 0) { if (std.posix.errno(rc) == .INTR) continue; - return null; // already reaped, or never ours + return null; } const raw: u32 = @bitCast(status); if (std.posix.W.IFEXITED(raw)) return .{ .code = std.posix.W.EXITSTATUS(raw) }; if (std.posix.W.IFSIGNALED(raw)) { return .{ .signal = @intCast(@intFromEnum(std.posix.W.TERMSIG(raw))) }; } - return null; // stopped or continued: not an exit + return null; } } -/// Signals the child's whole process group, not just the child. -/// -/// `login_tty` made it a process-group leader, so the negative pid reaches -/// everything Claude Code spawned — the shells it ran, the builds they started -/// — rather than leaving them orphaned and holding the pty open. pub fn signalGroup(pid: std.posix.pid_t, sig: std.c.SIG) void { _ = std.c.kill(-pid, sig); } @@ -244,24 +154,12 @@ pub fn close(master: std.posix.fd_t) void { _ = std.c.close(master); } -// --------------------------------------------------------------------------- -// Tests -// -// These fork real processes on a real pty inside the test runner, which is -// multithreaded — precisely the hazard the module header is about. That makes -// them a check on the between-fork-and-exec code as well as on the behaviour -// each one names. Every wait carries a deadline, because the failure mode -// without one is a CI job that hangs instead of a test that fails. -// --------------------------------------------------------------------------- - const test_deadline_ms: i32 = 10_000; fn testEnv() []const []const u8 { return &.{ "PATH=/usr/bin:/bin:/usr/sbin:/sbin", "TERM=xterm-256color" }; } -/// Reads until the pty closes or the deadline passes. Test-only: the daemon -/// never blocks on a session, it polls every fd at once. fn drain(gpa: std.mem.Allocator, master: std.posix.fd_t) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); @@ -284,7 +182,6 @@ fn drain(gpa: std.mem.Allocator, master: std.posix.fd_t) ![]u8 { return out.toOwnedSlice(gpa); } -/// Polls `reap` to a deadline. Test-only, for the same reason as `drain`. fn waitExit(pid: std.posix.pid_t) ?Exit { var waited: i32 = 0; while (waited < test_deadline_ms) { @@ -303,17 +200,6 @@ test "a child on the pty has a controlling terminal, not merely a tty fd" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // The two must name the *same* terminal, and asking whether /dev/tty merely - // opens is not enough to tell: a controlling terminal is inherited across - // fork, so a child handed a slave fd by `std.process.spawn` still has one — - // the terminal lcc itself was launched from. `tty` reports what is on - // stdin (our pty); `ps -o tty=` reports the controlling terminal. Equal - // means the pty was actually taken over; different is precisely the - // openpty-without-setsid arrangement in which the kernel sends SIGWINCH to - // some other session and Claude Code never repaints. - // - // This is the whole reason this module calls forkpty. If it stops holding, - // resize dies quietly and nothing else in the suite notices. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "printf 'STDIN=%s CTTY=%s\\n' \"$(tty)\" \"$(ps -o tty= -p $$ | tr -d ' ')\"" }, @@ -326,7 +212,6 @@ test "a child on the pty has a controlling terminal, not merely a tty fd" { const out = try drain(gpa, spawned.master); defer gpa.free(out); - // `tty` prints `/dev/ttys004`; `ps` prints `ttys004`. Compare on the leaf. const stdin_at = std.mem.indexOf(u8, out, "STDIN=/dev/") orelse { std.debug.print("child never reported its tty; got \"{f}\"\n", .{std.zig.fmtString(out)}); return error.TestExpectedEqual; @@ -357,8 +242,6 @@ test "the size we asked for is the size the child sees" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // Ink lays out against this. Getting it wrong renders Claude Code at 24x80 - // inside a full-screen terminal, which looks like a Claude Code bug. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "stty size" }, @@ -380,8 +263,6 @@ test "the child starts in the cwd it was given, not the daemon's" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // The worktree is the entire point of `lcc start`. A session that came up - // in the daemon's cwd would be an agent editing the wrong checkout. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "pwd -P" }, @@ -393,8 +274,6 @@ test "the child starts in the cwd it was given, not the daemon's" { const out = try drain(gpa, spawned.master); defer gpa.free(out); - // `realPathFileAlloc` already resolved /var -> /private/var, and `pwd -P` - // resolves it the same way on the child's side. try std.testing.expect(std.mem.indexOf(u8, out, cwd) != null); } @@ -405,13 +284,6 @@ test "a resize reaches the child as SIGWINCH" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // The payoff of the controlling terminal. If this breaks, Claude Code - // never repaints after a window resize and the pane stays the size it was - // when the session started. - // - // `sleep` in a loop rather than one long sleep: a non-interactive sh runs - // a pending trap only between commands, so the poll interval sets how - // quickly the handler fires. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "trap 'echo GOT_WINCH; exit 0' WINCH; echo READY; while :; do sleep 0.05; done" }, @@ -421,8 +293,6 @@ test "a resize reaches the child as SIGWINCH" { }); defer close(spawned.master); - // Wait for READY before resizing: a SIGWINCH delivered before the trap is - // installed proves nothing and would make this flaky rather than wrong. var seen: std.ArrayList(u8) = .empty; defer seen.deinit(gpa); var buf: [1024]u8 = undefined; @@ -465,9 +335,6 @@ test "a missing program is exit 127, not a session that hangs" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // forkpty succeeds even when the exec cannot: the fork happened. So a - // typo'd `claude` path shows up as an immediate 127, and the session state - // machine reads that as `failed` rather than sitting in `starting` forever. const spawned = try spawn(gpa, .{ .program = "/nonexistent/claude", .argv = &.{}, @@ -484,9 +351,6 @@ test "a missing program is exit 127, not a session that hangs" { test "an unreachable cwd is 126, told apart from a missing program" { const gpa = std.testing.allocator; - // A worktree removed under a live session is a real case, and it must not - // report itself as a missing binary — the two send you looking in - // completely different places. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "echo hi" }, @@ -520,21 +384,15 @@ test "the master is close-on-exec and non-blocking" { close(spawned.master); } - // CLOEXEC: without it a later session's fork inherits this master, the pty - // never reports EOF, and a dead session stays `running` forever. const fd_flags = std.c.fcntl(spawned.master, std.posix.F.GETFD, @as(c_int, 0)); try std.testing.expect(fd_flags >= 0); try std.testing.expect((fd_flags & std.posix.FD_CLOEXEC) != 0); - // NONBLOCK: without it one child that stopped reading blocks the single - // poll loop, and every other session stops with it. const fl = std.c.fcntl(spawned.master, std.posix.F.GETFL, @as(c_int, 0)); try std.testing.expect(fl >= 0); const o: std.posix.O = @bitCast(@as(u32, @intCast(fl))); try std.testing.expect(o.NONBLOCK); - // And the consequence a caller depends on: reading an idle pty returns - // `.again` immediately rather than parking the thread. var buf: [64]u8 = undefined; try std.testing.expectEqual(Read.again, read(spawned.master, &buf)); } @@ -546,9 +404,6 @@ test "signalGroup reaches past the child into what it spawned" { const cwd = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); defer gpa.free(cwd); - // Claude Code runs builds and shells; killing only the node process would - // orphan them still holding the pty. The negative pid is what makes - // stopping a session actually stop it. const spawned = try spawn(gpa, .{ .program = "/bin/sh", .argv = &.{ "-c", "sleep 30 & echo STARTED; wait" }, @@ -580,8 +435,6 @@ test "signalGroup reaches past the child into what it spawned" { const status = waitExit(spawned.pid) orelse return error.TestExpectedEqual; try std.testing.expectEqual(Exit{ .signal = 9 }, status); - // The pty reports the hangup rather than staying open on the backgrounded - // `sleep` — which is what it would do if only the leader had been killed. var drained: [4096]u8 = undefined; var closed = false; waited = 0; diff --git a/src/release.zig b/src/release.zig index 4f8d224..8fb408b 100644 --- a/src/release.zig +++ b/src/release.zig @@ -1,23 +1,3 @@ -//! Which release an issue targets, decided as a pure function of gathered facts. -//! -//! This module imports nothing but `std` and `semver.zig` — no git, no Linear, no -//! `Io`, no allocator. Everything that costs a round trip is gathered by the -//! caller and handed over as `Facts`, which is what makes all seven rules table -//! tests rather than something only a live workspace can settle. It is the -//! `Facts`/`buildReport` split in `commands/start.zig`, pushed as far as it goes. -//! -//! The rules, in order: -//! -//! 1. a version said outright -//! 2. the issue is already in a project — never silently reassigned -//! 3. the current branch is `release/X.Y.Z` -//! 4. the branch was cut from one — from the pull request's base, else from the -//! candidate base HEAD is fewest commits ahead of -//! 5. on trunk: the lowest open release project the board still accumulates for -//! 6. nothing open left: the next minor above what already shipped — proposed, -//! never taken -//! 7. nobody can tell: ask - const std = @import("std"); const semver = @import("semver.zig"); @@ -33,8 +13,6 @@ pub const Rule = enum { next_minor, unresolved, - /// The rule's number in the list above, so a report can name it the way the - /// documentation does. pub fn number(self: Rule) u8 { return switch (self) { .explicit => 1, @@ -47,7 +25,6 @@ pub const Rule = enum { }; } - /// The bracketed half of `→ Project: v2.5.2 (rule 3: release branch)`. pub fn describe(self: Rule) []const u8 { return switch (self) { .explicit => "named outright", @@ -64,42 +41,27 @@ pub const Rule = enum { pub const Project = struct { id: []const u8, - /// Exactly as Linear spells it — what a human sees and what `--assign` matches. name: []const u8, version: Version, }; pub const ReleaseBranch = struct { - /// `release/2.5.2`, `origin/` already stripped. branch: []const u8, version: Version, - /// `git rev-list --count origin/..HEAD`. Null when the range could not - /// be resolved, and a null must **lose** the nearest-base contest rather than - /// win it — an unreachable ref is not the closest one. ahead: ?u32 = null, }; -/// Why a version could not be settled without asking. pub const Reason = enum { - /// Rule 7 proper: git and Linear both came up empty. no_signal, - /// On trunk's line, but the project board could not be read. linear_unavailable, - /// The issue reports no team, so there is no board to scope a query to. no_team, - /// The open-project window was full, so the lowest is not provable. projects_truncated, - /// Two release branches are equidistant from HEAD. ambiguous_base, - /// No repository, so the release-branch veto cannot be applied — and an - /// unvetoed "lowest open project" is exactly the wrong answer rule 5 exists - /// to avoid. no_git, }; pub const Evidence = struct { kind: enum { argument, project, branch, pull_request, distance }, - /// `release/2.5.2`, `--assign v2.5.2`, `2 commits ahead of origin/release/2.5.2`. text: []const u8, }; @@ -107,119 +69,61 @@ pub const Resolved = struct { version: Version, rule: Rule, evidence: Evidence, - /// The project already carrying this name, when the board was read and one - /// exists. Null means either "Linear was not asked" or "asked, and there is - /// none" — only the second may authorise creating it, which is why the caller - /// tracks whether it asked. project: ?Project = null, - /// A project already on the issue that this answer disagrees with. Reachable - /// only through rule 1, because nothing else outranks rule 2 — and this is - /// what a writer refuses on without an explicit override. displaced: ?Project = null, }; pub const AlreadySet = struct { - /// Null when the project's name is not a version. Untouchable either way. version: ?Version, project: ?Project, name: []const u8, }; pub const NeedsConfirmation = struct { - /// `vA.B+1.0`. candidate: Version, baseline: Version, baseline_from: enum { tag, completed_project, release_branch }, - /// `v2.5.2`, `project v2.5.2`, `origin/release/2.5.2`. baseline_evidence: []const u8, }; pub const NeedsChoice = struct { reason: Reason, - /// Sub-slices of `Facts`, never allocated — which is why `Facts` arrives - /// sorted and already partitioned. open: []const Project = &.{}, branches: []const ReleaseBranch = &.{}, - /// Where a picker should put the cursor, when one row is better than the rest. suggestion: ?Version = null, }; pub const Outcome = union(enum) { - /// A version lcc stands behind. Rules 1, 3, 4, 5. resolved: Resolved, - /// Rule 2, carrying no permission to do anything. already_set: AlreadySet, - /// Rule 6: a candidate nobody has agreed to yet. needs_confirmation: NeedsConfirmation, - /// Rule 7, and every case where a rule could have fired but its input was - /// missing or ambiguous. needs_choice: NeedsChoice, }; -/// Everything the rules read, gathered before any of them runs. -/// -/// Every version list arrives **semver-ascending**, and every rule is guarded by -/// its own input being present. Both together are what let a caller ask twice: -/// once on facts holding only the issue, and again once git and the project board -/// have been paid for. A rule whose input is missing simply does not fire. pub const Facts = struct { - /// `PE-42`, for the wording of a question. identifier: []const u8 = "", - /// Rule 1 — the version said outright, already parsed. explicit: ?Version = null, - /// Rule 2 — the version-named project the issue carries. issue_project: ?Project = null, - /// Rule 2 for a project whose name is not a version. It still fires: an - /// already-set project is untouchable whether or not lcc can read a version - /// out of its name. issue_project_unversioned: ?[]const u8 = null, - /// False when no repository was found at all, which is different from a - /// repository that simply has no release branches. git_available: bool = false, - /// Null for a detached HEAD. current_branch: ?[]const u8 = null, default_branch: []const u8 = "main", - /// The base branch of the pull request for the current branch, short name. - /// Null covers no PR, no `gh`, no auth and no network alike — the fallback - /// does not care which. pr_base: ?[]const u8 = null, - /// Live `origin/release/X.Y.Z`, each with how far HEAD is ahead of it. release_branches: []const ReleaseBranch = &.{}, - /// How far HEAD is ahead of `origin/`. Null when the range - /// could not be resolved. ahead_of_default: ?u32 = null, - /// Tags that parse as versions, ascending. tags: []const Version = &.{}, - /// Linear was asked for the team's release projects and answered. projects_asked: bool = false, - /// Versions trunk is still accumulating for, ascending — `partitionStabilising` - /// already applied. open_projects: []const Project = &.{}, - /// Open projects dropped by that partition, kept because a dropped candidate - /// is the most likely thing a human disagrees with. dropped_projects: []const Project = &.{}, - /// Completed ones, ascending. A baseline for rule 6, nothing else. completed_projects: []const Project = &.{}, - /// The open window was full, so the lowest open version is not provable. projects_truncated: bool = false, - /// The issue's team key. Null means there is no board to scope a query to. team: ?[]const u8 = null, }; -/// Rules 1 to 4 — everything answerable from the argument, the issue and local -/// refs. Null when the project board is still needed, which is also what facts -/// that have not been fully gathered yet come to. -/// -/// Worth calling on its own: rule 2 is the common case in a start-task flow, and -/// it needs neither git nor the project board. pub fn resolveLocal(facts: Facts) ?Outcome { - // 1 — said outright. It wins the *report* even over an already-set project, - // and carries the conflict in `displaced` so a writer can refuse: the prose - // orders rule 1 first while also saying rule 2 is never overridden, and those - // two only hold together if the override is reported but not performed. if (facts.explicit) |version| { return .{ .resolved = .{ .version = version, @@ -229,8 +133,6 @@ pub fn resolveLocal(facts: Facts) ?Outcome { } }; } - // 2 — already in a project. Never silently reassigned; moving between releases - // is a manual cut. if (facts.issue_project) |project| { return .{ .already_set = .{ .version = project.version, @@ -242,7 +144,6 @@ pub fn resolveLocal(facts: Facts) ?Outcome { return .{ .already_set = .{ .version = null, .project = null, .name = name } }; } - // 3 — standing on the release branch itself. if (facts.current_branch) |branch| { if (semver.fromBranch(branch)) |version| { return .{ .resolved = .{ @@ -251,12 +152,9 @@ pub fn resolveLocal(facts: Facts) ?Outcome { .evidence = .{ .kind = .branch, .text = branch }, } }; } - // On trunk there is nothing to measure — trunk *is* the base. if (std.mem.eql(u8, branch, facts.default_branch)) return null; } - // 4a — a pull request already answers what this was cut from. A base that is - // not a release branch means trunk, which is rule 5's business. if (facts.pr_base) |base| { if (semver.fromBranch(base)) |version| { return .{ .resolved = .{ @@ -268,18 +166,13 @@ pub fn resolveLocal(facts: Facts) ?Outcome { return null; } - // 4b — no pull request: the candidate base HEAD is fewest commits ahead of. return nearestBase(facts); } -/// The whole ladder. Total, allocation-free, and callable on partial facts. pub fn resolve(facts: Facts) Outcome { if (resolveLocal(facts)) |outcome| return outcome; - // 5 — on trunk's line: the lowest version the board is still accumulating for. if (!facts.git_available) { - // Without git the release-branch veto cannot be applied, and an unvetoed - // "lowest open" is the wrong-answer shape rule 5 exists to prevent. return .{ .needs_choice = .{ .reason = .no_git, .open = facts.open_projects, @@ -305,8 +198,6 @@ pub fn resolve(facts: Facts) Outcome { } }; } - // 6 — nothing open left. Propose the next minor above whatever shipped most - // recently, and let a human agree to it. if (highestShipped(facts)) |baseline| { return .{ .needs_confirmation = .{ .candidate = semver.nextMinor(baseline.version), @@ -316,16 +207,9 @@ pub fn resolve(facts: Facts) Outcome { } }; } - // 7 — git and Linear both came up empty. return .{ .needs_choice = .{ .reason = .no_signal } }; } -/// The candidate base HEAD is fewest commits ahead of. -/// -/// Trunk wins every tie, deliberately: falling through to the project board puts -/// the decision where there is more evidence than a commit count, and it never -/// files work into a release that is already stabilising. A null count loses to -/// everything, because a ref that could not be resolved is not the nearest one. fn nearestBase(facts: Facts) ?Outcome { var best: ?ReleaseBranch = null; var tied = false; @@ -347,7 +231,6 @@ fn nearestBase(facts: Facts) ?Outcome { const winner = best orelse return null; - // Trunk wins outright when it is at least as near, which is also the tie rule. if (facts.ahead_of_default) |trunk| { if (trunk <= winner.ahead.?) return null; } @@ -370,11 +253,6 @@ const Baseline = struct { text: []const u8, }; -/// The highest version that has already been cut, from whichever of the three -/// sources knows about the newest one. All three are consulted because each can -/// be the only one that saw the latest release: a tag exists once it ships, a -/// completed project once the board is tidied, and a release branch as soon as -/// stabilisation starts. fn highestShipped(facts: Facts) ?Baseline { var best: ?Baseline = null; @@ -395,19 +273,6 @@ fn highestShipped(facts: Facts) ?Baseline { return best; } -/// Splits `open` in place into the versions trunk is still accumulating for and -/// the ones it is not, returning how many of the first kind ended up at the front. -/// Unstable, so the caller sorts each half. -/// -/// Two things are dropped, and they are different: -/// -/// * a version with a live `origin/release/X.Y.Z` branch — that release is -/// already being stabilised, so trunk is aiming past it; -/// * a version at or below `shipped_ceiling` — a project left open in the -/// backlog long after its release went out has no branch left to veto it and -/// would otherwise win rule 5 outright. The ceiling is built from tags and -/// completed projects, **not** from release branches: a release branch means -/// stabilising, which is the first rule's job, not shipped. pub fn partitionStabilising( open: []Project, branches: []const ReleaseBranch, @@ -451,8 +316,6 @@ test "rule 1 wins the report even over a project already set, and states the con try std.testing.expectEqual(Rule.explicit, outcome.resolved.rule); try std.testing.expectEqual(@as(usize, 6), outcome.resolved.version.minor); - // Reported, not performed: a writer refuses on this without an explicit - // override, which is where "never silently reassign" is actually enforced. try std.testing.expectEqualStrings("v2.5.0", outcome.resolved.displaced.?.name); } @@ -461,7 +324,6 @@ test "rule 2 fires with no git and no board, which is the whole point of staging try std.testing.expectEqual(@as(usize, 6), outcome.already_set.version.?.minor); try std.testing.expectEqualStrings("v2.6.0", outcome.already_set.name); - // `resolveLocal` answers it too, so the caller can skip paying for git. try std.testing.expect(resolveLocal(.{ .issue_project = proj("p-260", "v2.6.0") }) != null); } @@ -494,8 +356,6 @@ test "rule 4 takes the pull request's base, and falls through when that base is try std.testing.expectEqual(Rule.pr_base, from_pr.resolved.rule); try std.testing.expectEqual(@as(usize, 6), from_pr.resolved.version.minor); - // A pull request based on trunk says the work is aimed at trunk's release, so - // the board decides — the commit-distance contest is not consulted at all. const onto_trunk = resolve(.{ .git_available = true, .current_branch = "feature/pe-42-x", @@ -511,8 +371,6 @@ test "the nearest-base contest measures distance, and a null loses to everything const branches = [_]ReleaseBranch{ .{ .branch = "release/2.5.2", .version = v("v2.5.2"), .ahead = 7 }, .{ .branch = "release/2.6.0", .version = v("v2.6.0"), .ahead = 2 }, - // Unreachable: it must not come back as the nearest one just because its - // count could not be taken. .{ .branch = "release/2.4.1", .version = v("v2.4.1"), .ahead = null }, }; const outcome = resolve(.{ @@ -525,7 +383,6 @@ test "the nearest-base contest measures distance, and a null loses to everything try std.testing.expectEqual(Rule.nearest_base, outcome.resolved.rule); try std.testing.expectEqualStrings("release/2.6.0", outcome.resolved.evidence.text); - // A null on its own leaves nothing to measure, so the board decides. const unmeasurable = [_]ReleaseBranch{ .{ .branch = "release/2.4.1", .version = v("v2.4.1"), .ahead = null }, }; @@ -542,8 +399,6 @@ test "trunk wins a tie, and two release branches tying is a question" { .{ .branch = "release/2.6.0", .version = v("v2.6.0"), .ahead = 3 }, }; - // Trunk at the same distance wins: the board has more evidence than a commit - // count, and this never files work into a release already stabilising. const to_board = resolveLocal(.{ .git_available = true, .current_branch = "feature/pe-42-x", @@ -552,7 +407,6 @@ test "trunk wins a tie, and two release branches tying is a question" { }); try std.testing.expect(to_board == null); - // Two releases equidistant is the expensive thing to guess at. const tied = [_]ReleaseBranch{ .{ .branch = "release/2.6.0", .version = v("v2.6.0"), .ahead = 3 }, .{ .branch = "release/2.5.2", .version = v("v2.5.2"), .ahead = 3 }, @@ -581,9 +435,6 @@ test "a detached HEAD still gets the contest rather than falling straight to rul } test "rule 5 takes the lowest open project — the prose's own worked example" { - // v2.4.1, v2.5.0, v2.5.1 and v2.5.2 are Completed; v2.6.0 is Backlog with no - // release branch. This is the board the plan was written against, and the one - // the live workspace turned out to have. const completed = [_]Project{ proj("p-241", "v2.4.1"), proj("p-250", "v2.5.0"), @@ -615,14 +466,10 @@ test "an open project with a live release branch is vetoed, and a stale one is d .{ .branch = "release/2.5.2", .version = v("v2.5.2"), .ahead = 4 }, }; - // v2.5.2 is stabilising on a branch, and v2.4.1 sits at or below what already - // shipped — a project forgotten in the backlog long after its release went out. const keep = partitionStabilising(&open, &branches, v("v2.4.1")); try std.testing.expectEqual(@as(usize, 1), keep); try std.testing.expectEqualStrings("v2.6.0", open[0].name); - // Without a ceiling only the branch veto applies, which is what the prose says - // on its own — the stale project survives and would win rule 5. var again = [_]Project{ proj("p-241", "v2.4.1"), proj("p-260", "v2.6.0") }; try std.testing.expectEqual(@as(usize, 2), partitionStabilising(&again, &branches, null)); } @@ -643,12 +490,9 @@ test "rule 6 proposes a minor above the highest of the three baselines" { .completed_projects = &completed, .release_branches = &branches, .tags = &tags, - // Trunk is nearer than the release branch, so the contest does not fire. .ahead_of_default = 0, }); - // The release branch knows about the newest cut, so it is the baseline — and - // the proposal is a minor above it, never a patch. try std.testing.expectEqual(@as(usize, 6), outcome.needs_confirmation.candidate.minor); try std.testing.expectEqual(@as(usize, 0), outcome.needs_confirmation.candidate.patch); try std.testing.expectEqual(@as(usize, 2), outcome.needs_confirmation.baseline.patch); @@ -672,25 +516,19 @@ test "rule 6 counts to ten, where a lexical baseline would have said nine" { test "everything a rule needs can be missing, and each absence has its own answer" { const base: Facts = .{ .git_available = true, .current_branch = "main", .team = "PE" }; - // Rule 7 proper. try std.testing.expectEqual( Reason.no_signal, resolve(.{ .git_available = true, .current_branch = "main", .team = "PE", .projects_asked = true }).needs_choice.reason, ); - // Linear could not be asked, so "nothing open" is not a claim to make. try std.testing.expectEqual(Reason.linear_unavailable, resolve(base).needs_choice.reason); - // No team means no board to scope a query to. try std.testing.expectEqual( Reason.no_team, resolve(.{ .git_available = true, .current_branch = "main" }).needs_choice.reason, ); - // No repository: the release-branch veto cannot be applied, so the lowest open - // project is offered as a suggestion rather than taken as an answer. const open = [_]Project{proj("p-260", "v2.6.0")}; const blind = resolve(.{ .projects_asked = true, .team = "PE", .open_projects = &open }); try std.testing.expectEqual(Reason.no_git, blind.needs_choice.reason); try std.testing.expectEqual(@as(usize, 6), blind.needs_choice.suggestion.?.minor); - // The window was full, so the lowest of what came back is a different claim. const capped = resolve(.{ .git_available = true, .current_branch = "main", diff --git a/src/remote_cache.zig b/src/remote_cache.zig index 78a417d..7b3b45b 100644 --- a/src/remote_cache.zig +++ b/src/remote_cache.zig @@ -1,52 +1,18 @@ -//! What GitHub and Linear last said, so a dashboard redrawn a minute later does -//! not ask them again. -//! -//! The two network columns are most of what `lcc list` costs, and neither is -//! bound by how much work there is to do — both are one round trip to a host on -//! the other side of the internet, ~0.5s each before either has said anything. -//! Nothing local comes close. Yet a PR does not change state between two runs a -//! minute apart, and neither does a Linear issue: the answer that took half a -//! second is still the right answer. -//! -//! So each answer is kept with the time it arrived, and reused while it is -//! younger than `ttl_seconds`. Staleness is never hidden — `lcc list` says when -//! a column came from here, and `--refresh` skips the cache outright. -//! -//! Only successes are stored. A failed lookup is a different situation on every -//! run — `gh` not installed, a token that needs refreshing, a flaky network — -//! and remembering one would keep showing its note after the cause was fixed. -//! -//! Every failure here is silent, for the same reason `usage_cache` is: losing -//! the file costs one slow run, and nothing a cache does is worth failing the -//! command that wanted a number. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); const github = @import("github.zig"); const linear = @import("linear.zig"); -/// Bumped when the stored shape changes. An older file is dropped rather than -/// migrated: rebuilding costs one slow run. const version: u32 = 3; -/// A ceiling, so a corrupt or hostile file cannot be read into memory unbounded. const file_limit = 8 * 1024 * 1024; -/// How long an answer stands in for a fresh one. Long enough that the runs which -/// annoy — the dashboard redrawn while working through a task — are free, short -/// enough that a PR merged in another window shows up without being asked twice. pub const ttl_seconds: i64 = 300; -/// `github.PullRequest` with the state as text. The enum's numbering is an -/// implementation detail and must not end up on disk, where reordering the tags -/// would silently repaint every cached row. const StoredPr = struct { number: u32 = 0, branch: []const u8 = "", - /// What the pull request merges into. Absent from a version-2 file, which is - /// why the schema version moved: without it a cached row silently has no base - /// and the release resolver's pull-request path disappears for a whole TTL. base: []const u8 = "", state: []const u8 = "open", draft: bool = false, @@ -59,21 +25,11 @@ const StoredIssue = struct { }; const Stored = struct { - /// Main worktree path — what makes two repos two entries. root: []const u8 = "", prs_at: i64 = 0, - /// The branches the stored pull requests were *asked* about. The lookup is - /// per-branch, so — exactly as with `issues_asked` — a branch with no row - /// means GitHub was asked and had nothing, and a worktree the stored answer - /// never covered has to miss rather than read a dash out of it. prs_asked: []const []const u8 = &.{}, prs: []const StoredPr = &.{}, issues_at: i64 = 0, - /// The issue identifiers the stored statuses were *asked* about, which is not - /// the same as the ones that came back: an identifier with no row means Linear - /// was asked and had nothing, and that is an answer worth reusing. Without - /// this a new worktree would read a cached reply that never mentioned it and - /// show a dash forever. issues_asked: []const []const u8 = &.{}, issues: []const StoredIssue = &.{}, }; @@ -96,23 +52,14 @@ pub const IssueHit = struct { pub const Cache = struct { gpa: std.mem.Allocator, io: Io, - /// Where it lives, or null when lcc could not work that out. A null path is a - /// working cache that remembers nothing: every lookup misses and nothing is - /// written. path: ?[]const u8 = null, repos: std.ArrayList(Stored) = .empty, - /// Something was learned that the file does not already hold. A run that - /// changed nothing writes nothing. dirty: bool = false, - /// A cache that remembers nothing, for `--refresh` and for callers that must - /// not read the disk. pub fn none(gpa: std.mem.Allocator, io: Io) Cache { return .{ .gpa = gpa, .io = io }; } - /// The cache on disk, loaded. Never fails: an unreadable, corrupt, or - /// older-version file is the same as an empty one. pub fn open( gpa: std.mem.Allocator, io: Io, @@ -143,7 +90,6 @@ pub const Cache = struct { return null; } - /// The entry for `root`, created empty if this is the first time it is named. fn entry(self: *Cache, root: []const u8) ?*Stored { if (self.find(root)) |found| return found; const key = self.gpa.dupe(u8, root) catch return null; @@ -151,8 +97,6 @@ pub const Cache = struct { return &self.repos.items[self.repos.items.len - 1]; } - /// The pull requests from an answer still inside the TTL, provided that - /// answer covered every branch being asked about now. pub fn prs(self: *Cache, root: []const u8, asked: []const []const u8, now: i64) ?PrHit { if (self.path == null) return null; const found = self.find(root) orelse return null; @@ -206,8 +150,6 @@ pub const Cache = struct { self.dirty = true; } - /// The issue statuses from an answer still inside the TTL, provided that - /// answer covered every identifier being asked about now. pub fn issues( self: *Cache, root: []const u8, @@ -262,14 +204,6 @@ pub const Cache = struct { self.dirty = true; } - /// Writes back what was learned. A run that learned nothing writes nothing, - /// which is the common case once the file is warm. - /// - /// An entry with nothing left inside the TTL is dropped rather than carried: - /// it can never produce a hit again, and a file rewritten this often should - /// not accumulate every repo the user has ever run `lcc list` in. That does - /// mean switching between two repos more slowly than the TTL never gets a - /// hit — but those entries had expired, so there was no hit to lose. pub fn save(self: *Cache, now: i64) void { if (!self.dirty) return; const file_path = self.path orelse return; @@ -277,7 +211,6 @@ pub const Cache = struct { var keep: std.ArrayList(Stored) = .empty; for (self.repos.items) |stored| { if (!fresh(stored.prs_at, now) and !fresh(stored.issues_at, now)) continue; - // A repo that has been deleted keeps no entry, however fresh. Io.Dir.cwd().access(self.io, stored.root, .{}) catch continue; keep.append(self.gpa, stored) catch return; } @@ -295,10 +228,6 @@ pub const Cache = struct { } }; -/// Whether an answer stamped `at` is one this run may reuse. Zero means nothing -/// was ever stored, and a negative age means the clock moved backwards — which -/// makes every age here meaningless, so the entry reads as expired rather than -/// as infinitely fresh. fn fresh(at: i64, now: i64) bool { if (at == 0) return false; const age = now - at; @@ -312,17 +241,12 @@ fn contains(haystack: []const []const u8, needle: []const u8) bool { return false; } -/// Mirrors `github.parseState`, which is private to that module. Anything -/// unrecognised reads as open, the state that shows the most detail. fn parseState(raw: []const u8) github.State { if (std.ascii.eqlIgnoreCase(raw, "merged")) return .merged; if (std.ascii.eqlIgnoreCase(raw, "closed")) return .closed; return .open; } -/// `LCC_REMOTE_CACHE` overrides it. Otherwise `~/.cache/lcc`, beside the usage -/// cache and for the same reason: this file is regenerable and does not belong -/// in the config directory people commit to dotfile repos. pub fn path( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -331,7 +255,7 @@ pub fn path( const override = std.mem.trim(u8, raw, " \t"); if (override.len > 0) return gpa.dupe(u8, override); } - _ = try config.dir(gpa, environ); // Fails the same way when HOME is unset. + _ = try config.dir(gpa, environ); const home = environ.get("HOME").?; return std.fs.path.join(gpa, &.{ home, ".cache", "lcc", "remote.json" }); } @@ -354,8 +278,6 @@ test "a PR list round-trips through the file and expires with the TTL" { var environ: std.process.Environ.Map = .init(arena); try environ.put("LCC_REMOTE_CACHE", try std.fs.path.join(arena, &.{ base, "remote.json" })); - // `save` keeps an entry only while its repo is still on disk, so the root has - // to be a directory that exists. const root = base; const asked = [_][]const u8{ "feature/x", "feature/y" }; @@ -373,24 +295,17 @@ test "a PR list round-trips through the file and expires with the TTL" { const hit = reopened.prs(root, &asked, 1_060).?; try std.testing.expectEqual(@as(usize, 1), hit.list.len); try std.testing.expectEqual(@as(u32, 412), hit.list[0].number); - // The state survives as a state, not as whatever integer the enum happened to use. try std.testing.expectEqual(github.State.merged, hit.list[0].state); try std.testing.expectEqualStrings("feature/x", hit.list[0].branch); try std.testing.expectEqual(@as(i64, 60), hit.age_seconds); - // feature/y was asked about and had no pull request. That is an answer, so a - // run that only wants feature/y still hits rather than paying a round trip to - // be told the same nothing. try std.testing.expect(reopened.prs(root, &.{"feature/y"}, 1_060) != null); try std.testing.expectEqual(@as(usize, 1), reopened.prs(root, &.{"feature/y"}, 1_060).?.list.len); - // A worktree cut since the answer was stored was never covered by it. try std.testing.expect(reopened.prs(root, &.{ "feature/x", "feature/new" }, 1_060) == null); - // Past the TTL, and for a repo nobody stored. try std.testing.expect(reopened.prs(root, &asked, 1_000 + ttl_seconds + 1) == null); try std.testing.expect(reopened.prs("/other", &asked, 1_060) == null); - // A clock that jumped backwards must not read as an infinitely fresh entry. try std.testing.expect(reopened.prs(root, &asked, 900) == null); } @@ -415,9 +330,7 @@ test "saving drops entries that can never hit again, and keeps the live one" { { var cache = testCache(arena, &environ); cache.putPrs(live, &.{"b"}, 1_000, &.{}); - // Expired: nothing left inside the TTL by the time the file is written. cache.putPrs(base, &.{"b"}, 1_000 - ttl_seconds - 1, &.{}); - // Fresh, but its repo is gone from disk. cache.putPrs(deleted, &.{"b"}, 1_000, &.{}); cache.save(1_000); } @@ -442,8 +355,6 @@ test "issue statuses are reused only for an answer that covered what is being as try environ.put("LCC_REMOTE_CACHE", try std.fs.path.join(arena, &.{ base, "remote.json" })); var cache = testCache(arena, &environ); - // PE-9 was asked about and came back with nothing — a real answer, and one - // that must not be mistaken for never having been asked. cache.putPrs("/repo", &.{"b"}, 500, &.{}); cache.putIssues("/repo", &.{ "PE-7", "PE-9" }, 500, &.{ .{ .identifier = "PE-7", .state_name = "In Progress", .state_type = "started" }, @@ -453,15 +364,11 @@ test "issue statuses are reused only for an answer that covered what is being as try std.testing.expectEqual(@as(usize, 1), hit.list.len); try std.testing.expectEqualStrings("In Progress", hit.list[0].state_name); - // A subset of what was asked still hits — removing a worktree must not cost - // a round trip. try std.testing.expect(cache.issues("/repo", &.{"PE-9"}, 500) != null); try std.testing.expect(cache.issues("/repo", &.{ "PE-7", "PE-9" }, 500) != null); - // A new worktree names an issue the stored answer never covered. try std.testing.expect(cache.issues("/repo", &.{ "PE-7", "PE-11" }, 500) == null); - // The two halves expire independently: PRs are repo-wide, issues are not. try std.testing.expect(cache.prs("/repo", &.{"b"}, 500) != null); try std.testing.expect(cache.issues("/repo", &.{"PE-7"}, 500 + ttl_seconds + 1) == null); } @@ -482,7 +389,7 @@ test "a cache with nowhere to live keeps working and remembers nothing" { try std.testing.expect(cache.prs("/repo", &.{"b"}, 100) == null); try std.testing.expect(cache.issues("/repo", &.{"PE-1"}, 100) == null); try std.testing.expect(!cache.dirty); - cache.save(100); // Writes nothing, and must not fail doing it. + cache.save(100); } test "an unreadable or wrong-version file reads as an empty cache" { diff --git a/src/repos.zig b/src/repos.zig index 502b4a4..7e12a83 100644 --- a/src/repos.zig +++ b/src/repos.zig @@ -1,27 +1,9 @@ -//! Which repository an issue's code lives in. -//! -//! Nothing in Linear answers this. The team is one team, the project is a release, -//! and `branchName` is derived from the title — so an identifier names work without -//! naming a checkout. lcc used to answer it with the current directory alone, which -//! is right until you are standing somewhere else, and then it cuts a branch and -//! builds a worktree that look correct in a repo that has nothing to do with the -//! issue. -//! -//! Guessing from the issue text is worse than it sounds: the words in a title are -//! exactly the words that appear in unrelated repositories. So lcc does not guess. -//! It remembers what it was told, it looks for work that already exists, and only -//! then does it ask — once per issue, and never again. -//! -//! Kept in `~/.config/lcc/repos.json`, beside the config rather than inside it: -//! this is state lcc maintains, not something to hand-edit. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); const exec = @import("exec.zig"); const linear = @import("linear.zig"); -/// Small file, but a bound all the same. const state_limit = 4 * 1024 * 1024; pub const Pair = struct { @@ -29,17 +11,11 @@ pub const Pair = struct { repo: []const u8, }; -/// An array of pairs rather than a JSON object, so the file stays trivially -/// parseable in every direction and stable to diff. pub const State = struct { - /// Repositories lcc has resolved before, most recent first. known: []const []const u8 = &.{}, - /// Where each issue's work turned out to live. issues: []const Pair = &.{}, }; -/// `LCC_REPOS` overrides it, which is also the only way to exercise this without -/// moving `HOME` — and moving `HOME` would move the login keychain with it. pub fn path( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -52,8 +28,6 @@ pub fn path( return std.fs.path.join(gpa, &.{ dir, "repos.json" }); } -/// What lcc knows. A missing or unreadable file is an empty state — this is a -/// cache of answers, so losing it costs one question, not a run. pub fn load( gpa: std.mem.Allocator, io: Io, @@ -81,8 +55,6 @@ pub fn save( try cwd.writeFile(io, .{ .sub_path = file_path, .data = body }); } -/// The repository this issue was resolved to before, if it was. Matched on the -/// issue *ref* rather than the string, so `pe-236` and `PE-236` are one issue. pub fn recall(state: State, identifier: []const u8) ?[]const u8 { for (state.issues) |pair| { if (linear.sameIssue(pair.identifier, identifier)) return pair.repo; @@ -90,9 +62,6 @@ pub fn recall(state: State, identifier: []const u8) ?[]const u8 { return null; } -/// The answer, written down. Replaces an existing entry — an issue moved to another -/// repository is a re-answer, not a second answer — and puts `repo_root` at the head -/// of `known`, which is the order the picker offers. pub fn remember( gpa: std.mem.Allocator, state: State, @@ -119,10 +88,6 @@ pub fn remember( }; } -/// Every repository worth offering: the ones lcc knows, plus `beside`'s siblings — -/// so the very first run, with an empty state, still has something to offer instead -/// of asking for a path. `first` leads the list when it is a repository at all. -/// Deduplicated, and every entry is a main checkout that exists right now. pub fn candidates( gpa: std.mem.Allocator, io: Io, @@ -165,9 +130,6 @@ pub fn candidates( return out.toOwnedSlice(gpa); } -/// Whether `root` is the main checkout of a repository. A linked worktree has a -/// `.git` *file* pointing at the real one, so requiring a directory keeps the -/// worktrees lcc itself created out of the list of repositories. fn isMainCheckout(gpa: std.mem.Allocator, io: Io, root: []const u8) bool { const dot_git = std.fs.path.join(gpa, &.{ root, ".git" }) catch return false; var dir = Io.Dir.cwd().openDir(io, dot_git, .{}) catch return false; @@ -175,9 +137,6 @@ fn isMainCheckout(gpa: std.mem.Allocator, io: Io, root: []const u8) bool { return true; } -/// The repositories that already hold a branch for this issue — work that was -/// started before anyone wrote down where it lives. An exact answer when there is -/// one of them, and the shortlist worth offering when there are several. pub fn withIssueBranch( gpa: std.mem.Allocator, io: Io, @@ -194,7 +153,6 @@ pub fn withIssueBranch( return out.toOwnedSlice(gpa); } -/// Whether any line of `git for-each-ref` output is a branch for this issue. pub fn hasIssueBranch(refs: []const u8, identifier: []const u8) bool { var lines = std.mem.splitScalar(u8, refs, '\n'); while (lines.next()) |line| { @@ -212,10 +170,8 @@ test "recall matches on the issue, not the spelling" { } }; try std.testing.expectEqualStrings("/repo/app", recall(state, "PE-236").?); - // What a branch name carries, and what a shell might hand over. try std.testing.expectEqualStrings("/repo/app", recall(state, "pe-236").?); try std.testing.expectEqualStrings("/repo/other", recall(state, "PE-9").?); - // Not a prefix match: PE-23 is a different issue from PE-236. try std.testing.expect(recall(state, "PE-23") == null); try std.testing.expect(recall(state, "PE-999") == null); } @@ -237,12 +193,10 @@ test "remember replaces the issue's answer and leads with the repo" { const after = try remember(arena, before, "PE-236", "/repo/app"); - // One entry per issue: the correction wins, the other issue is untouched. try std.testing.expectEqual(@as(usize, 2), after.issues.len); try std.testing.expectEqualStrings("/repo/app", recall(after, "PE-236").?); try std.testing.expectEqualStrings("/repo/other", recall(after, "PE-9").?); - // Most recent first, and no duplicate for a repo that was already known. try std.testing.expectEqual(@as(usize, 2), after.known.len); try std.testing.expectEqualStrings("/repo/app", after.known[0]); try std.testing.expectEqualStrings("/repo/other", after.known[1]); @@ -255,7 +209,6 @@ test "hasIssueBranch reads for-each-ref output" { \\release/2.4.1 ; try std.testing.expect(hasIssueBranch(refs, "PE-236")); - // The issue was renamed: the ref still matches, the words no longer do. try std.testing.expect(hasIssueBranch(refs, "PE-236")); try std.testing.expect(!hasIssueBranch(refs, "PE-23")); try std.testing.expect(!hasIssueBranch(refs, "PE-9")); @@ -277,7 +230,6 @@ test "state survives a round trip through the file" { var environ: std.process.Environ.Map = .init(arena); try environ.put("HOME", base); - // Nothing on disk yet is an empty state, not a failure. try std.testing.expectEqual(@as(usize, 0), load(arena, io, &environ).known.len); const written = try remember(arena, .{}, "PE-236", "/repo/app"); @@ -287,7 +239,6 @@ test "state survives a round trip through the file" { try std.testing.expectEqualStrings("/repo/app", recall(read, "PE-236").?); try std.testing.expectEqualStrings("/repo/app", read.known[0]); - // Garbage on disk costs a question, not a run. try Io.Dir.cwd().writeFile(io, .{ .sub_path = try path(arena, &environ), .data = "{\"known\": [", @@ -309,8 +260,6 @@ test "candidates lead with the current repo and skip linked worktrees" { const base = try tmp.dir.realPathFileAlloc(io, ".", arena); const cwd = Io.Dir.cwd(); - // Two repositories side by side, plus the shape lcc's own worktrees take: a - // directory whose `.git` is a file, which must not be offered as a repository. const app = try std.fs.path.join(arena, &.{ base, "app" }); const other = try std.fs.path.join(arena, &.{ base, "other" }); const worktree = try std.fs.path.join(arena, &.{ base, "app.worktrees-pe-1" }); @@ -323,13 +272,11 @@ test "candidates lead with the current repo and skip linked worktrees" { .data = "gitdir: /elsewhere\n", }); - // An empty state still finds them, because they sit beside the anchor. const fresh = try candidates(arena, io, .{}, other, app); try std.testing.expectEqual(@as(usize, 2), fresh.len); try std.testing.expectEqualStrings(other, fresh[0]); try std.testing.expectEqualStrings(app, fresh[1]); - // A known repository that has since been deleted is not offered. const gone = try std.fs.path.join(arena, &.{ base, "deleted" }); const with_state = try candidates(arena, io, .{ .known = &.{ app, gone } }, null, null); try std.testing.expectEqual(@as(usize, 1), with_state.len); diff --git a/src/ring.zig b/src/ring.zig index fcc8524..f47d093 100644 --- a/src/ring.zig +++ b/src/ring.zig @@ -1,25 +1,7 @@ -//! A fixed byte ring with monotonic sequence numbers, so a reader that stops -//! reading loses bytes instead of costing the writer memory. -//! -//! This is the whole backpressure design for `lcc watch`. A session's output -//! goes into one ring; every attached client holds a `u64` cursor into it -//! rather than a queue of its own. So a client that has stopped reading — a -//! suspended terminal, a stalled ssh link — loses *scrollback*, never the -//! session's output, and costs the daemon neither an allocation nor a wakeup. -//! The alternative, a per-client byte queue, makes one wedged client able to -//! exhaust memory on behalf of an agent that is working perfectly. -//! -//! `written` is bytes-ever-written and never wraps: at a sustained megabyte a -//! second a `u64` takes half a million years to overflow, which is what lets a -//! cursor be a plain integer with no generation counter beside it. - const std = @import("std"); pub const Ring = struct { buf: []u8, - /// Bytes ever appended. Readers hold one of these, not a pointer, so a - /// wrap that happens between two reads is arithmetic rather than a - /// dangling slice. written: u64 = 0, pub fn init(gpa: std.mem.Allocator, capacity: usize) !Ring { @@ -32,41 +14,20 @@ pub const Ring = struct { r.* = undefined; } - /// The lowest cursor still backed by bytes. Anything below it has been - /// overwritten. pub fn oldest(r: Ring) u64 { return r.written -| r.buf.len; } - /// Never fails, never allocates, never blocks — the three properties the - /// daemon's output path depends on. Oldest bytes fall out. pub fn append(r: *Ring, bytes: []const u8) void { - // A write larger than the ring can only end as its own tail, so skip - // straight there rather than wrapping the buffer several times over. const tail = if (bytes.len > r.buf.len) bytes[bytes.len - r.buf.len ..] else bytes; - // Where the tail lands, not where the write began: a truncated append - // still ends where the full one would have, so the dropped prefix has - // to advance the position too. Using the start offset instead leaves - // the bytes rotated, and `since` then reads them out of order. const at = r.written + (bytes.len - tail.len); const start: usize = @intCast(at % r.buf.len); const first = @min(tail.len, r.buf.len - start); @memcpy(r.buf[start..][0..first], tail[0..first]); if (first < tail.len) @memcpy(r.buf[0 .. tail.len - first], tail[first..]); - // The full length, not the truncated tail: `written` counts what the - // session produced, and a reader compares its cursor against it to - // learn that it fell behind. r.written += bytes.len; } - /// What `cursor` has not seen, in order, split where the buffer wraps. - /// Both slices empty means caught up — which is what keeps `POLLOUT` off a - /// client that has nothing waiting, and with it the daemon's idle cost at - /// zero wakeups. - /// - /// A cursor below `oldest` is treated as `oldest`: the bytes are simply - /// gone. Callers that need to *tell* the client it lost some ask `clamp` - /// first. pub fn since(r: Ring, cursor: u64) [2][]const u8 { const from = @max(cursor, r.oldest()); if (from >= r.written) return .{ &.{}, &.{} }; @@ -80,9 +41,6 @@ pub const Ring = struct { }; } - /// The cursor moved forward into the ring, and whether that lost anything. - /// `skipped` is what makes a client redraw from scratch instead of - /// resuming mid-escape-sequence against a screen it can no longer derive. pub fn clamp(r: Ring, cursor: u64) struct { cursor: u64, skipped: bool } { const floor = r.oldest(); if (cursor < floor) return .{ .cursor = floor, .skipped = true }; @@ -90,8 +48,6 @@ pub const Ring = struct { } }; -/// `since` as one contiguous string, for tests. Production never needs it — -/// the daemon writes the two slices straight to a socket. fn collect(gpa: std.mem.Allocator, r: Ring, cursor: u64) ![]u8 { const parts = r.since(cursor); const out = try gpa.alloc(u8, parts[0].len + parts[1].len); @@ -109,9 +65,6 @@ test "a reader that never reads loses the oldest bytes, not the newest" { r.append("ijklmnop"); r.append("qrstuvwx"); - // Three ring-fulls in, a reader starting from zero gets the *last* eight - // bytes. Losing the newest instead would be the exact wrong failure: on - // reattach the only output anyone cares about is what just happened. const got = try collect(gpa, r, 0); defer gpa.free(got); try std.testing.expectEqualStrings("qrstuvwx", got); @@ -124,12 +77,10 @@ test "since spans the wrap in two slices, in order" { var r = try Ring.init(gpa, 8); defer r.deinit(gpa); - r.append("abcde"); // occupies 0..5 - r.append("fghij"); // wraps: fgh at 5..8, ij at 0..2 + r.append("abcde"); + r.append("fghij"); const parts = r.since(2); - // Two slices, not one: the caller writes them back to back, and getting - // the order wrong scrambles the terminal rather than failing loudly. try std.testing.expectEqualStrings("cdefgh", parts[0]); try std.testing.expectEqualStrings("ij", parts[1]); @@ -143,14 +94,11 @@ test "a caught-up cursor asks for nothing" { var r = try Ring.init(gpa, 16); defer r.deinit(gpa); - // An empty ring is caught up at zero. const fresh = r.since(0); try std.testing.expectEqual(@as(usize, 0), fresh[0].len + fresh[1].len); r.append("hello"); const done = r.since(r.written); - // Both slices empty is what the daemon reads as "do not arm POLLOUT for - // this client". A non-empty answer here is a loop that never sleeps. try std.testing.expectEqual(@as(usize, 0), done[0].len); try std.testing.expectEqual(@as(usize, 0), done[1].len); } @@ -161,19 +109,16 @@ test "clamp reports the skip exactly when bytes were lost" { defer r.deinit(gpa); r.append("abcdefgh"); - r.append("ijkl"); // written = 12, oldest = 4 + r.append("ijkl"); - // Still inside the ring: no loss, and the cursor is left alone. const kept = r.clamp(4); try std.testing.expectEqual(@as(u64, 4), kept.cursor); try std.testing.expect(!kept.skipped); - // Fell off the back: moved up to the floor, and said so. const lost = r.clamp(0); try std.testing.expectEqual(@as(u64, 4), lost.cursor); try std.testing.expect(lost.skipped); - // Caught up is not a skip. const current = r.clamp(12); try std.testing.expectEqual(@as(u64, 12), current.cursor); try std.testing.expect(!current.skipped); @@ -184,17 +129,11 @@ test "one append larger than the whole ring keeps the tail" { var r = try Ring.init(gpa, 4); defer r.deinit(gpa); - // A single 64 KiB read from a pty into a smaller ring is an ordinary event - // during a full-screen repaint, not an edge case — and copying the whole - // thing round the ring several times to arrive at the same answer would be - // wasted work on the hot path. r.append("abcdefghij"); const got = try collect(gpa, r, 0); defer gpa.free(got); try std.testing.expectEqualStrings("ghij", got); - // `written` counts what was produced, not what survived — that is how a - // client learns it fell behind. try std.testing.expectEqual(@as(u64, 10), r.written); try std.testing.expect(r.clamp(0).skipped); } @@ -204,9 +143,6 @@ test "bytes survive a wrap that lands exactly on the boundary" { var r = try Ring.init(gpa, 8); defer r.deinit(gpa); - // An append ending flush against the end of the buffer leaves `start` at - // 0 next time round, which is the off-by-one most ring implementations get - // wrong in one direction or the other. r.append("abcdefgh"); try std.testing.expectEqual(@as(usize, 0), r.since(8)[0].len); diff --git a/src/semver.zig b/src/semver.zig index a3b90e5..b7a4a4d 100644 --- a/src/semver.zig +++ b/src/semver.zig @@ -1,27 +1,8 @@ -//! Release versions as lcc reads them — out of a `release/X.Y.Z` branch, out of a -//! `vX.Y.Z` git tag, and out of a Linear project's name — and back out as `vX.Y.Z`. -//! -//! A version arrives from three different systems here, which is why this is its -//! own module rather than a corner of `git.zig` or `linear.zig`: putting it in -//! either would make the other two import a sibling sideways, and today they do -//! not know about each other at all. `fold.zig` is the precedent — a small pure -//! utility that belongs to no external system. -//! -//! Zig has no regex, so each scanner below is hand-rolled in the style of -//! `linear.refFromBranch`: pure, `?T`-returning, and answering "no" rather than -//! guessing. `std.SemanticVersion` does the arithmetic once the shape is settled. - const std = @import("std"); const Io = std.Io; pub const Version = std.SemanticVersion; -/// `v2.6.0` or `2.6.0` — the whole trimmed string and nothing else. -/// -/// Strict on purpose, in two ways `std.SemanticVersion.parse` is not. It accepts -/// pre-release and build metadata, so it reads `v2.6.0-rc1` as 2.6.0 and would let -/// a release candidate stand in for the release; both are declined here. And it is -/// anchored, so a project called `v2 rewrite, phase 1` is not a release. pub fn parse(raw: []const u8) ?Version { const trimmed = std.mem.trim(u8, raw, " \t\r\n"); const body = if (trimmed.len > 0 and (trimmed[0] == 'v' or trimmed[0] == 'V')) @@ -34,13 +15,6 @@ pub fn parse(raw: []const u8) ?Version { return parsed; } -/// The version a release branch names. `release/2.5.2`, `origin/release/2.5.2` and -/// `release/v2.5.2` all answer 2.5.2; `release/2.4` and `hotfix/release/2.4.1` do -/// not. -/// -/// Anchored rather than tolerant, unlike `linear.refFromBranch` — whose tolerance -/// is earned by issue keys genuinely turning up in the middle of a branch name. -/// A release prefix does not. pub fn fromBranch(branch: []const u8) ?Version { const short = if (std.mem.startsWith(u8, branch, "origin/")) branch["origin/".len..] @@ -62,13 +36,10 @@ pub fn max(a: Version, b: Version) Version { return if (a.order(b) == .gt) a else b; } -/// `v2.5.2 → v2.6.0`. Patch releases are cut on a `release/*` branch and never -/// originate from trunk, so trunk's next stop is always a minor. pub fn nextMinor(v: Version) Version { return .{ .major = v.major, .minor = v.minor + 1, .patch = 0 }; } -/// `v2.6.0` rendered with `{f}`, so a human line costs no allocation. pub const Display = struct { version: Version, @@ -81,7 +52,6 @@ pub fn show(v: Version) Display { return .{ .version = v }; } -/// The same thing owned, for a JSON payload that needs a string it can keep. pub fn render(gpa: std.mem.Allocator, v: Version) ![]u8 { return std.fmt.allocPrint(gpa, "v{d}.{d}.{d}", .{ v.major, v.minor, v.patch }); } @@ -105,15 +75,11 @@ test "parse takes a release version and declines everything that only looks like } const declined = [_][]const u8{ - // A release candidate is not the release. `std.SemanticVersion.parse` reads - // this as 2.6.0, which would let it stand in for one. "v2.6.0-rc1", "v2.6.0+build.7", - // Two components is not a version this resolver can order against three. "v2.6", "v2.6.0.1", "v2.06.0", - // A project name that merely starts with a v. "v2 rewrite", "version 2.6.0", "", @@ -132,21 +98,15 @@ test "fromBranch is anchored, unlike the issue-ref scanner beside it" { try std.testing.expectEqual(@as(usize, 2), fromBranch("origin/release/2.5.2").?.patch); try std.testing.expectEqual(@as(usize, 5), fromBranch("release/v2.5.2").?.minor); - // The converse of `linear.refFromBranch`'s "release/2.4.1 is not an issue". try std.testing.expect(fromBranch("feature/pe-250-thing") == null); try std.testing.expect(fromBranch("main") == null); - // Two components — a release line, not a release. try std.testing.expect(fromBranch("release/2.4") == null); - // Near misses that a substring search would have taken. try std.testing.expect(fromBranch("releases/2.5.2") == null); try std.testing.expect(fromBranch("release-2.5.2") == null); try std.testing.expect(fromBranch("hotfix/release/2.5.2") == null); } test "versions order by number, which is where a lexical sort gets it wrong" { - // The trap, spelled out: as text `v2.10.0` sorts before `v2.9.0`, so a - // string-based max answers 2.9.0 — and the next-minor rule then proposes - // v2.10.0 when the answer is v2.11.0. const ten = parse("v2.10.0").?; const nine = parse("v2.9.0").?; try std.testing.expect(std.mem.order(u8, "v2.10.0", "v2.9.0") == .lt); @@ -173,7 +133,6 @@ test "versions order by number, which is where a lexical sort gets it wrong" { test "the next release off trunk is a minor, never a patch" { try std.testing.expectEqual(@as(usize, 6), nextMinor(parse("v2.5.2").?).minor); try std.testing.expectEqual(@as(usize, 0), nextMinor(parse("v2.5.2").?).patch); - // Nine to ten, so the bump is arithmetic rather than a digit being appended. try std.testing.expectEqual(@as(usize, 10), nextMinor(parse("v2.9.9").?).minor); try std.testing.expectEqual(@as(usize, 2), nextMinor(parse("v2.9.9").?).major); } diff --git a/src/sessions.zig b/src/sessions.zig index a14c777..5585481 100644 --- a/src/sessions.zig +++ b/src/sessions.zig @@ -1,24 +1,9 @@ -//! `sessions.json` — what other commands may know about live sessions without -//! a daemon to ask. -//! -//! A **projection**, never the authority. The daemon holds the pty fds and the -//! child pids; a file can only describe them, and anything read out of it is -//! stale the moment it is read. Making it authoritative would not change that, -//! it would only hide it — and it would mean two writers, which this repo has -//! no locking for. -//! -//! It has exactly two read-only jobs: letting `lcc list` show a session column -//! without a socket round trip, and leaving a breadcrumb naming orphaned pids -//! when the daemon dies. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); const disk = @import("disk.zig"); const watch_paths = @import("watch_paths.zig"); -/// Bumped when the stored shape changes. An older file is dropped rather than -/// migrated: this is a projection, and the daemon rewrites it within a second. const version: u32 = 1; const state_limit = 4 * 1024 * 1024; @@ -27,21 +12,9 @@ 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, - /// No daemon is alive, so nothing on disk can be believed. unknown, pub fn label(self: Status) []const u8 { @@ -53,38 +26,25 @@ pub const Session = struct { id: []const u8 = "", worktree: []const u8 = "", branch: []const u8 = "", - /// Null for a worktree whose branch names no issue. issue: ?[]const u8 = null, repo_root: []const u8 = "", - /// The `claude` child, not the daemon. pid: i32 = 0, - /// Stored as TEXT, never the enum's tag number. The numbering is an - /// implementation detail, and reordering the tags would otherwise silently - /// repaint every row on disk — the reason `remote_cache.zig` gives for the - /// same decision. status: []const u8 = "unknown", status_at: i64 = 0, started_at: i64 = 0, last_activity_at: i64 = 0, exit_code: ?i32 = null, - /// Text back to the enum. An unrecognised value — a newer daemon's status - /// this build has never heard of — reads as `unknown` rather than failing - /// the whole file. pub fn parsedStatus(self: Session) Status { return std.meta.stringToEnum(Status, self.status) orelse .unknown; } }; -/// Without this block a dead daemon's file reports its sessions `active` -/// forever and every reader believes it. pub const Daemon = struct { pid: i32 = 0, started_at: i64 = 0, socket: []const u8 = "", protocol: u32 = 0, - /// When the projection was written. Everything beside it is at least this - /// stale, and a debounced writer can promise nothing better. wrote_at: i64 = 0, }; @@ -93,8 +53,6 @@ pub const State = struct { sessions: []const Session = &.{}, }; -/// Every field defaulted, so a file written by an older build still parses -/// rather than costing a command. const Wire = struct { version: u32 = 0, daemon: ?Daemon = null, @@ -110,9 +68,6 @@ pub fn path(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![] return std.fs.path.join(gpa, &.{ dir, "sessions.json" }); } -/// Infallible. A missing or unreadable file is an empty registry, because -/// losing this costs a safety check rather than a command — `repos.load`'s -/// contract, for the same reason. pub fn load(gpa: std.mem.Allocator, io: Io, environ: *const std.process.Environ.Map) State { const file_path = path(gpa, environ) catch return .{}; const raw = Io.Dir.cwd().readFileAlloc(io, file_path, gpa, .limited(state_limit)) catch return .{}; @@ -120,18 +75,10 @@ pub fn load(gpa: std.mem.Allocator, io: Io, environ: *const std.process.Environ. .ignore_unknown_fields = true, .allocate = .alloc_always, }) catch return .{}; - // Drop, never migrate. if (wire.version != version) return .{}; return .{ .daemon = wire.daemon, .sessions = wire.sessions }; } -/// Written by the daemon and nobody else. -/// -/// Temp-then-rename, which no other state file in this repo bothers with. The -/// difference is who reads it: `lcc remove` consults this to decide whether a -/// worktree has a live agent in it, and a torn read there means deleting a -/// worktree someone is working in. `repos.json` losing a write costs a -/// remembered answer; this one costs work. pub fn save( gpa: std.mem.Allocator, io: Io, @@ -149,24 +96,15 @@ pub fn save( const cwd = Io.Dir.cwd(); if (std.fs.path.dirname(file_path)) |parent| try cwd.createDirPath(io, parent); - // Same directory, so the rename cannot cross a filesystem and degrade into - // a copy that can itself be interrupted. const tmp_path = try std.fmt.allocPrint(gpa, "{s}.tmp", .{file_path}); defer gpa.free(tmp_path); try cwd.writeFile(io, .{ .sub_path = tmp_path, .data = body }); try Io.Dir.renameAbsolute(tmp_path, file_path, io); } -/// Whether the daemon that wrote this is still there. -/// -/// `PermissionDenied` counts as alive: the pid was recycled by another user's -/// process, which is not our daemon but is also not an invitation to treat the -/// file as a corpse. pub fn alive(state: State) bool { const d = state.daemon orelse return false; if (d.pid <= 0) return false; - // Signal 0 tests for existence without delivering anything. `SIG` is a - // non-exhaustive enum, so zero is representable. std.posix.kill(d.pid, @enumFromInt(0)) catch |err| return switch (err) { error.PermissionDenied => true, else => false, @@ -177,54 +115,19 @@ pub fn alive(state: State) bool { pub const Resolved = struct { session: Session, status: Status, - /// The daemon is alive but has not written recently. stale: bool, }; -/// How long a projection stays believable. Longer than the daemon's write -/// debounce, so ordinary quiet does not read as staleness. pub const stale_after_seconds: i64 = 10; -/// Whether the running daemon is an *older build* than the binary asking. -/// -/// A different question from `Resolved.stale`, which is about how recently the -/// file was written. This one is about code: a daemon outlives many rebuilds, -/// and a `zig build` replaces the file on disk without touching the image the -/// running process already exec'd. So the daemon can be hours of commits behind -/// the client talking to it, on the same path, at the same `protocol`. -/// -/// It is worth a warning because of how that failure presents. A daemon from -/// before `announceExit` reaps its children and records `exited` in this very -/// file — so every reader looks correct — while never telling an attached -/// client its session is over. The client then polls a pty that will never -/// speak again and the only way out is the detach key, with nothing on screen -/// to say why. Hours were spent looking for that bug in code that was already -/// fixed. -/// -/// `started_at` rather than a build stamp the daemon writes, because this has to -/// work on the daemons already running when it ships — one of which is what -/// prompted it. The cost is precision: a rebuild that changed nothing still -/// counts, and the answer is a hint, never a refusal. pub fn daemonOutdated(state: State, binary_modified: ?i64) bool { const built = binary_modified orelse return false; const daemon = state.daemon orelse return false; - // A registry from a build that did not record it. Silence beats a warning - // derived from a zero. if (daemon.started_at == 0) return false; - // The daemon block outlives the daemon: it is whatever the last one wrote, - // and it is still sitting there after the process is gone. Asked here rather - // than left to each caller, because the first version left it to callers and - // promptly reported an outdated daemon beside `daemon_running: false` — - // which reads as two contradictory facts rather than one absent one. if (!alive(state)) return false; return built > daemon.started_at; } -/// What a reader may believe, given who wrote the file and when. -/// -/// Readers never re-derive a status from timestamps: the daemon computed it -/// from events readers do not see, and a reader that guessed would contradict -/// the dashboard for the same session. pub fn resolved( gpa: std.mem.Allocator, io: Io, @@ -234,18 +137,13 @@ pub fn resolved( const out = try gpa.alloc(Resolved, state.sessions.len); const daemon_alive = alive(state); const wrote_at = if (state.daemon) |d| d.wrote_at else 0; - // Clamped: a clock that moved backwards must read as expired rather than - // infinitely fresh, the way `remote_cache.fresh` treats the same case. const age = @max(0, now - wrote_at); for (state.sessions, 0..) |session, i| { var status = session.parsedStatus(); if (!daemon_alive) { - // Nothing in the file can be believed, whatever it says. status = .unknown; } else if (status != .exited and !worktreeExists(io, session.worktree)) { - // Not dropped. An agent still running in a directory that no longer - // exists is the row most worth seeing. status = .orphan; } out[i] = .{ @@ -263,12 +161,6 @@ fn worktreeExists(io: Io, worktree: []const u8) bool { return info.kind == .directory; } -/// The session, if any, whose worktree is `target` or contains it. -/// -/// What `lcc remove` has to answer before deleting a directory, with no daemon -/// required. A wrong answer in either direction is expensive: a false negative -/// deletes a worktree with a live agent in it, a false positive refuses to -/// clean up an idle one. pub fn owning(gpa: std.mem.Allocator, state: State, target: []const u8) ?Session { for (state.sessions) |session| { if (std.mem.eql(u8, session.worktree, target)) return session; @@ -333,13 +225,10 @@ test "garbage on disk is an empty registry, not a failed command" { const base = try tmp.dir.realPathFileAlloc(io, ".", arena); var environ = try testEnviron(arena, base); - // `lcc remove` and `lcc list` must still work when this file is truncated - // or hand-edited. Losing it costs a column, never a command. const file_path = try path(arena, &environ); try Io.Dir.cwd().writeFile(io, .{ .sub_path = file_path, .data = "{\"sessions\": [" }); try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).sessions.len); - // And a version this build does not know is dropped, not guessed at. try Io.Dir.cwd().writeFile(io, .{ .sub_path = file_path, .data = "{\"version\":99,\"sessions\":[{\"id\":\"s-x\"}]}", @@ -364,7 +253,6 @@ test "a missing file is empty rather than an error" { const empty = load(arena, io, &environ); try testing.expectEqual(@as(usize, 0), empty.sessions.len); try testing.expect(empty.daemon == null); - // Nothing has ever run, so there is nothing to be alive. try testing.expect(!alive(empty)); } @@ -375,8 +263,6 @@ test "a dead daemon makes every status unknown, whatever the file claims" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // pid 1 is launchd: alive, and not ours — `kill` answers PermissionDenied, - // which still means alive. const live: State = .{ .daemon = .{ .pid = 1, .wrote_at = 1000 }, .sessions = &.{.{ .id = "s-a", .worktree = "/", .status = "active", .last_activity_at = 990 }}, @@ -386,8 +272,6 @@ test "a dead daemon makes every status unknown, whatever the file claims" { try testing.expectEqual(Status.active, live_rows[0].status); try testing.expect(!live_rows[0].stale); - // A pid that cannot exist. Without the daemon block this file would report - // "active" forever and every reader would believe it. const dead: State = .{ .daemon = .{ .pid = 0x7fff_fffe, .wrote_at = 1000 }, .sessions = &.{.{ .id = "s-a", .worktree = "/", .status = "active" }}, @@ -395,7 +279,6 @@ test "a dead daemon makes every status unknown, whatever the file claims" { try testing.expect(!alive(dead)); const dead_rows = try resolved(arena, io, dead, 1005); try testing.expectEqual(Status.unknown, dead_rows[0].status); - // The row survives: a session that silently vanished would read as a bug. try testing.expectEqual(@as(usize, 1), dead_rows.len); } @@ -411,45 +294,28 @@ test "staleness is reported past the window, and a backwards clock is not freshn .sessions = &.{.{ .id = "s-a", .worktree = "/", .status = "idle" }}, }; - // Inside the window: believable. try testing.expect(!(try resolved(arena, io, state, 1000 + stale_after_seconds))[0].stale); - // Past it: the only honest thing a debounced writer can say. try testing.expect((try resolved(arena, io, state, 1000 + stale_after_seconds + 1))[0].stale); - // A clock that jumped backwards must not read as infinitely fresh. try testing.expect(!(try resolved(arena, io, state, 900))[0].stale); } test "a daemon started before this binary was built is reported as the older build" { - // This process, so the liveness check has something real to find. const running: State = .{ .daemon = .{ .pid = @intCast(std.c.getpid()), .started_at = 1_000 } }; - // The case that cost hours: the daemon predates the fix in the binary now - // talking to it, and every other signal — the registry, the statuses, the - // protocol number — looks perfectly healthy. try testing.expect(daemonOutdated(running, 1_001)); - // Built before it started, which is the ordinary state of affairs. try testing.expect(!daemonOutdated(running, 999)); - // Same second: not evidence of anything, and a warning wants evidence. try testing.expect(!daemonOutdated(running, 1_000)); } test "nothing is out of date when there is no daemon to be out of date" { const pid: i32 = @intCast(std.c.getpid()); - // No daemon block at all. try testing.expect(!daemonOutdated(.{}, 5_000)); - // The block a dead daemon left behind. Every timestamp still says "older - // build", and saying so beside `daemon_running: false` gives a reader two - // facts that contradict each other instead of one that is simply absent. try testing.expect(!daemonOutdated(.{ .daemon = .{ .pid = 0, .started_at = 1 } }, 5_000)); - // A stat that failed. Inventing a warning from that would train people to - // ignore the one case it exists for. try testing.expect(!daemonOutdated(.{ .daemon = .{ .pid = pid, .started_at = 1 } }, null)); - // A registry from a build that never wrote `started_at`: the zero is absence, - // not 1970, and every binary would otherwise look newer than every daemon. try testing.expect(!daemonOutdated(.{ .daemon = .{ .pid = pid, .started_at = 0 } }, 5_000)); } @@ -469,13 +335,7 @@ test "a worktree that is gone reads as orphan, and the row stays" { .sessions = &.{ .{ .id = "s-here", .worktree = base, .status = "active" }, .{ .id = "s-gone", .worktree = try std.fs.path.join(arena, &.{ base, "removed" }), .status = "active" }, - // 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" }, }, }; @@ -488,8 +348,6 @@ test "a worktree that is gone reads as orphan, and the row stays" { } 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()); @@ -505,22 +363,15 @@ test "owning matches the worktree and what is inside it, never a sibling prefix" .{ .id = "s-256", .worktree = "/r/.lcc/worktrees/pe-256" }, } }; - // The directory itself, and anything under it. try testing.expectEqualStrings("s-256", owning(arena, state, "/r/.lcc/worktrees/pe-256").?.id); try testing.expectEqualStrings("s-256", owning(arena, state, "/r/.lcc/worktrees/pe-256/src").?.id); - // A sibling that merely shares the prefix is a different worktree. Getting - // this wrong means `lcc remove` refuses to clean up pe-2567, or — with the - // comparison the other way round — deletes pe-256 while an agent works in it. try testing.expect(owning(arena, state, "/r/.lcc/worktrees/pe-2567") == null); try testing.expect(owning(arena, state, "/r/.lcc/worktrees") == null); try testing.expect(owning(arena, state, "/elsewhere") == null); } test "an unrecognised status from a newer daemon reads as unknown, not a parse failure" { - // Forward compatibility in the direction that actually happens: the daemon - // is rebuilt first and starts writing a status this reader has never heard - // of. One odd row beats an empty registry. const session: Session = .{ .id = "s-a", .status = "thinking_very_hard" }; try testing.expectEqual(Status.unknown, session.parsedStatus()); } diff --git a/src/term.zig b/src/term.zig index 7bc1800..70d73c4 100644 --- a/src/term.zig +++ b/src/term.zig @@ -1,16 +1,3 @@ -//! Raw mode, terminal size, in-place frame redraw and key decoding — the -//! mechanics `prompt.zig` kept private until a second caller needed them. -//! -//! Split out of `prompt.zig` unchanged, for `lcc watch`: a live session table -//! redraws on a schedule rather than on a keystroke, but it needs exactly the -//! same raw mode, the same line-counting erase, and the same codepoint-safe -//! truncation. Duplicating that would mean two redraw disciplines drifting -//! apart, and only one of them ever getting a fix. -//! -//! Nothing here threads `Io`. These are ioctls and reads on a tty file -//! descriptor, which `Io` has no vtable entry for; `oauth.zig`'s `waitReadable` -//! is the same call made the same way. - const std = @import("std"); const Io = std.Io; @@ -25,39 +12,17 @@ pub const Size = struct { rows: u16, cols: u16 }; pub const Terminal = struct { fd: std.posix.fd_t, - /// What to put back on the way out. saved: std.posix.termios, - /// What was actually set. `readPending` used to rebuild its mode from - /// `saved`, which quietly turned the input translation back on for the - /// length of an escape sequence — the same flags this type exists to keep - /// off. raw: std.posix.termios, pub fn enterRaw() Error!Terminal { const fd = std.posix.STDIN_FILENO; - // Ask first: tcgetattr on a pipe reports ENOTTY through std's - // "unexpected errno" path, which dumps a stack trace in debug builds. if (isatty(fd) == 0) return Error.NotATerminal; const saved = std.posix.tcgetattr(fd) catch return Error.NotATerminal; var raw = saved; - // Char-at-a-time, no echo, and no signal generation — Ctrl-C arrives as - // a byte so the terminal can be restored before exiting. raw.lflag.ICANON = false; raw.lflag.ECHO = false; raw.lflag.ISIG = false; - // Input translation off, which "raw mode" was only pretending to be. - // - // `ICRNL` is the one that mattered and the one that hid: the line - // discipline turns the CR the terminal sends for Enter into an NL - // before anyone reads it. The pickers never noticed, because `readKey` - // treats both as `.enter`. But `lcc watch` forwards these bytes - // verbatim to another program, and Claude Code's resume picker acts on - // CR and ignores NL — so every key worked there except Enter, which is - // exactly the shape of a translation that touches one byte and no - // others. - // - // `IXON` goes too, so Ctrl-S and Ctrl-Q reach the agent instead of - // freezing the terminal on the way. raw.iflag.ICRNL = false; raw.iflag.INLCR = false; raw.iflag.IGNCR = false; @@ -65,25 +30,16 @@ pub const Terminal = struct { raw.iflag.BRKINT = false; raw.iflag.ISTRIP = false; raw.iflag.PARMRK = false; - // `OPOST` deliberately stays on. Everything lcc draws itself ends lines - // with a bare `\n` and relies on the terminal adding the carriage - // return; clearing it would stair-step every frame this program prints. raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; raw.cc[@intFromEnum(std.posix.V.TIME)] = 0; - // NOW, not FLUSH: anything typed while the issues were still loading is - // real input the user meant, and FLUSH would silently discard it. std.posix.tcsetattr(fd, .NOW, raw) catch return Error.NotATerminal; return .{ .fd = fd, .saved = saved, .raw = raw }; } pub fn restore(self: Terminal) void { - // FLUSH on the way out, so keys pressed after the answer do not spill - // into the shell that gets the terminal back. std.posix.tcsetattr(self.fd, .FLUSH, self.saved) catch {}; } - /// Reads whatever is already buffered, without blocking. Used to tell a - /// bare Esc from the start of an arrow-key sequence. pub fn readPending(self: Terminal, buf: []u8) usize { var poll_mode = self.raw; poll_mode.cc[@intFromEnum(std.posix.V.MIN)] = 0; @@ -124,14 +80,14 @@ pub fn readKey(term: Terminal, buf: []u8) Key { if (n == 0) return .cancel; const b = buf[0]; switch (b) { - 0x03, 0x04 => return .cancel, // Ctrl-C, Ctrl-D + 0x03, 0x04 => return .cancel, 0x0d, 0x0a => return .enter, 0x7f, 0x08 => return .backspace, ' ' => return .space, 0x1b => { var seq: [8]u8 = undefined; const got = term.readPending(&seq); - if (got == 0) return .cancel; // bare Esc + if (got == 0) return .cancel; if (got >= 2 and seq[0] == '[') { switch (seq[1]) { 'A' => return .up, @@ -145,7 +101,6 @@ pub fn readKey(term: Terminal, buf: []u8) Key { }, else => { if (b < 0x20) return .ignored; - // UTF-8 continuation bytes arrive in the same read burst. const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1; if (seq_len > 1) { const extra = std.posix.read(term.fd, buf[1..seq_len]) catch 0; @@ -156,44 +111,52 @@ pub fn readKey(term: Terminal, buf: []u8) Key { } } -/// Cyrillic letters, paired with the Latin letter on the same physical key. -/// -/// ЙЦУКЕН and QWERTY agree on where the keys are; only what they print -/// differs. Ukrainian and Russian differ from each other in two positions -/// (`s` and `'`), and both are listed — a layout switch is not a decision to -/// stop using the program. const layout_pairs = [_]struct { []const u8, u8 }{ - .{ "й", 'q' }, .{ "ц", 'w' }, .{ "у", 'e' }, .{ "к", 'r' }, .{ "е", 't' }, - .{ "н", 'y' }, .{ "г", 'u' }, .{ "ш", 'i' }, .{ "щ", 'o' }, .{ "з", 'p' }, - .{ "х", '[' }, .{ "ї", ']' }, .{ "ф", 'a' }, .{ "і", 's' }, .{ "ы", 's' }, - .{ "в", 'd' }, .{ "а", 'f' }, .{ "п", 'g' }, .{ "р", 'h' }, .{ "о", 'j' }, - .{ "л", 'k' }, .{ "д", 'l' }, .{ "ж", ';' }, .{ "є", '\'' }, .{ "э", '\'' }, - .{ "я", 'z' }, .{ "ч", 'x' }, .{ "с", 'c' }, .{ "м", 'v' }, .{ "и", 'b' }, - .{ "т", 'n' }, .{ "ь", 'm' }, .{ "б", ',' }, .{ "ю", '.' }, + .{ "й", 'q' }, + .{ "ц", 'w' }, + .{ "у", 'e' }, + .{ "к", 'r' }, + .{ "е", 't' }, + .{ "н", 'y' }, + .{ "г", 'u' }, + .{ "ш", 'i' }, + .{ "щ", 'o' }, + .{ "з", 'p' }, + .{ "х", '[' }, + .{ "ї", ']' }, + .{ "ф", 'a' }, + .{ "і", 's' }, + .{ "ы", 's' }, + .{ "в", 'd' }, + .{ "а", 'f' }, + .{ "п", 'g' }, + .{ "р", 'h' }, + .{ "о", 'j' }, + .{ "л", 'k' }, + .{ "д", 'l' }, + .{ "ж", ';' }, + .{ "є", '\'' }, + .{ "э", '\'' }, + .{ "я", 'z' }, + .{ "ч", 'x' }, + .{ "с", 'c' }, + .{ "м", 'v' }, + .{ "и", 'b' }, + .{ "т", 'n' }, + .{ "ь", 'm' }, + .{ "б", ',' }, + .{ "ю", '.' }, }; -/// The Latin letter at the physical key that produced `text`. -/// -/// Single-letter shortcuts are positions on a keyboard, not characters in a -/// language. Reading the character directly means every binding silently stops -/// working the moment someone switches layout — and they do not switch back to -/// press one key. `lcc remove`'s y/n was the worst of it: on a Cyrillic layout -/// there was no way to confirm at all. -/// -/// Null for anything that is not a letter position, so callers can tell a -/// shortcut from text. pub fn layoutKey(text: []const u8) ?u8 { if (text.len == 1) { const byte = text[0]; if (byte >= 'a' and byte <= 'z') return byte; if (byte >= 'A' and byte <= 'Z') return byte + 32; if (byte >= '0' and byte <= '9') return byte; - // Punctuation shared by both layouts. if (byte == '[' or byte == ']' or byte == ';' or byte == '\'' or byte == ',' or byte == '.') return byte; return null; } - // Cyrillic is two bytes; compare whole codepoints rather than bytes so an - // uppercase form is matched by its own entry rather than by arithmetic. for (layout_pairs) |pair| { if (std.mem.eql(u8, text, pair[0])) return pair[1]; } @@ -205,26 +168,19 @@ pub fn layoutKey(text: []const u8) ?u8 { return null; } -/// The uppercase form of a two-byte Cyrillic letter. -/// -/// The block is laid out so that а–я at U+0430–U+044F map to А–Я at -/// U+0410–U+042F. Only і, ї and є sit outside it and need their own pairs; -/// э is inside the range already. fn toUpperCyrillic(buf: []u8, lower: []const u8) ?[]const u8 { const cp = std.unicode.utf8Decode(lower) catch return null; const upper: u21 = switch (cp) { 0x0430...0x044F => cp - 0x20, - 0x0456 => 0x0406, // і - 0x0457 => 0x0407, // ї - 0x0454 => 0x0404, // є + 0x0456 => 0x0406, + 0x0457 => 0x0407, + 0x0454 => 0x0404, else => return null, }; const len = std.unicode.utf8Encode(upper, buf) catch return null; return buf[0..len]; } -/// Truncates on a codepoint boundary so a narrow terminal cannot wrap a row -/// and desynchronise the redraw's line count. pub fn truncate(s: []const u8, max_cols: usize) []const u8 { var cols: usize = 0; var i: usize = 0; @@ -237,12 +193,6 @@ pub fn truncate(s: []const u8, max_cols: usize) []const u8 { return s; } -/// In-place redraw of a known number of lines: hidden cursor, and an erase that -/// walks back up exactly as far as the last frame reached. -/// -/// Holds no terminal — only the writer and the count. It used to carry a -/// `Terminal` that nothing ever read, and dropping it is what lets the erase be -/// tested against a fixed buffer instead of a tty. pub const Screen = struct { out: *Io.Writer, lines: usize = 0, @@ -254,70 +204,41 @@ pub const Screen = struct { self.lines = 0; } - /// A width change invalidates the line count: rows drawn at the old width - /// may already have wrapped, so walking back up `lines` rows lands - /// somewhere in the middle of the last frame and every frame after it - /// inherits the error. One full clear, and the count starts again. pub fn reset(self: *Screen) void { self.out.writeAll(csi ++ "2J" ++ csi ++ "H") catch {}; self.lines = 0; } }; -/// Undo everything a full-screen program may have left switched on, for the -/// moment lcc hands the terminal back. -/// -/// Only `lcc watch` needs this: it passes another program's output straight -/// through, so whatever Claude Code turned on is still on when the user -/// detaches. A shell inheriting mouse reporting or the alternate screen behaves -/// strangely in ways nobody traces back to lcc — and the program that would -/// normally have cleaned up is still running, deliberately. -/// -/// The alternate screen goes first so everything after it lands on the screen -/// the user is being given back. -/// -/// The keyboard-protocol resets at the end are not speculative padding: a -/// capture of Claude Code's own startup emits `CSI ?1004h`, `CSI ?2031h`, -/// `CSI > 1 u` and `CSI > 4;2 m` within the first eighty bytes. Left set, the -/// first two spray `\x1b[I` / `\x1b[O` at the shell on every window focus -/// change, and the second two hand it a different encoding for ordinary -/// keypresses. Those are the strange-shell symptoms nobody traces back. pub fn sanitize(w: *Io.Writer) void { - w.writeAll(csi ++ "?1049l" ++ // leave the alternate screen - csi ++ "?1000l" ++ // mouse: click tracking - csi ++ "?1002l" ++ // mouse: drag tracking - csi ++ "?1003l" ++ // mouse: any-motion tracking - csi ++ "?1006l" ++ // mouse: SGR extended coordinates - csi ++ "?2004l" ++ // bracketed paste - csi ++ "?1004l" ++ // focus in/out reporting - csi ++ "?2031l" ++ // colour-scheme change notifications - csi ++ " 1 u` - csi ++ ">4;0m" ++ // modifyOtherKeys back to the default encoding - csi ++ "r" ++ // scroll region: the whole screen - csi ++ "?7h" ++ // autowrap back on - csi ++ "?25h" ++ // cursor visible - csi ++ "0m" // attributes back to default - ) catch {}; + w.writeAll(csi ++ "?1049l" ++ + csi ++ "?1000l" ++ + csi ++ "?1002l" ++ + csi ++ "?1003l" ++ + csi ++ "?1006l" ++ + csi ++ "?2004l" ++ + csi ++ "?1004l" ++ + csi ++ "?2031l" ++ + csi ++ "4;0m" ++ + csi ++ "r" ++ + csi ++ "?7h" ++ + csi ++ "?25h" ++ + csi ++ "0m") catch {}; } const testing = std.testing; test "truncate cuts on a codepoint boundary, never mid-character" { - // ASCII: the byte count and the column count agree. try testing.expectEqualStrings("abc", truncate("abcdef", 3)); try testing.expectEqualStrings("abcdef", truncate("abcdef", 99)); try testing.expectEqualStrings("", truncate("abcdef", 0)); - // Cyrillic is two bytes per codepoint, and Linear titles are full of it. - // Cutting at a byte offset would emit half a character, which a terminal - // renders as a replacement glyph of unpredictable width — and an unexpected - // width is exactly what desynchronises the redraw's line count. const cyrillic = "ВИПРАВИТИ"; const cut = truncate(cyrillic, 4); try testing.expectEqualStrings("ВИПР", cut); try testing.expect(std.unicode.utf8ValidateSlice(cut)); - // A four-byte codepoint is kept whole or dropped whole. try testing.expectEqualStrings("🚀", truncate("🚀🚀", 1)); try testing.expectEqualStrings("", truncate("🚀🚀", 0)); } @@ -327,8 +248,6 @@ test "eraseFrame walks back up exactly as far as the last frame reached" { var w: Io.Writer = .fixed(&buf); var screen: Screen = .{ .out = &w }; - // Nothing drawn yet: erasing must emit nothing at all. A stray `CSI 1A` - // here would scroll the shell's own prompt off the top. screen.eraseFrame(); try testing.expectEqual(@as(usize, 0), w.end); @@ -338,7 +257,6 @@ test "eraseFrame walks back up exactly as far as the last frame reached" { "\r" ++ csi ++ "1A" ++ csi ++ "2K" ++ csi ++ "1A" ++ csi ++ "2K" ++ csi ++ "1A" ++ csi ++ "2K", w.buffered(), ); - // The count has to be spent, or the next erase walks up twice as far. try testing.expectEqual(@as(usize, 0), screen.lines); } @@ -349,14 +267,10 @@ test "reset clears the whole screen and forgets the count" { screen.reset(); try testing.expectEqualStrings(csi ++ "2J" ++ csi ++ "H", w.buffered()); - // The point of reset is that `lines` was untrustworthy — it must not - // survive into the next frame. try testing.expectEqual(@as(usize, 0), screen.lines); } test "raw mode leaves the bytes alone, but still lets lcc's own output wrap" { - // Asserted on the struct rather than on a terminal, since a test has no - // tty. `enterRaw` builds exactly this from what tcgetattr returned. var t: std.posix.termios = undefined; t.lflag = .{ .ICANON = true, .ECHO = true, .ISIG = true }; t.iflag = .{ .ICRNL = true, .IXON = true, .BRKINT = true }; @@ -373,52 +287,34 @@ test "raw mode leaves the bytes alone, but still lets lcc's own output wrap" { t.iflag.ISTRIP = false; t.iflag.PARMRK = false; - // The whole bug: with ICRNL on, the Enter key arrives as NL and a program - // downstream that acts on CR never sees it. try testing.expect(!t.iflag.ICRNL); try testing.expect(!t.iflag.INLCR); try testing.expect(!t.iflag.IGNCR); - // Ctrl-S must reach the agent rather than freezing the terminal in front - // of it. try testing.expect(!t.iflag.IXON); - // And output translation stays: every frame lcc draws ends in a bare `\n` - // and needs the terminal to add the carriage return. try testing.expect(t.oflag.OPOST); try testing.expect(t.oflag.ONLCR); } test "a shortcut is a key position, not a character" { - // Latin, unchanged. try testing.expectEqual(@as(u8, 'q'), layoutKey("q").?); try testing.expectEqual(@as(u8, 'n'), layoutKey("n").?); - // Shift or caps lock is still the same key. try testing.expectEqual(@as(u8, 'q'), layoutKey("Q").?); - // Ukrainian ЙЦУКЕН: the key labelled `n` prints `т`. Reading the character - // is what makes every binding stop working on a layout switch — and nobody - // switches back to press one key. try testing.expectEqual(@as(u8, 'n'), layoutKey("т").?); try testing.expectEqual(@as(u8, 'q'), layoutKey("й").?); try testing.expectEqual(@as(u8, 'x'), layoutKey("ч").?); try testing.expectEqual(@as(u8, 'j'), layoutKey("о").?); try testing.expectEqual(@as(u8, 'k'), layoutKey("л").?); - // `lcc remove` asks y/n, and on a Cyrillic layout there was no way to say - // either — the confirmation for a destructive command was unreachable. try testing.expectEqual(@as(u8, 'y'), layoutKey("н").?); - // Uppercase Cyrillic is the same key too. try testing.expectEqual(@as(u8, 'n'), layoutKey("Т").?); try testing.expectEqual(@as(u8, 'y'), layoutKey("Н").?); - // Both layouts reach `s`, which they spell differently. try testing.expectEqual(@as(u8, 's'), layoutKey("і").?); try testing.expectEqual(@as(u8, 's'), layoutKey("ы").?); - // Digits are the same position in every layout. try testing.expectEqual(@as(u8, '3'), layoutKey("3").?); - // Not a letter position: the caller must be able to tell a shortcut from - // text, or typing into a search box would trigger commands. try testing.expect(layoutKey(" ") == null); try testing.expect(layoutKey("→") == null); try testing.expect(layoutKey("") == null); @@ -430,22 +326,17 @@ test "sanitize leaves the alternate screen before anything else" { sanitize(&w); const out = w.buffered(); - // Order matters once: every reset after this one has to land on the screen - // the user is getting back, not on the one being torn down. try testing.expect(std.mem.startsWith(u8, out, csi ++ "?1049l")); - // The ones that actually break a shell if they survive a detach. The last - // four were not guessed: they undo modes observed in a capture of Claude - // Code's own first eighty bytes. for ([_][]const u8{ - csi ++ "?1003l", // any-motion mouse: turns every cursor move into input - csi ++ "?2004l", // bracketed paste: wraps pasted text in escapes - csi ++ "?25h", // a shell with an invisible cursor - csi ++ "0m", // a shell rendered in Claude Code's last colour - csi ++ "?1004l", // focus reporting: \x1b[I on every window focus change - csi ++ "?2031l", // colour-scheme notifications - csi ++ "4;0m", // modifyOtherKeys: re-encodes ordinary keypresses + csi ++ "?1003l", + csi ++ "?2004l", + csi ++ "?25h", + csi ++ "0m", + csi ++ "?1004l", + csi ++ "?2031l", + csi ++ "4;0m", }) |needle| { try testing.expect(std.mem.indexOf(u8, out, needle) != null); } diff --git a/src/ui.zig b/src/ui.zig index 59a3a1d..92f3979 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1,11 +1,8 @@ -//! Terminal output — the `picocolors` replacement plus lcc's log prefixes. - const std = @import("std"); const Io = std.Io; var color_enabled: bool = true; -/// Honours `NO_COLOR` and a non-tty stdout, like picocolors does. pub fn detectColor(io: Io, environ: *const std.process.Environ.Map) void { if (environ.get("NO_COLOR")) |v| { if (v.len > 0) { @@ -24,8 +21,6 @@ pub fn colorEnabled() bool { return color_enabled; } -/// Escape sequences for code that builds its own frames (the prompts), so they -/// honour `NO_COLOR` and non-tty output like everything else. pub const Palette = struct { reset: []const u8, bold: []const u8, @@ -67,15 +62,11 @@ const Code = struct { const cyan = "\x1b[36m"; }; -/// A string plus the escape it should be wrapped in. Rendered with `{f}`, so -/// nothing is allocated and disabling colour is a single branch at write time. pub const Painted = struct { code: []const u8, text: []const u8, pub fn format(self: Painted, w: *Io.Writer) Io.Writer.Error!void { - // Empty text writes nothing at all, so callers can pass conditional - // fragments without emitting stray escape sequences. if (self.text.len == 0) return; if (!color_enabled) return w.writeAll(self.text); try w.writeAll(self.code); @@ -103,16 +94,10 @@ pub fn cyan(text: []const u8) Painted { return .{ .code = Code.cyan, .text = text }; } -/// Buffered stdout/stderr with lcc's log vocabulary. Writes are best-effort: -/// a CLI that fails because its own logging failed is worse than a lost line. pub const Ui = struct { io: Io, out: *Io.Writer, err: *Io.Writer, - /// Send the log vocabulary to stderr instead of stdout, leaving stdout for - /// `payload` alone. What `--json` turns on: a caller parsing stdout must not - /// have to filter progress lines out of it, and a human running the same - /// command still sees them. divert: bool = false, fn log(self: Ui) *Io.Writer { @@ -136,7 +121,6 @@ pub const Ui = struct { } pub fn hint(self: Ui, comptime fmt: []const u8, args: anytype) void { - // `log.dim` in the TypeScript version. const w = self.log(); if (color_enabled) w.writeAll(Code.dim) catch {}; w.print(fmt, args) catch {}; @@ -150,8 +134,6 @@ pub const Ui = struct { self.err.flush() catch {}; } - /// Machine-readable output — never coloured, never diverted, always the only - /// thing on stdout when `divert` is set. pub fn payload(self: Ui, comptime fmt: []const u8, args: anytype) void { self.out.print(fmt, args) catch {}; } @@ -162,8 +144,6 @@ pub const Ui = struct { } }; -/// Pads `text` to `width` *display* columns, counting codepoints rather than -/// bytes so non-ASCII issue titles do not shift the columns. pub const Padded = struct { text: []const u8, width: usize, @@ -179,8 +159,6 @@ pub fn pad(text: []const u8, width: usize) Padded { return .{ .text = text, .width = width }; } -/// Codepoint count — close enough to display width for the Latin/Cyrillic text -/// that shows up in Linear titles and branch names. pub fn displayWidth(text: []const u8) usize { var cols: usize = 0; var i: usize = 0; @@ -192,7 +170,6 @@ pub fn displayWidth(text: []const u8) usize { return cols; } -/// Human-readable byte count, matching `formatBytes` in derived-data.ts. pub const Bytes = struct { value: u64, @@ -217,8 +194,6 @@ pub fn bytes(value: u64) Bytes { return .{ .value = value }; } -/// Human-readable count — `812`, `3.7k`, `62.2M`. Token counts reach nine -/// digits, which no table column can carry and no reader wants to parse. pub const Count = struct { value: u64, @@ -243,14 +218,10 @@ pub fn count(value: u64) Count { return .{ .value = value }; } -/// Coarse elapsed time in one or two characters plus a unit — `2h`, `6d`, `3w`. -/// A dashboard column wants "roughly how stale", not a duration. pub const Age = struct { seconds: i64, pub fn format(self: Age, w: *Io.Writer) Io.Writer.Error!void { - // A tip committed in the future (clock skew, a rebase with an old date) - // is not worth a negative number. if (self.seconds <= 0) return w.writeAll("now"); const s: u64 = @intCast(self.seconds); const minute = 60; @@ -274,12 +245,6 @@ pub fn age(seconds: i64) Age { return .{ .seconds = seconds }; } -/// A worked length of time, in two units at most — `0m`, `12m`, `9h40m`, `2d3h`. -/// -/// Deliberately not `Age`, which rounds to one unit because "roughly how stale" -/// is all a staleness column can honestly claim. This is a duration someone is -/// meant to weigh against a working day, and there `9h` and `9h40m` are -/// different answers. pub const Duration = struct { seconds: i64, @@ -313,13 +278,10 @@ test "duration keeps the second unit that age rounds away" { const cases = [_]struct { seconds: i64, want: []const u8 }{ .{ .seconds = -1, .want = "0m" }, .{ .seconds = 0, .want = "0m" }, - // Under a minute is real time worked, but there is no unit below `m` - // worth a column — it rounds down rather than inventing one. .{ .seconds = 59, .want = "0m" }, .{ .seconds = 12 * 60, .want = "12m" }, .{ .seconds = 3600, .want = "1h" }, .{ .seconds = 9 * 3600 + 40 * 60, .want = "9h40m" }, - // Seconds never surface: the trailing 30 is dropped, not rounded up. .{ .seconds = 9 * 3600 + 40 * 60 + 30, .want = "9h40m" }, .{ .seconds = 24 * 3600, .want = "1d" }, .{ .seconds = 2 * 24 * 3600 + 3 * 3600, .want = "2d3h" }, diff --git a/src/usage.zig b/src/usage.zig index 2e6300d..9599fe4 100644 --- a/src/usage.zig +++ b/src/usage.zig @@ -1,67 +1,24 @@ -//! Token usage recorded in Claude Code transcripts. -//! -//! Every assistant message in a transcript carries the `usage` block the API -//! returned for it — input, output, and the cache-write/cache-read split — -//! alongside the model that produced it. Aggregating those per project -//! directory turns into aggregating per worktree, because Claude Code keys a -//! project directory on the cwd it was launched in (see `claude_projects.zig`). -//! -//! Counts are kept per model rather than only in total: `lcc stats` shows the -//! breakdown, and a rollup is a sum over it, so the everyday commands pay -//! nothing for the detail being there. -//! -//! Two things stop the numbers from being wrong. Messages are counted once by -//! `message.id`: a resumed session copies history forward and compaction -//! rewrites it, so the same API response shows up in more than one line and -//! more than one transcript. And lines are parsed as JSON rather than scanned -//! for `"output_tokens"`, because a message's own text can quote a usage block -//! — a transcript of a session that talked about token counts would otherwise -//! inflate itself. - const std = @import("std"); const Io = std.Io; const cp = @import("claude_projects.zig"); const uc = @import("usage_cache.zig"); const ui = @import("ui.zig"); -/// Transcripts are read whole so a line spanning a read boundary cannot be -/// half-parsed. Anything past this is reported through `skipped` rather than -/// silently undercounted. const transcript_limit = 256 * 1024 * 1024; -/// How far below a project directory transcripts are looked for. Claude Code -/// puts subagents at `/subagents/`, which is two. const max_depth = 4; -/// The gap between two messages beyond which the work is taken to have stopped. -/// Fifteen minutes clears anything one turn can spend — a long agent run, a -/// build, a wall of output being read — and falls well short of stepping away. -/// -/// The number decides what `ACTIVE` means, so it is worth being wrong in a known -/// direction: too low splits one sitting into several and undercounts, too high -/// bills lunch. This errs low, because a duration meant to be compared against a -/// working day is more useful as a floor than as a flattering estimate. pub const idle_gap_seconds: i64 = 15 * 60; -/// What a set of messages spent. The unit of both the per-model buckets and the -/// rollup over them. pub const Counts = struct { - /// Assistant messages that reported usage. messages: u64 = 0, - /// Fresh input tokens — what neither cache bucket covered. input: u64 = 0, output: u64 = 0, cache_write_5m: u64 = 0, cache_write_1h: u64 = 0, cache_read: u64 = 0, - /// List price for the tokens above, accumulated per message so a mix of - /// models bills at each one's own rate. Zero for a model the price table - /// does not know — see `Totals.unpriced`. cost_usd: f64 = 0, - /// Everything the model had to read: fresh input plus both cache buckets. - /// This is the number that grows without bound across a long session, and - /// the one worth showing when there is room for exactly one. pub fn contextTokens(self: Counts) u64 { return self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read; } @@ -85,31 +42,17 @@ pub const Counts = struct { } }; -/// One model's share of a worktree's usage. pub const Model = struct { - /// The model id as the transcript recorded it. name: []const u8, counts: Counts = .{}, }; pub const Totals = struct { counts: Counts = .{}, - /// Transcripts the numbers came from. sessions: u64 = 0, - /// The newest timestamp seen, verbatim. Claude Code writes ISO 8601 with a - /// `Z` suffix, so lexicographic order is chronological and the maximum - /// needs no parsing until something wants to render it. last: []const u8 = "", - /// Per-model buckets, in first-seen order. Sums to `counts`. models: std.ArrayList(Model) = .empty, - /// A model carried tokens but had no entry in the price table, so - /// `counts.cost_usd` is an underestimate. unpriced: bool = false, - /// When each counted message landed, in Unix seconds and no particular - /// order. Kept as the raw set rather than a running duration because active - /// time is not additive: a subagent runs *alongside* the conversation that - /// spawned it, so two transcripts that each worked ten minutes may between - /// them have used ten minutes of anyone's day. `activeSeconds` unions them. stamps: std.ArrayList(i64) = .empty, pub fn empty(self: Totals) bool { @@ -127,15 +70,6 @@ pub const Totals = struct { try self.stamps.appendSlice(gpa, other.stamps.items); } - /// Time spent, as against time elapsed. Messages closer together than - /// `idle_gap_seconds` are one stretch of work and the gap between them - /// counts — it is thinking, tool calls, and reading the answer. A longer gap - /// is a break and contributes nothing, which is what separates this from the - /// span between the first message and the last. - /// - /// Undercounts by design at both ends: the first message of a stretch is - /// credited with no time (whatever went into asking for it happened before - /// the transcript recorded anything), and a lone message counts as zero. pub fn activeSeconds(self: Totals, gpa: std.mem.Allocator) !i64 { if (self.stamps.items.len < 2) return 0; @@ -151,7 +85,6 @@ pub const Totals = struct { return total; } - /// The bucket for `name`, created if this is its first message. fn bucket(self: *Totals, gpa: std.mem.Allocator, name: []const u8) !*Counts { for (self.models.items) |*model| { if (std.mem.eql(u8, model.name, name)) return &model.counts; @@ -160,8 +93,6 @@ pub const Totals = struct { return &self.models.items[self.models.items.len - 1].counts; } - /// Model buckets ordered by spend, so a `stats` breakdown leads with what - /// the tokens actually went to. pub fn modelsBySpend(self: Totals, gpa: std.mem.Allocator) ![]const Model { const sorted = try gpa.dupe(Model, self.models.items); std.mem.sort(Model, sorted, {}, struct { @@ -173,60 +104,31 @@ pub const Totals = struct { } }; -/// Reads transcripts and keeps the cross-file state the counting depends on: -/// which message ids have been seen, and scratch memory for one transcript at a -/// time. pub const Scanner = struct { gpa: std.mem.Allocator, io: Io, - /// Message ids already counted, so a message that appears in two - /// transcripts is billed once. Keys live in `gpa` — `scratch` is reset - /// between files. seen: std.StringHashMapUnmanaged(void) = .empty, - /// Per-transcript working memory. Reset rather than freed, so peak usage - /// tracks the largest transcript instead of their sum. scratch: std.heap.ArenaAllocator, - /// Transcripts that could not be read whole. Non-zero means the totals are - /// low and the caller should say so. skipped: usize = 0, - /// What earlier runs already read. `uc.Cache.none` opts out. cache: uc.Cache, pub fn init(gpa: std.mem.Allocator, io: Io, cache: uc.Cache) Scanner { return .{ .gpa = gpa, .io = io, .scratch = .init(gpa), .cache = cache }; } - /// Teardown writes the cache back, because every caller already defers this - /// and a cache that needs a second call remembered is a cache that quietly - /// stops working the first time someone adds a `return` above it. pub fn deinit(self: *Scanner) void { self.cache.save(); self.scratch.deinit(); self.seen.deinit(self.gpa); } - /// Usage across every `.jsonl` in one Claude Code project directory. A - /// directory that cannot be opened is not an error: usage is decoration, - /// and a missing transcript should not fail the command that wanted it. pub fn project(self: *Scanner, dir_path: []const u8) !Totals { var totals: Totals = .{}; try self.scan(&totals, dir_path, 0); return totals; } - /// Subagents do not write into the conversation that spawned them. Each gets - /// its own transcript under `/subagents/`, so the top level of a - /// project directory holds only part of what the work cost — for a worktree - /// driven through a pipeline, usually well under half of it. The whole tree - /// is walked for `.jsonl`; everything else Claude Code keeps down there - /// (`tool-results/`, the per-subagent `.json` sidecars, `.md` notes) is not - /// a transcript and carries no usage. - /// - /// Only the top level counts towards `sessions`. A subagent is part of a - /// conversation, not one of its own, and counting it would inflate a column - /// that answers "how many times did I sit down with this worktree". fn scan(self: *Scanner, totals: *Totals, dir_path: []const u8, depth: u8) !void { - // Claude Code nests two deep. The cap is for a tree that is not its own. if (depth > max_depth) return; var dir = Io.Dir.cwd().openDir(self.io, dir_path, .{ .iterate = true }) catch return; @@ -240,8 +142,6 @@ pub const Scanner = struct { const path = try std.fs.path.join(self.gpa, &.{ dir_path, dirent.name }); try self.transcript(totals, path, depth == 0); }, - // Symlinks report as `.sym_link` and are left alone, so the walk - // cannot be sent round a loop. .directory => { const path = try std.fs.path.join(self.gpa, &.{ dir_path, dirent.name }); try self.scan(totals, path, depth + 1); @@ -251,7 +151,6 @@ pub const Scanner = struct { } } - /// Usage across several project directories. pub fn projectDirs(self: *Scanner, dir_paths: []const []const u8) !Totals { var totals: Totals = .{}; for (dir_paths) |path| { @@ -260,10 +159,6 @@ pub const Scanner = struct { return totals; } - /// Usage for one worktree, resolved against a listing of `~/.claude/projects`. - /// A worktree can own several project directories — one per directory Claude - /// Code was launched from — and `cp.forWorktree` matches on the cwd each - /// transcript recorded, so a session started in a subdirectory counts too. pub fn worktree( self: *Scanner, projects: []const cp.Entry, @@ -275,14 +170,9 @@ pub const Scanner = struct { return self.projectDirs(dirs); } - /// One transcript, from the cache when the file on disk is still the one it - /// was built from, and from the transcript itself otherwise. Both routes end - /// at `apply`, so a cached run and a cold run cannot drift apart. fn transcript(self: *Scanner, totals: *Totals, path: []const u8, session: bool) !void { const info = Io.Dir.cwd().statFile(self.io, path, .{}) catch return; const size = info.size; - // Nanoseconds are `i96` at the source; a value that does not fit is not - // a time this decade, and 0 simply means the entry never matches. const mtime = std.math.cast(i64, info.mtime.nanoseconds) orelse 0; const entry = self.cache.lookup(path, size, mtime) orelse blk: { @@ -304,14 +194,6 @@ pub const Scanner = struct { try self.apply(totals, entry.messages); } - /// Reads a transcript down to the messages that carried usage. Null when the - /// file could not be read at all — which is not cached, because there is - /// nothing to say about it and the next run may find it readable. - /// - /// Messages are deduplicated against this transcript only. Doing it here - /// rather than against `seen` is what keeps the result a pure function of - /// the file: a cache entry must not depend on which transcripts happened to - /// be read before it, or reusing it would depend on repeating that order. fn parse(self: *Scanner, path: []const u8) ?uc.Entry { _ = self.scratch.reset(.retain_capacity); const scratch = self.scratch.allocator(); @@ -332,8 +214,6 @@ pub const Scanner = struct { var lines = std.mem.splitScalar(u8, bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; - // A line that is not the shape we expect is a line we do not count. - // Transcripts hold several record types and gain more over time. const record = std.json.parseFromSliceLeaky( Line, scratch, @@ -342,14 +222,11 @@ pub const Scanner = struct { ) catch continue; const found = self.extract(record, &local, scratch) orelse continue; - // The messages outlive `scratch`, which is reset for the next file. out.append(self.gpa, found) catch return null; } return .{ .messages = out.toOwnedSlice(self.gpa) catch return null }; } - /// The usage a transcript line reports, or null when it reports none or - /// repeats a message already taken from this transcript. fn extract( self: *Scanner, line: Line, @@ -375,9 +252,6 @@ pub const Scanner = struct { .timestamp = self.gpa.dupe(u8, line.timestamp orelse "") catch return null, }; - // The 5m/1h split arrived after the flat total did. Without it, credit - // the whole write to the 5-minute bucket — that was the only TTL when - // transcripts recorded the total alone. if (usage.cache_creation) |split| { out.cache_write_5m = split.ephemeral_5m_input_tokens orelse 0; out.cache_write_1h = split.ephemeral_1h_input_tokens orelse 0; @@ -387,10 +261,6 @@ pub const Scanner = struct { return out; } - /// Adds one transcript's messages to a running total, dropping the ones - /// already counted from somewhere else. This is where cost is worked out, so - /// the price table is read fresh on every run rather than cached into a - /// number nobody would think to invalidate. fn apply(self: *Scanner, totals: *Totals, messages: []const uc.Message) !void { for (messages) |msg| { if (msg.id.len > 0) { @@ -415,8 +285,6 @@ pub const Scanner = struct { totals.counts.add(counts); - // A model with nothing to its name would only clutter the breakdown; - // `` messages are the usual source. if (counts.tokens() > 0) { (try totals.bucket(self.gpa, msg.model)).add(counts); } @@ -424,9 +292,6 @@ pub const Scanner = struct { if (std.mem.lessThan(u8, totals.last, msg.timestamp)) { totals.last = msg.timestamp; } - // Collected here rather than per transcript so that `activeSeconds` - // sees one worktree's messages as the single interleaved sequence - // they were, whichever conversation or subagent wrote each of them. if (epochSeconds(msg.timestamp)) |at| { try totals.stamps.append(self.gpa, at); } @@ -434,13 +299,6 @@ pub const Scanner = struct { } }; -/// Usage for a single worktree, listing `~/.claude/projects` itself. For the -/// commands that touch one worktree; a dashboard shares one listing and one -/// scanner across its rows instead. -/// -/// Every failure here is an empty result rather than an error: this number is -/// context on a command that has other work to do, and a missing or unreadable -/// transcript must not stop it. pub fn forWorktree( gpa: std.mem.Allocator, io: Io, @@ -455,12 +313,8 @@ pub fn forWorktree( return scanner.worktree(projects, worktree_path) catch .{}; } -/// One line of "what this worktree has spent so far", for the commands that open -/// or delete one. Renders nothing at all when there is no usage to report, so a -/// caller can print it unconditionally. pub const Brief = struct { totals: Totals, - /// Unix seconds, for the relative age of the last message. now: i64, pub fn format(self: Brief, w: *Io.Writer) Io.Writer.Error!void { @@ -489,8 +343,6 @@ pub fn brief(totals: Totals, now: i64) Brief { return .{ .totals = totals, .now = now }; } -/// The fields of a transcript line that bear on usage. Everything else in the -/// record is skipped by the parser. const Line = struct { type: ?[]const u8 = null, timestamp: ?[]const u8 = null, @@ -517,13 +369,9 @@ const Line = struct { }; pub const Price = struct { - /// USD per million input tokens. input: f64, - /// USD per million output tokens. output: f64, - /// Cache writes bill above the input rate — 1.25× for the 5-minute TTL, - /// 2× for the hour — and cache reads at a tenth of it. pub fn cost(self: Price, counts: Counts) f64 { const per_input = self.input / 1_000_000.0; const per_output = self.output / 1_000_000.0; @@ -535,13 +383,6 @@ pub const Price = struct { } }; -/// Anthropic list price, matched on the model-id prefix so dated snapshots -/// (`claude-haiku-4-5-20251001`) and context suffixes resolve to their family. -/// A model that is not here still has its tokens counted — only the money is -/// left out, and `Totals.unpriced` says so. -/// -/// These are published prices, not what a Claude subscription bills. Update -/// them when the pricing page moves; nothing else in lcc reads them. const prices = [_]struct { prefix: []const u8, price: Price }{ .{ .prefix = "claude-fable-5", .price = .{ .input = 10, .output = 50 } }, .{ .prefix = "claude-mythos-5", .price = .{ .input = 10, .output = 50 } }, @@ -559,8 +400,6 @@ pub fn priceFor(model: []const u8) ?Price { return null; } -/// A model id short enough for a table column: the vendor prefix and any dated -/// snapshot suffix dropped. `claude-haiku-4-5-20251001` → `haiku-4-5`. pub fn shortModel(model: []const u8) []const u8 { var name = model; if (std.mem.startsWith(u8, name, "claude-")) name = name["claude-".len..]; @@ -574,8 +413,6 @@ pub fn shortModel(model: []const u8) []const u8 { return name; } -/// `2026-07-28T15:15:29.375Z` → Unix seconds. Null for anything not of that -/// shape, which costs a relative-time column and nothing else. pub fn epochSeconds(iso: []const u8) ?i64 { if (iso.len < 19) return null; if (iso[4] != '-' or iso[7] != '-' or iso[13] != ':' or iso[16] != ':') return null; @@ -593,23 +430,17 @@ pub fn epochSeconds(iso: []const u8) ?i64 { hour * 3600 + minute * 60 + second; } -/// Days between 1970-01-01 and the given date, by Howard Hinnant's -/// `days_from_civil`. Shifting the year to start in March makes the leap day -/// the last day of the year, which is what removes the special cases. fn daysFromCivil(year: i64, month: u8, day: u8) i64 { const y = year - @as(i64, if (month <= 2) 1 else 0); const era = @divFloor(y, 400); - const year_of_era = y - era * 400; // [0, 399] - // March is 0. `month` is validated 1–12 by the caller, so the operands are - // positive and `@rem` is the truncating remainder this needs. + const year_of_era = y - era * 400; const shifted_month: i64 = @rem(@as(i64, month) + 9, 12); - const day_of_year = @divTrunc(153 * shifted_month + 2, 5) + day - 1; // [0, 365] + const day_of_year = @divTrunc(153 * shifted_month + 2, 5) + day - 1; const day_of_era = year_of_era * 365 + @divTrunc(year_of_era, 4) - @divTrunc(year_of_era, 100) + day_of_year; return era * 146097 + day_of_era - 719468; } -/// A project directory holding `data` as its only transcript, for tests. fn fixture(arena: std.mem.Allocator, io: Io, base: []const u8, data: []const u8) ![]const u8 { const dir_path = try std.fs.path.join(arena, &.{ base, "project" }); try Io.Dir.cwd().createDirPath(io, dir_path); @@ -636,8 +467,6 @@ test "counts assistant usage once per message id" { const dir_path = try std.fs.path.join(arena, &.{ base, "project" }); try cwd.createDirPath(io, dir_path); - // Two transcripts, and the second replays the first's message — what a - // resumed session does. The replay must not be counted twice. const first = \\{"type":"user","message":{"role":"user","content":"hi"}} \\{"type":"assistant","timestamp":"2026-07-28T10:00:00.000Z","message":{"id":"msg_a","model":"claude-opus-5","usage":{"input_tokens":10,"output_tokens":100,"cache_read_input_tokens":1000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":50}}}} @@ -667,12 +496,10 @@ test "counts assistant usage once per message id" { try std.testing.expectEqual(@as(u64, 120), totals.counts.output); try std.testing.expectEqual(@as(u64, 1000), totals.counts.cache_read); try std.testing.expectEqual(@as(u64, 50), totals.counts.cache_write_1h); - // The flat `cache_creation_input_tokens` with no split lands in the 5m bucket. try std.testing.expectEqual(@as(u64, 40), totals.counts.cache_write_5m); try std.testing.expect(!totals.unpriced); try std.testing.expectEqualStrings("2026-07-28T11:00:00.000Z", totals.last); - // The per-model buckets sum back to the rollup. try std.testing.expectEqual(@as(usize, 2), totals.models.items.len); var summed: Counts = .{}; for (totals.models.items) |model| summed.add(model.counts); @@ -692,9 +519,6 @@ test "a message quoting a usage block does not inflate the totals" { defer tmp.cleanup(); const base = try tmp.dir.realPathFileAlloc(io, ".", arena); - // The assistant's own text quotes usage numbers, and `content` is - // serialised before `usage`. A scan for the first `"output_tokens"` in the - // line would read 999999 instead of 7. const dir_path = try fixture(arena, io, base, \\{"type":"assistant","timestamp":"2026-07-28T10:00:00.000Z","message":{"id":"msg_a","model":"claude-opus-5","content":[{"type":"text","text":"usage was {\"input_tokens\":888888,\"output_tokens\":999999}"}],"usage":{"input_tokens":3,"output_tokens":7}}} \\ @@ -721,8 +545,6 @@ test "unpriced models keep their tokens and flag the cost" { defer tmp.cleanup(); const base = try tmp.dir.realPathFileAlloc(io, ".", arena); - // `` messages carry no tokens, so they must neither raise the - // flag nor earn a bucket; an unknown real model with tokens must do both. const dir_path = try fixture(arena, io, base, \\{"type":"assistant","message":{"id":"msg_a","model":"","usage":{"input_tokens":0,"output_tokens":0}}} \\{"type":"assistant","message":{"id":"msg_b","model":"claude-next-9","usage":{"input_tokens":100,"output_tokens":200}}} @@ -752,8 +574,6 @@ test "usage from a sidechain subagent belongs to the worktree that spawned it" { defer tmp.cleanup(); const base = try tmp.dir.realPathFileAlloc(io, ".", arena); - // A subagent's messages are tagged `isSidechain` but run against the same - // worktree and cost the same money, so they are counted like any other. const dir_path = try fixture(arena, io, base, \\{"type":"assistant","isSidechain":false,"message":{"id":"msg_a","model":"claude-opus-5","usage":{"input_tokens":1,"output_tokens":10}}} \\{"type":"assistant","isSidechain":true,"message":{"id":"msg_b","model":"claude-opus-5","usage":{"input_tokens":1,"output_tokens":40}}} @@ -781,10 +601,6 @@ test "a subagent's own transcript counts, but not as another session" { const base = try tmp.dir.realPathFileAlloc(io, ".", arena); const cwd = Io.Dir.cwd(); - // The layout Claude Code writes: the conversation at the top, each subagent - // in its own transcript below it, and non-transcript company alongside. A - // pipeline puts most of the work through subagents, so a scan that stops at - // the top level can miss more than it finds. const dir_path = try std.fs.path.join(arena, &.{ base, "project" }); const subagents = try std.fs.path.join(arena, &.{ dir_path, "sess-1", "subagents" }); const tool_results = try std.fs.path.join(arena, &.{ dir_path, "sess-1", "tool-results" }); @@ -805,7 +621,6 @@ test "a subagent's own transcript counts, but not as another session" { \\ , }); - // Neither of these is a transcript, and both sit where the walk goes. try cwd.writeFile(io, .{ .sub_path = try std.fs.path.join(arena, &.{ subagents, "agent-1.json" }), .data = "{\"usage\":{\"output_tokens\":999999}}\n", @@ -821,9 +636,7 @@ test "a subagent's own transcript counts, but not as another session" { try std.testing.expectEqual(@as(u64, 2), totals.counts.messages); try std.testing.expectEqual(@as(u64, 1000), totals.counts.output); - // One conversation, whatever it delegated. The subagent is part of it. try std.testing.expectEqual(@as(u64, 1), totals.sessions); - // The subagent's message is the newest, so it sets `last`. try std.testing.expectEqualStrings("2026-07-28T11:00:00.000Z", totals.last); } @@ -834,17 +647,12 @@ test "active time counts the gaps inside a stretch, not the breaks between them" var totals: Totals = .{}; defer totals.stamps.deinit(gpa); - // Two messages five minutes apart: one stretch, five minutes of it. for ([_]i64{ t0, t0 + 5 * 60 }) |at| try totals.stamps.append(gpa, at); try std.testing.expectEqual(@as(i64, 5 * 60), try totals.activeSeconds(gpa)); - // An hour later the work resumes. The hour is a break and is not billed; - // the ten minutes on the far side of it are. for ([_]i64{ t0 + 65 * 60, t0 + 75 * 60 }) |at| try totals.stamps.append(gpa, at); try std.testing.expectEqual(@as(i64, 15 * 60), try totals.activeSeconds(gpa)); - // Order is the sequence the messages happened in, not the one they were - // read in — two transcripts arrive interleaved and neither is sorted. var shuffled: Totals = .{}; defer shuffled.stamps.deinit(gpa); for ([_]i64{ t0 + 75 * 60, t0, t0 + 65 * 60, t0 + 5 * 60 }) |at| { @@ -852,8 +660,6 @@ test "active time counts the gaps inside a stretch, not the breaks between them" } try std.testing.expectEqual(@as(i64, 15 * 60), try shuffled.activeSeconds(gpa)); - // A gap exactly at the threshold is still one sitting: the break is the - // first gap *longer* than it. var edge: Totals = .{}; defer edge.stamps.deinit(gpa); for ([_]i64{ t0, t0 + idle_gap_seconds, t0 + 2 * idle_gap_seconds + 1 }) |at| { @@ -861,8 +667,6 @@ test "active time counts the gaps inside a stretch, not the breaks between them" } try std.testing.expectEqual(idle_gap_seconds, try edge.activeSeconds(gpa)); - // One message has no gap to measure. Zero, rather than a guess at how long - // producing it took. var single: Totals = .{}; defer single.stamps.deinit(gpa); try single.stamps.append(gpa, t0); @@ -886,9 +690,6 @@ test "a subagent's time overlaps its parent's rather than adding to it" { const subagents = try std.fs.path.join(arena, &.{ dir_path, "sess-1", "subagents" }); try cwd.createDirPath(io, subagents); - // The conversation spans 10:00–10:10 and delegates the middle of it. The - // subagent's two messages land *inside* that window, which is the whole - // point: they are the same ten minutes of someone's day, not six more. try cwd.writeFile(io, .{ .sub_path = try std.fs.path.join(arena, &.{ dir_path, "sess-1.jsonl" }), .data = @@ -910,13 +711,8 @@ test "a subagent's time overlaps its parent's rather than adding to it" { defer scanner.deinit(); const totals = try scanner.project(dir_path); - // Ten minutes, the width of the window. Summing the two transcripts - // separately would give sixteen — more time than the window holds, and the - // error grows with every subagent a pipeline runs in parallel. try std.testing.expectEqual(@as(i64, 10 * 60), try totals.activeSeconds(arena)); - // Active time can never exceed the span it happened in. Worth asserting - // rather than reasoning about: it is the invariant the union is there for. const span = epochSeconds("2026-07-28T10:10:00.000Z").? - epochSeconds("2026-07-28T10:00:00.000Z").?; try std.testing.expect(try totals.activeSeconds(arena) <= span); @@ -938,8 +734,6 @@ test "a cached run agrees with a cold one, and notices an appended transcript" { var environ: std.process.Environ.Map = .init(arena); try environ.put("LCC_USAGE_CACHE", try std.fs.path.join(arena, &.{ base, "usage.json" })); - // Two transcripts sharing a message, so the cross-file deduplication has - // something to do: it is the part a per-file cache could most easily lose. const dir_path = try std.fs.path.join(arena, &.{ base, "project" }); try cwd.createDirPath(io, dir_path); const first = try std.fs.path.join(arena, &.{ dir_path, "a.jsonl" }); @@ -966,7 +760,6 @@ test "a cached run agrees with a cold one, and notices an appended transcript" { var warm: Scanner = .init(arena, io, .open(arena, io, &environ)); const from_cache = try warm.project(dir_path); - // Nothing was learned, which is only true if every transcript was a hit. try std.testing.expect(!warm.cache.dirty); warm.deinit(); @@ -980,11 +773,9 @@ test "a cached run agrees with a cold one, and notices an appended transcript" { try from_disk.activeSeconds(arena), try from_cache.activeSeconds(arena), ); - // The shared message was counted once by both routes, not once per file. try std.testing.expectEqual(@as(u64, 2), from_cache.counts.messages); try std.testing.expectEqual(@as(u64, 120), from_cache.counts.output); - // What a live session does between two runs. try cwd.writeFile(io, .{ .sub_path = first, .data = @@ -1026,7 +817,6 @@ test "add merges per-model buckets across project directories" { try std.testing.expectEqual(@as(u64, 3), a.counts.messages); try std.testing.expectEqual(@as(u64, 155), a.counts.output); try std.testing.expectEqual(@as(u64, 3), a.sessions); - // The later timestamp wins regardless of merge order. try std.testing.expectEqualStrings("2026-07-09T00:00:00Z", a.last); try std.testing.expectEqual(@as(usize, 2), a.models.items.len); try std.testing.expectEqual(@as(u64, 150), a.models.items[0].counts.output); @@ -1038,7 +828,6 @@ test "add merges per-model buckets across project directories" { test "brief renders one line, and nothing at all when there is no usage" { const gpa = std.testing.allocator; - // 2026-07-28T15:00:00Z plus 36 minutes, so `last` reads as 36m ago. const now = epochSeconds("2026-07-28T15:36:00Z").?; const nothing = try std.fmt.allocPrint(gpa, "{f}", .{brief(.{}, now)}); @@ -1064,7 +853,6 @@ test "brief renders one line, and nothing at all when there is no usage" { line, ); - // One session is not "1 sessions", and a partial total keeps its marker. var single = spent; single.sessions = 1; single.unpriced = true; @@ -1088,8 +876,6 @@ test "brief drops the age when no message carried a timestamp" { test "price applies the cache multipliers" { const price = priceFor("claude-opus-5").?; - // 1M fresh input at $5, 1M output at $25, 1M 5m-write at 1.25×, 1M - // 1h-write at 2×, 1M read at 0.1× → 5 + 25 + 6.25 + 10 + 0.5. const got = price.cost(.{ .input = 1_000_000, .output = 1_000_000, @@ -1099,7 +885,6 @@ test "price applies the cache multipliers" { }); try std.testing.expectApproxEqAbs(@as(f64, 46.75), got, 0.0001); - // Dated snapshots and context suffixes resolve to the family. try std.testing.expectEqual(@as(f64, 1), priceFor("claude-haiku-4-5-20251001").?.input); try std.testing.expectEqual(@as(f64, 5), priceFor("claude-opus-5[1m]").?.input); try std.testing.expect(priceFor("") == null); @@ -1109,15 +894,12 @@ test "shortModel drops the vendor prefix and dated suffix" { try std.testing.expectEqualStrings("opus-5", shortModel("claude-opus-5")); try std.testing.expectEqualStrings("haiku-4-5", shortModel("claude-haiku-4-5-20251001")); try std.testing.expectEqualStrings("", shortModel("")); - // Not a date: eight digits have to be the whole suffix. try std.testing.expectEqualStrings("opus-4-8", shortModel("claude-opus-4-8")); } test "epochSeconds parses the transcript timestamp shape" { - // Cross-checked against `date -u -j -f "%Y-%m-%dT%H:%M:%SZ"`. try std.testing.expectEqual(@as(i64, 1785251729), epochSeconds("2026-07-28T15:15:29.375Z").?); try std.testing.expectEqual(@as(i64, 0), epochSeconds("1970-01-01T00:00:00.000Z").?); - // A leap day, in the year the 400-rule makes leap after the 100-rule said no. try std.testing.expectEqual(@as(i64, 951782400), epochSeconds("2000-02-29T00:00:00Z").?); try std.testing.expect(epochSeconds("") == null); try std.testing.expect(epochSeconds("not-a-timestamp-here") == null); diff --git a/src/usage_cache.zig b/src/usage_cache.zig index 5e6f812..773dbb6 100644 --- a/src/usage_cache.zig +++ b/src/usage_cache.zig @@ -1,44 +1,12 @@ -//! What the transcripts said, distilled, so a second run does not read them again. -//! -//! Counting tokens means parsing every line of every transcript, and the pile -//! only grows: transcripts are append-only and Claude Code never prunes them. -//! On a repo worked in daily that is hundreds of megabytes of JSON re-parsed to -//! redraw a table, and almost none of it changed since the last run — a session -//! in progress appends to one file, and the other two hundred are frozen. -//! -//! So each transcript is reduced to the assistant messages that carried usage, -//! and that is what is kept. It is two orders of magnitude smaller than the -//! transcript, and a file is reused whenever its size and mtime still match — -//! append-only means either one moving is enough to notice. -//! -//! What is *not* stored is anything derived. Cost is recomputed from the token -//! counts on every read, so correcting the price table takes effect at once -//! instead of being frozen into a file nobody would think to delete. And -//! deduplication is not applied here: entries are deduplicated within their own -//! transcript only, which keeps a file's entry a pure function of that file. -//! The cross-file pass belongs to the scanner, which is the only thing that -//! knows which transcripts a given question spans. -//! -//! Every failure is silent. This is a cache — losing it costs one slow run, and -//! nothing it can do is worth failing the command that wanted a number. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); -/// Bumped when the stored shape changes. An older file is dropped rather than -/// migrated: rebuilding costs one slow run. const version: u32 = 1; -/// A ceiling, so a corrupt or hostile file cannot be read into memory unbounded. const file_limit = 128 * 1024 * 1024; -/// One assistant message that reported usage — the whole of what a transcript -/// contributes. Raw token counts only; see the note on derived values above. pub const Message = struct { - /// `message.id`, or empty when the transcript recorded none. An empty id is - /// not deduplicated against anything: there is nothing to match it on, and - /// treating them all as one message would lose real spend. id: []const u8 = "", model: []const u8 = "", input: u64 = 0, @@ -46,28 +14,16 @@ pub const Message = struct { cache_write_5m: u64 = 0, cache_write_1h: u64 = 0, cache_read: u64 = 0, - /// Verbatim ISO 8601, the form `Totals.last` compares and renders. timestamp: []const u8 = "", }; -/// One transcript's distilled contents, plus what identifies the version of it -/// that produced them. pub const Entry = struct { size: u64 = 0, - /// Modification time in nanoseconds. Together with `size` this is the whole - /// test: for a file only ever appended to, either one moving means new - /// content, and a rewrite that lands on the same size within the same - /// nanosecond is not a thing that happens. mtime: i64 = 0, - /// The transcript was past the scanner's ceiling and never read. Worth - /// remembering — otherwise every run pays to rediscover that it cannot read it. skipped: bool = false, messages: []const Message = &.{}, }; -/// An entry as stored, which is `Entry` plus the path it belongs to. The file -/// is a flat list rather than an object so it round-trips through a plain -/// struct with no dynamic keys. const Stored = struct { path: []const u8 = "", size: u64 = 0, @@ -84,26 +40,15 @@ const Wire = struct { pub const Cache = struct { gpa: std.mem.Allocator, io: Io, - /// Where it lives, or null when lcc could not work that out. A null path is - /// a working cache that remembers nothing: every lookup misses, nothing is - /// written, and the scan simply pays full price. path: ?[]const u8 = null, entries: std.StringHashMapUnmanaged(Entry) = .empty, - /// Paths this run looked at. An entry nobody asked about is still kept — - /// another repository's transcripts must survive a run that never mentions - /// them — but only the ones asked about are known to still exist. used: std.StringHashMapUnmanaged(void) = .empty, - /// Something was learned that the file does not already hold. A run that - /// changed nothing writes nothing. dirty: bool = false, - /// A cache that remembers nothing, for callers that must not touch the disk. pub fn none(gpa: std.mem.Allocator, io: Io) Cache { return .{ .gpa = gpa, .io = io }; } - /// The cache on disk, loaded. Never fails: an unreadable, corrupt, or - /// older-version file is the same as an empty one. pub fn open( gpa: std.mem.Allocator, io: Io, @@ -132,8 +77,6 @@ pub const Cache = struct { return self; } - /// What is known about `file_path`, if what is known is about the file that - /// is there now. pub fn lookup(self: *Cache, file_path: []const u8, size: u64, mtime: i64) ?Entry { const entry = self.entries.get(file_path) orelse return null; if (entry.size != size or entry.mtime != mtime) return null; @@ -153,8 +96,6 @@ pub const Cache = struct { self.used.put(self.gpa, file_path, {}) catch {}; } - /// Writes back what was learned. A run that learned nothing writes nothing, - /// which is the common case and keeps a repeat run free of disk writes. pub fn save(self: *Cache) void { if (!self.dirty) return; const file_path = self.path orelse return; @@ -162,10 +103,6 @@ pub const Cache = struct { var files: std.ArrayList(Stored) = .empty; var it = self.entries.iterator(); while (it.next()) |kv| { - // A transcript nobody asked about this run is kept only while it is - // still on disk — otherwise a deleted worktree's sessions would sit - // in here forever, and `lcc clean` would have reclaimed the space - // for nothing. if (self.used.get(kv.key_ptr.*) == null) { Io.Dir.cwd().access(self.io, kv.key_ptr.*, .{}) catch continue; } @@ -191,10 +128,6 @@ pub const Cache = struct { } }; -/// `LCC_USAGE_CACHE` overrides it. Otherwise `~/.cache/lcc`, not the -/// `~/.config/lcc` the rest of lcc's state uses: this file is regenerable, -/// rewritten constantly, and the size of the transcripts behind it. Config -/// directories get committed to dotfile repos, and this does not belong in one. pub fn path( gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, @@ -203,7 +136,7 @@ pub fn path( const override = std.mem.trim(u8, raw, " \t"); if (override.len > 0) return gpa.dupe(u8, override); } - _ = try config.dir(gpa, environ); // Fails the same way when HOME is unset. + _ = try config.dir(gpa, environ); const home = environ.get("HOME").?; return std.fs.path.join(gpa, &.{ home, ".cache", "lcc", "usage.json" }); } @@ -244,8 +177,6 @@ test "a cache round-trips through the file and notices a changed transcript" { try std.testing.expectEqualStrings("msg_a", hit.messages[0].id); try std.testing.expectEqual(@as(u64, 7), hit.messages[0].output); - // Either half of the fingerprint moving is a miss — an appended transcript - // grows, and a rewritten one changes mtime. try std.testing.expect(reopened.lookup(transcript, 4, 100) == null); try std.testing.expect(reopened.lookup(transcript, 3, 101) == null); try std.testing.expect(reopened.lookup("/nowhere.jsonl", 3, 100) == null); @@ -267,8 +198,6 @@ test "saving drops entries whose transcript is gone, and keeps the untouched" { var environ: std.process.Environ.Map = .init(arena); try environ.put("LCC_USAGE_CACHE", try std.fs.path.join(arena, &.{ base, "usage.json" })); - // `alive` belongs to another repository this run never asks about; `dead` - // was reclaimed by `lcc clean` since the run that recorded it. const alive = try std.fs.path.join(arena, &.{ base, "alive.jsonl" }); const dead = try std.fs.path.join(arena, &.{ base, "dead.jsonl" }); try cwd.writeFile(io, .{ .sub_path = alive, .data = "{}\n" }); @@ -281,11 +210,10 @@ test "saving drops entries whose transcript is gone, and keeps the untouched" { cache.save(); } - // A fresh run that touches neither still keeps the one that exists. { var cache: Cache = .open(arena, io, &environ); try std.testing.expectEqual(@as(u32, 2), cache.entries.size); - cache.dirty = true; // Stand in for a run that learned something else. + cache.dirty = true; cache.save(); } @@ -302,8 +230,6 @@ test "a cache with nowhere to live is a cache that remembers nothing" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // No HOME and no override: `path` cannot be resolved, and nothing may touch - // the disk on the way to finding that out. var environ: std.process.Environ.Map = .init(arena); var cache: Cache = .open(arena, io, &environ); try std.testing.expect(cache.path == null); @@ -339,7 +265,6 @@ test "a file from another version is ignored rather than misread" { const stale: Cache = .open(arena, io, &environ); try std.testing.expectEqual(@as(u32, 0), stale.entries.size); - // And so is a file that is not the shape at all. try Io.Dir.cwd().writeFile(io, .{ .sub_path = file_path, .data = "not json" }); const broken: Cache = .open(arena, io, &environ); try std.testing.expectEqual(@as(u32, 0), broken.entries.size); diff --git a/src/watch_attach.zig b/src/watch_attach.zig index 932cb35..e274eae 100644 --- a/src/watch_attach.zig +++ b/src/watch_attach.zig @@ -1,37 +1,3 @@ -//! Raw passthrough of one session's bytes, and handing the terminal back the -//! way it was found. -//! -//! This is the one place lcc writes to stdout without going through `app.ui`, -//! and it has to be: the bytes belong to Claude Code, not to lcc, and framing -//! them through a logging vocabulary would corrupt them. It is also the one -//! place that reads a file descriptor without threading `Io` — `prompt.zig` -//! already reads stdin with `std.posix.read`, and 0.16 offers no readiness -//! primitive that would let a single task wait on stdin and a socket together. -//! Blocking on one while the other has bytes is precisely what this loop exists -//! to avoid. -//! -//! Everything that is not the pump — connecting, the registry, the snapshot — -//! still goes through `Io` and through `app.ui`. -//! -//! **lcc draws nothing while attached, and that is a conclusion rather than an -//! omission.** A status bar lived here: the child was told the terminal was one -//! row shorter, a scroll region fenced it out of the last line, and lcc painted -//! the sessions and `^\` there — the way tmux does it. Against Claude Code it -//! did not hold. It renders with relative cursor moves many times a second, so -//! every paint has to borrow the cursor and give it back exactly; it emits a -//! bare `CSI r` that reclaims the region; and it shares the terminal's single -//! saved-cursor slot, so a DECSC of its own spanning two bursts collides with -//! ours. Painting on a timer, only after output, and unconditionally -//! reasserting the region were each tried, and each still disturbed its -//! rendering. Doing it correctly needs a model of where the cursor is, which is -//! a terminal emulator — more than a status bar is worth. -//! -//! So the way back out is documented instead of displayed: the dashboard's own -//! footer names `^\`, and README says it. If this is revisited, the honest -//! shapes are a real emulator over the ring, or terminal chrome that occupies -//! no cell (OSC 2) — not another schedule for writing into someone else's -//! screen. - const std = @import("std"); const Io = std.Io; const ansi = @import("ansi.zig"); @@ -40,44 +6,17 @@ const term = @import("term.zig"); const watch_client = @import("watch_client.zig"); const wire = @import("wire.zig"); -/// Ctrl-\ . -/// -/// `ISIG` is off in raw mode, so it arrives as a plain byte rather than -/// SIGQUIT, and Claude Code binds nothing to it. Ctrl-C is deliberately not -/// used: it has to keep reaching the agent, which is the first thing anyone -/// tries when a turn goes wrong. pub const detach_key: u8 = 0x1c; pub const Outcome = enum { - /// The user asked to come back. detached, - /// The child exited while attached. ended, - /// The daemon went away underneath us. daemon_gone, }; -/// `\` — the key itself, once a terminal has stopped sending it as a byte. const backslash = '\\'; -/// The control code Ctrl-\ produces, which some terminals report in place of -/// the key when asked for a modified-key sequence. const backslash_ctrl_code = 0x1c; -/// Where the detach key falls in a read, if it is there at all. -/// -/// Three encodings, because Claude Code turns on two protocols that change how -/// the terminal reports a modified key, and both of those bytes pass straight -/// through lcc to the real terminal: -/// -/// 0x1c the legacy control code -/// CSI 92 ; u kitty keyboard protocol — `CSI > 1 u` -/// CSI 27 ; ; N ~ xterm modifyOtherKeys=2 — `CSI > 4 ; 2 m` -/// -/// Scanning for the byte alone was enough against `/bin/cat`, which enables -/// neither, and would have silently failed against the program this exists for -/// — leaving someone inside a session with no way back at all. -/// -/// Pure, so the one thing that can strand a user is testable without a terminal. pub fn detachAt(bytes: []const u8) ?usize { var i: usize = 0; while (i < bytes.len) : (i += 1) { @@ -89,11 +28,6 @@ pub fn detachAt(bytes: []const u8) ?usize { return null; } -/// Whether a CSI body (everything after `ESC [`) is a modified backslash. -/// -/// Only the ctrl bit is required: a terminal that folds shift or meta into the -/// same report should still detach rather than send the sequence to the agent, -/// which would do nothing with it either way. fn csiDetach(body: []const u8) bool { var params: [4]u32 = .{ 0, 0, 0, 0 }; var count: usize = 0; @@ -118,8 +52,6 @@ fn csiDetach(body: []const u8) bool { if (!seen_digit and count < 2) return false; return matches(byte, params[0..@min(count, params.len)]); }, - // Any other byte either ends the sequence as something else, or is - // not part of one at all. Either way it is not a detach. else => return false, } } @@ -128,13 +60,10 @@ fn csiDetach(body: []const u8) bool { fn matches(final: u8, params: []const u32) bool { if (params.len < 2) return false; - // Modifiers are reported as 1 + a bitmask, and ctrl is bit 2 (value 4). const ctrl = ((params[1] -| 1) & 4) != 0; if (!ctrl) return false; return switch (final) { - // kitty: the keysym comes first. 'u' => params[0] == backslash or params[0] == backslash_ctrl_code, - // modifyOtherKeys: `27 ; mods ; keysym ~`. '~' => params[0] == 27 and params.len >= 3 and (params[2] == backslash or params[2] == backslash_ctrl_code), else => false, @@ -143,18 +72,9 @@ fn matches(final: u8, params: []const u32) bool { pub const Options = struct { session_id: []const u8, - /// Replay the scrollback so the screen is repainted on arrival, rather than - /// staying blank until the agent next prints. replay: bool = true, }; -/// Tees every byte in both directions to a file, for diagnosing a terminal that -/// is not the one lcc was developed against. -/// -/// Keys are encoded by the *terminal*, and Claude Code turns on two protocols -/// that change that encoding. A pty in a test harness implements neither, so a -/// key that misbehaves under Ghostty or iTerm cannot be reproduced by reasoning -/// about it — only by reading what actually arrived. fn openDump(app: app_mod.App) ?Io.File { const path = app.environ.get("LCC_WATCH_DUMP") orelse return null; if (path.len == 0) return null; @@ -167,8 +87,6 @@ fn note(dump: ?Io.File, io: Io, comptime label: []const u8, bytes: []const u8) v const head = std.fmt.bufPrint(&buf, "\n[{s} {d}] ", .{ label, bytes.len }) catch return; var w = file.writer(io, &.{}); w.interface.writeAll(head) catch {}; - // Hex, because the interesting bytes are escapes and control codes that a - // text dump would either hide or mangle. for (bytes) |byte| { const pair = std.fmt.bufPrint(&buf, "{x:0>2} ", .{byte}) catch continue; w.interface.writeAll(pair) catch {}; @@ -184,9 +102,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { var conn = (try watch_client.connectExisting(app, .attach)) orelse return .daemon_gone; - // Ordered so the terminal is always handed back intact, whatever happens: - // restore the modes lcc set, then undo the ones the *child* set — the - // program that would normally clean those up is still running, on purpose. defer { var exit_buf: [512]u8 = undefined; var out_writer: Io.File.Writer = .init(.stdout(), app.io, &exit_buf); @@ -202,8 +117,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { try conn.sendControl(app.gpa, .attach, wire.Attach{ .session_id = opts.session_id, .cols = size.cols, - // The whole terminal: lcc keeps no row of its own. The module header - // says why the screen belongs entirely to the child. .rows = size.rows, .replay = opts.replay, }); @@ -212,8 +125,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { var in_buf: [4096]u8 = undefined; while (true) { - // Re-queried every iteration rather than driven by SIGWINCH, matching - // `prompt.zig`. No handler to install, and no signal to miss. const now_size = terminal.size(); if (now_size.rows != size.rows or now_size.cols != size.cols) { size = now_size; @@ -227,8 +138,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { .{ .fd = terminal.fd, .events = std.posix.POLL.IN, .revents = 0 }, .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 }, }; - // A short timeout only so a resize is noticed promptly; nothing else - // depends on it firing. _ = std.posix.poll(&fds, 200) catch continue; if (fds[0].revents & std.posix.POLL.IN != 0) { @@ -237,8 +146,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { const chunk = in_buf[0..n]; note(dump, app.io, "key", chunk); if (detachAt(chunk)) |at| { - // Anything typed before the key is still meant for the agent, - // and dropping it would silently lose a keystroke. if (at > 0) conn.send(app.gpa, .input, chunk[0..at]) catch {}; return .detached; } @@ -252,14 +159,6 @@ pub fn run(app: app_mod.App, opts: Options) !Outcome { conn.dec.commit(@intCast(n)); while (conn.dec.next() catch return .daemon_gone) |frame| switch (frame.type) { - // Straight to the terminal, unexamined. These bytes are Claude - // Code's own rendering and anything that reinterpreted them - // would corrupt the screen. - // Scrollback, not live output. The child's terminal setup is - // in there — replaying `CSI > 1 u` pushes a second level onto - // the keyboard stack, after which the child and the terminal - // disagree about how keys are encoded and its pickers stop - // seeing Enter while the mouse keeps working. .replay => { const kept = replay_filter.filter(frame.payload, &filtered); note(dump, app.io, "replay", kept); @@ -286,9 +185,6 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) void { sent += @intCast(n); continue; } - // `Io.Threaded` installs a SIGPIPE handler, so a closed stdout is an - // error return rather than a killed process — but it carries no - // SA_RESTART, so EINTR is ours to retry. if (n < 0 and std.posix.errno(n) == .INTR) continue; return; } @@ -297,25 +193,18 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) void { const testing = std.testing; test "the detach key is found in every encoding a terminal may send it as" { - // The legacy byte, which is all `/bin/cat` ever produces. try testing.expectEqual(@as(usize, 0), detachAt(&.{detach_key}).?); - // kitty keyboard protocol, which Claude Code turns on with `CSI > 1 u`. try testing.expectEqual(@as(usize, 0), detachAt("\x1b[92;5u").?); try testing.expectEqual(@as(usize, 4), detachAt("abcd\x1b[92;5u").?); - // Some report the control code rather than the key. try testing.expect(detachAt("\x1b[28;5u") != null); - // Shift or meta folded in alongside ctrl still counts. try testing.expect(detachAt("\x1b[92;7u") != null); - // xterm modifyOtherKeys=2, which it turns on with `CSI > 4 ; 2 m`. try testing.expect(detachAt("\x1b[27;5;92~") != null); try testing.expect(detachAt("\x1b[27;5;28~") != null); - // Without ctrl it is a plain backslash and belongs to the agent. try testing.expect(detachAt("\x1b[92;1u") == null); try testing.expect(detachAt("\x1b[27;1;92~") == null); - // A different key with ctrl is not a detach either. try testing.expect(detachAt("\x1b[99;5u") == null); try testing.expect(detachAt("\x1b[27;5;99~") == null); } @@ -326,17 +215,12 @@ test "ordinary output and other escapes are not mistaken for it" { try testing.expectEqual(@as(usize, 5), detachAt("hello\x1c").?); try testing.expect(detachAt("") == null); - // Arrow keys, function keys and a bare Esc all reach the agent untouched. for ([_][]const u8{ "\x1b[A", "\x1b[B", "\x1b", "\x1b[1;5C", "\x1b[200~pasted\x1b[201~" }) |seq| { try testing.expect(detachAt(seq) == null); } } test "a UTF-8 continuation byte can never be mistaken for the detach key" { - // 0x1c is below 0x80, and every byte of a multi-byte UTF-8 sequence is at - // or above it. So typing a non-ASCII character can never detach — worth - // asserting rather than reasoning about once, since the failure would be a - // session that drops out from under someone mid-word. const cyrillic = "Привіт"; try testing.expect(detachAt(cyrillic) == null); for (cyrillic) |byte| try testing.expect(byte != detach_key); @@ -346,8 +230,6 @@ test "a UTF-8 continuation byte can never be mistaken for the detach key" { } test "Ctrl-C is not the detach key" { - // It has to keep reaching the agent: interrupting a turn is the first thing - // anyone reaches for, and a detach there would look like a crash. try testing.expect(detachAt(&.{0x03}) == null); try testing.expect(detach_key != 0x03); } diff --git a/src/watch_client.zig b/src/watch_client.zig index 339551b..1d6a875 100644 --- a/src/watch_client.zig +++ b/src/watch_client.zig @@ -1,11 +1,3 @@ -//! The client half: find the daemon, start one when there is none, and speak -//! the protocol. -//! -//! The client never touches the daemon's lock. Its only probe is `connect`, -//! with a short backoff, and a wasted daemon spawn costs a few milliseconds. -//! Probing the lock instead would introduce two races — a client holding it -//! while a daemon starts, and the handoff between them — to save nothing. - const std = @import("std"); const Io = std.Io; const app_mod = @import("app.zig"); @@ -15,16 +7,11 @@ const watch_paths = @import("watch_paths.zig"); const wire = @import("wire.zig"); pub const Error = error{ - /// Nothing is listening, and starting one did not help. DaemonUnreachable, - /// Nothing is listening and we were told not to start one. NotRunning, ProtocolMismatch, Refused, BadResponse, - // A daemon that framed something wrong is not a case any caller can act on - // differently from a daemon that answered nothing, but the distinction is - // worth keeping in the message rather than collapsing at the boundary. } || wire.Error || watch_paths.Error || std.mem.Allocator.Error; pub const Conn = struct { @@ -52,8 +39,6 @@ pub const Conn = struct { try self.send(gpa, t, body); } - /// Blocking. Only ever used on the one-shot request/response paths — the - /// interactive loops poll and decode for themselves. pub fn recv(self: *Conn) !wire.Frame { while (true) { if (try self.dec.next()) |frame| return frame; @@ -83,31 +68,11 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void { } } -/// Connect to a daemon that is already running, or null. -/// -/// Asked before connecting rather than after failing: std's -/// `UnixAddress.ConnectError` has no `ConnectionRefused`, so a stale socket -/// falls through to `error.Unexpected`, which prints a stack trace in a debug -/// build. `prompt.zig` documents the same shape for `tcgetattr` on a pipe — -/// ask first, do not make std report it. pub fn connectExisting(app: app_mod.App, role: wire.Role) Error!?Conn { return connectAt(app, role, try watch_paths.socket(app.gpa, app.environ)); } -/// The same, against a socket the caller names rather than one derived from the -/// environment. -/// -/// Exists for the hook handler, which is told its socket on the command line. -/// That path is the daemon's own, resolved when the daemon started, and it is -/// the only thing a hook can trust: a hook runs with the *session's* -/// environment, which is the environment of whichever shell started the -/// session, and an `LCC_WATCH_DIR` in there would point the report at a daemon -/// that does not exist. Same reasoning as the absolute `exe` beside it in -/// `watch_hooks.settingsJson`. pub fn connectAt(app: app_mod.App, role: wire.Role, socket_path: []const u8) Error!?Conn { - // std will not do this, and the path came off a command line rather than - // out of `watch_paths.socket` — so this is the last place it can be an - // error instead of a write past `sockaddr_un.path`. try watch_paths.checkSocketPath(socket_path); const info = Io.Dir.cwd().statFile(app.io, socket_path, .{}) catch return null; _ = info; @@ -134,10 +99,6 @@ pub fn connectAt(app: app_mod.App, role: wire.Role, socket_path: []const u8) Err return conn; } -/// Connect, starting a daemon if nothing answers. -/// -/// Several `lcc start --watch` invocations may each spawn one; the lock inside -/// the daemon means all but one exit immediately. pub fn connect(app: app_mod.App, role: wire.Role) Error!Conn { if (try connectExisting(app, role)) |conn| return conn; @@ -148,9 +109,6 @@ pub fn connect(app: app_mod.App, role: wire.Role) Error!Conn { } exec.detached(app.io, &.{ exe, "daemon" }, log_path) catch return Error.DaemonUnreachable; - // 50, 100, 200, 400, 800 ms. A daemon that has not answered by then is not - // starting, and looping silently would hide a lock held by a daemon whose - // socket someone deleted. var delay_ms: u64 = 50; for (0..5) |_| { std.Io.Timestamp.now(app.io, .awake).addDuration(.fromMilliseconds(@intCast(delay_ms))) @@ -178,8 +136,6 @@ pub const Handoff = struct { size: struct { rows: u16, cols: u16 } = .{ .rows = 40, .cols = 120 }, }; -/// The whole `--watch` branch of `lcc start`, so `start.zig` gains one import -/// and one `if`. pub fn startSession(app: app_mod.App, handoff: Handoff) Error!Started { var conn = try connect(app, .control); defer conn.close(app.io); @@ -191,9 +147,6 @@ pub fn startSession(app: app_mod.App, handoff: Handoff) Error!Started { .repo_root = handoff.repo_root, .program = handoff.program, .argv = handoff.argv, - // The client's environment, never the daemon's: a daemon started days - // ago by another shell would otherwise hand this session that shell's - // PATH and every variable Claude Code reads. .env = try environSlice(app), .cols = handoff.size.cols, .rows = handoff.size.rows, @@ -215,10 +168,6 @@ pub fn startSession(app: app_mod.App, handoff: Handoff) Error!Started { }; } -/// A snapshot from the daemon, or null when none is running. -/// -/// Null rather than an error: no daemon is a legitimate answer — it means no -/// sessions — and every caller has to render that anyway. pub fn snapshot(app: app_mod.App) Error!?[]const sessions_mod.Session { var conn = (try connectExisting(app, .control)) orelse return null; defer conn.close(app.io); @@ -230,15 +179,6 @@ pub fn snapshot(app: app_mod.App) Error!?[]const sessions_mod.Session { return body.sessions; } -/// The hook path: one connect, one frame, exit. -/// -/// Never starts a daemon. A hook fires on every turn of every session lcc -/// launched, and one that could spawn a process would turn a stopped daemon -/// into a spawn storm. -/// -/// `socket` is the path the daemon baked into the hook's command line. Null -/// falls back to the environment, which is what a hand-run `lcc watch-hook` -/// with no `--socket` gets. pub fn report( app: app_mod.App, socket: ?[]const u8, diff --git a/src/watch_hooks.zig b/src/watch_hooks.zig index 8ea093e..7836d29 100644 --- a/src/watch_hooks.zig +++ b/src/watch_hooks.zig @@ -1,57 +1,10 @@ -//! How a session tells lcc what it is doing — Claude Code's own hooks, not a -//! reading of its screen. -//! -//! The first design here scraped the pty: strip ANSI, match a spinner glyph and -//! "esc to interrupt", guess. That needed a pattern table, fixtures pinned to a -//! release, and a drift detector to notice when a new Claude Code silently -//! invalidated all of it. None of it is necessary. Claude Code reports its own -//! state through hooks, exactly and by contract: -//! -//! Notification/permission_prompt → blocked on a decision → waiting -//! Notification/agent_needs_input → blocked on a decision → waiting -//! UserPromptSubmit, PreToolUse → a turn is in flight → active -//! SubagentStart → so is work it handed -//! to a subagent → active -//! Stop → the turn finished → idle -//! SessionEnd → the session is over → exited -//! -//! **The matcher does the discrimination, not lcc.** Each entry hard-codes the -//! state it means into its own command line, so nothing here has to parse a -//! 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 `, 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 -//! exist only for sessions lcc launched. - const std = @import("std"); const Io = std.Io; -/// What a hook reports. Deliberately smaller than the set of hook events: these -/// are the transitions a dashboard can act on, and nothing else is worth a -/// process spawn on every turn. pub const Event = enum { - /// Blocked on a human decision. The one that means "go here now". waiting, - /// A turn is in flight. active, - /// The turn finished. Not blocked — come back whenever. idle, - /// The session ended on its own. ended, pub fn parse(text: []const u8) ?Event { @@ -59,48 +12,20 @@ pub const Event = enum { } }; -/// `type` and `async` are Claude Code's field names, not ours. const Command = struct { type: []const u8 = "command", command: []const u8, - /// Seconds. Short on purpose: a wedged handler must not become a wedged - /// session, and everything this does is one connect and one write. timeout: u32 = 5, - /// Never block a turn on lcc's bookkeeping. A status that arrives late is - /// a cosmetic problem; a turn that waits for it is not. - @"async": bool = true, + async: bool = true, }; const Entry = struct { - /// Empty string, never null, for "every notification of this event". - /// - /// Measured, not assumed: a `"matcher": null` parses as valid JSON and is - /// accepted without complaint, and then the whole entry is silently - /// dropped — Claude Code registers no hook and reports no error. An empty - /// string registers. Nothing in lcc's own schema test could catch that, - /// because the schema was lcc's. matcher: []const u8 = "", hooks: []const Command, }; const Events = struct { Notification: []const Entry, - /// Claude Code fires this for every agent a workflow or a `Task` spawns, - /// and its matcher is the agent type — so an empty one is all of them. - /// - /// It is here because of what a session looks like without it. The main - /// agent hands work to subagents and its own turn ends, so `Stop` fires and - /// the row reads `idle` — "finished, come back whenever" — while a dynamic - /// workflow grinds away underneath it for twenty minutes. The one thing the - /// dashboard exists to answer is "where is the work", and `idle` was the - /// wrong answer to it. - /// - /// `active`, not `waiting`, and the distinction is the whole point of the - /// colour. `waiting` has to keep meaning "a person is blocking this one"; - /// a session whose subagents are busy is blocking on nobody, and painting - /// it the same yellow would put two states that call for opposite - /// responses under one signal. The same reasoning keeps `idle_prompt` out - /// of `blocking_matchers` below. SubagentStart: []const Entry, UserPromptSubmit: []const Entry, PreToolUse: []const Entry, @@ -110,13 +35,6 @@ const Events = struct { const Settings = struct { hooks: Events }; -/// The notification matchers that mean a human is actually blocking the -/// session, as opposed to being merely informed. -/// -/// `idle_prompt` is deliberately absent: it is a nudge after a quiet spell, not -/// a question, and mapping it to `waiting` would light up every finished -/// session as if it needed attention — which is exactly the signal this feature -/// exists to make trustworthy. pub const blocking_matchers = [_][]const u8{ "permission_prompt", "elicitation_dialog", @@ -124,20 +42,11 @@ pub const blocking_matchers = [_][]const u8{ }; fn command(gpa: std.mem.Allocator, exe: []const u8, socket: []const u8, event: Event) ![]const u8 { - // Absolute paths on both: a hook runs with the session's environment, and - // the daemon may have been started from a shell whose PATH no longer - // resembles this one. return std.fmt.allocPrint(gpa, "{s} watch-hook --socket {s} --event {s}", .{ exe, socket, @tagName(event), }); } -/// The settings file handed to `claude --settings`. -/// -/// `exe` must be the resolved binary, never `argv[0]`: `lcc` on PATH is a -/// symlink into `zig-out/bin`, and a hook that re-resolved it would run -/// whichever build the symlink happens to point at rather than the one that -/// started the daemon. pub fn settingsJson( gpa: std.mem.Allocator, exe: []const u8, @@ -166,32 +75,16 @@ pub fn settingsJson( } }, .{ .whitespace = .indent_2 }); } -/// The fields lcc reads out of the JSON a hook receives on stdin. -/// -/// `cwd` is the whole point: it is the worktree, which is the key the daemon -/// already has a session under. Everything else is defaulted, so a Claude Code -/// that adds or drops a field cannot turn a status update into a failure. pub const Payload = struct { cwd: []const u8 = "", 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); } @@ -203,7 +96,6 @@ pub fn parsePayload(gpa: std.mem.Allocator, raw: []const u8) ?Payload { }) catch null; } -/// What the handler sends on to the daemon. pub const Report = struct { cwd: []const u8, session_id: []const u8, @@ -221,48 +113,41 @@ test "the settings name every event and bake the state into each command" { const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/home/me/.config/lcc/daemon.sock"); - // Declared independently, and unknown fields rejected, so a rename here - // fails in this test rather than as a session that silently never reports. const Schema = struct { hooks: struct { Notification: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, SubagentStart: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, UserPromptSubmit: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, PreToolUse: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, Stop: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, SessionEnd: []struct { matcher: []const u8, - hooks: []struct { type: []const u8, command: []const u8, timeout: u32, @"async": bool }, + hooks: []struct { type: []const u8, command: []const u8, timeout: u32, async: bool }, }, }, }; const parsed = try std.json.parseFromSliceLeaky(Schema, arena, body, .{}); - // One entry per blocking matcher, each carrying `--event waiting`. try testing.expectEqual(blocking_matchers.len, parsed.hooks.Notification.len); for (parsed.hooks.Notification, blocking_matchers) |entry, matcher| { try testing.expectEqualStrings(matcher, entry.matcher); try testing.expect(std.mem.endsWith(u8, entry.hooks[0].command, "--event waiting")); } - // A subagent starting is the session working, so `active` — never - // `waiting`, which has to go on meaning that a person is blocking it. An - // empty matcher, so it counts for every agent type rather than for - // whichever ones were named the day this was written. try testing.expectEqual(@as(usize, 1), parsed.hooks.SubagentStart.len); try testing.expectEqualStrings("", parsed.hooks.SubagentStart[0].matcher); try testing.expect(std.mem.endsWith(u8, parsed.hooks.SubagentStart[0].hooks[0].command, "--event active")); @@ -272,28 +157,18 @@ test "the settings name every event and bake the state into each command" { try testing.expect(std.mem.endsWith(u8, parsed.hooks.PreToolUse[0].hooks[0].command, "--event active")); try testing.expect(std.mem.endsWith(u8, parsed.hooks.SessionEnd[0].hooks[0].command, "--event ended")); - // An unmatched entry carries an empty matcher, not null: null is accepted - // as JSON and then silently registers nothing. Asserted on the wire rather - // than trusted from the struct, since the struct is what got it wrong. try testing.expect(std.mem.indexOf(u8, body, "\"matcher\": null") == null); try testing.expectEqualStrings("", parsed.hooks.Stop[0].matcher); - // Never block a turn, and never wait long. A hook that stalls a session is - // worse than a status that never arrives. - try testing.expect(parsed.hooks.Stop[0].hooks[0].@"async"); + try testing.expect(parsed.hooks.Stop[0].hooks[0].async); try testing.expect(parsed.hooks.Stop[0].hooks[0].timeout <= 10); try testing.expectEqualStrings("command", parsed.hooks.Stop[0].hooks[0].type); - // The resolved binary, not a bare name a PATH lookup could resolve - // differently inside a session. try testing.expect(std.mem.startsWith(u8, parsed.hooks.Stop[0].hooks[0].command, "/opt/homebrew/bin/lcc ")); try testing.expect(std.mem.indexOf(u8, parsed.hooks.Stop[0].hooks[0].command, "--socket /home/me/.config/lcc/daemon.sock") != null); } test "idle_prompt is not a blocking matcher" { - // The distinction the whole dashboard rests on. `waiting` has to mean "this - // one needs you now"; if a quiet-spell nudge produced it, every finished - // session would claim attention and the colour would stop meaning anything. for (blocking_matchers) |matcher| { try testing.expect(!std.mem.eql(u8, matcher, "idle_prompt")); } @@ -306,11 +181,6 @@ 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":"669f68ae","transcript_path":"/h/.claude/projects/x/t.jsonl", \\ "cwd":"/r/.lcc/worktrees/pe-256","prompt_id":"551136fb", @@ -320,29 +190,18 @@ test "a hook payload yields the worktree, and a broken one yields nothing" { \\ "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("669f68ae", payload.session_id); try testing.expectEqualStrings("PreToolUse", 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. try testing.expect(parsePayload(arena, "not json at all") == null); try testing.expect(parsePayload(arena, "") == null); - // A payload missing everything still parses: every field is defaulted, so a - // 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)); } @@ -353,12 +212,6 @@ test "the events that report no mode really report none" { 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", @@ -372,14 +225,9 @@ test "the events that report no mode really report none" { 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("")); } @@ -390,10 +238,6 @@ test "plan mode needs no hook of its own" { 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); @@ -403,8 +247,6 @@ test "plan mode needs no hook of its own" { } test "event names round-trip through the command line" { - // The handler receives `--event ` and has to map it back. A rename on - // one side only would leave every hook reporting nothing. for ([_]Event{ .waiting, .active, .idle, .ended }) |event| { try testing.expectEqual(event, Event.parse(@tagName(event)).?); } diff --git a/src/watch_paths.zig b/src/watch_paths.zig index 98d9337..18b6a95 100644 --- a/src/watch_paths.zig +++ b/src/watch_paths.zig @@ -1,27 +1,9 @@ -//! Where the session daemon's files live — and the 103 bytes macOS really -//! gives the first of them. -//! -//! One override, `LCC_WATCH_DIR`, moves the socket, the lock and the hook -//! settings together. Tests need all three isolated at once, and the -//! alternative — moving `HOME` — moves the login Keychain with it, which is -//! where the Linear token lives. - const std = @import("std"); const Io = std.Io; const config = @import("config.zig"); pub const Error = error{SocketPathTooLong} || std.mem.Allocator.Error || error{NoHomeDirectory}; -/// Darwin's `sockaddr_un.path` is `[104]u8` (`std/c.zig`), and the path needs a -/// NUL inside it. -/// -/// std will not enforce this. `Io.net.UnixAddress.max_len` is 108 — Linux's -/// number, applied to every non-Windows target — and `init` only rejects past -/// that. `Threaded.addressUnixToPosix` then takes `path_len = a.path.len` -/// *unclamped* and `@memcpy`s into the 104-byte field. So a path of 105 to 108 -/// bytes passes every check std makes and writes off the end of the struct: -/// a bounds panic in Debug, a silent stack write in the ReleaseFast build lcc -/// ships. Reachable in practice with a long `$HOME` or a deep `LCC_WATCH_DIR`. pub const sun_path_max = 104; pub const socket_path_max = sun_path_max - 1; @@ -29,8 +11,6 @@ pub fn checkSocketPath(path: []const u8) error{SocketPathTooLong}!void { if (path.len > socket_path_max) return error.SocketPathTooLong; } -/// `LCC_WATCH_DIR`, else `~/.config/lcc`. Config rather than cache: the pids in -/// here are the only record of what was running if the daemon dies. pub fn dir(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"); @@ -39,9 +19,6 @@ pub fn dir(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]c return config.dir(gpa, environ); } -/// The unix socket clients connect to. Checked against `socket_path_max` here, -/// because this is the last place that can turn it into an error rather than a -/// memory write. pub fn socket(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) Error![]const u8 { const base = try dir(gpa, environ); const path = try std.fs.path.join(gpa, &.{ base, "daemon.sock" }); @@ -49,46 +26,30 @@ pub fn socket(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) E return path; } -/// Held exclusively by the daemon for its whole life. Both the single-instance -/// guard and the liveness probe: if the lock can be taken, the daemon that -/// owned the socket beside it is gone and the socket is stale. pub fn lock(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 { const base = try dir(gpa, environ); return std.fs.path.join(gpa, &.{ base, "daemon.lock" }); } -/// The settings file passed to `claude --settings`, carrying nothing but lcc's -/// hooks. Written once at daemon start — its contents do not vary per session. pub fn hooks(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 { const base = try dir(gpa, environ); return std.fs.path.join(gpa, &.{ base, "hooks.json" }); } -/// Where a detached daemon's stdout and stderr go. Cache rather than config: -/// a log is regenerable, and `usage.json` and `remote.json` set the precedent. -/// Without it a daemon that dies during startup is invisible — no terminal, no -/// message, and a client that can only report that nothing answered. 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"); if (override.len > 0) return std.fs.path.join(gpa, &.{ override, "daemon.log" }); } - // Fails the same way when HOME is unset, before the unwrap below. _ = try config.dir(gpa, environ); const home = environ.get("HOME").?; return std.fs.path.join(gpa, &.{ home, ".cache", "lcc", "daemon.log" }); } test "a socket path past Darwin's sun_path is refused here, because std will not" { - // 103 is the last length that leaves room for the NUL. const ok = "a" ** socket_path_max; try checkSocketPath(ok); - // 104 fits the field but not the terminator; 108 is what - // `Io.net.UnixAddress.init` still accepts. Everything in between reaches an - // unclamped @memcpy into a 104-byte array inside std — a stack write, not - // an error return. If this ever stops failing, re-read - // `Threaded.addressUnixToPosix` before assuming std started clamping. try std.testing.expectError(error.SocketPathTooLong, checkSocketPath("a" ** (socket_path_max + 1))); try std.testing.expectError(error.SocketPathTooLong, checkSocketPath("a" ** 108)); } @@ -102,13 +63,9 @@ test "LCC_WATCH_DIR moves the socket, the lock and the hooks together" { var environ: std.process.Environ.Map = .init(arena); try environ.put("LCC_WATCH_DIR", "/tmp/lcc-test"); - // One variable, every runtime file — so a test can isolate the daemon - // without moving HOME, which would move the login Keychain with it. try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.sock", try socket(arena, &environ)); try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.lock", try lock(arena, &environ)); try std.testing.expectEqualStrings("/tmp/lcc-test/hooks.json", try hooks(arena, &environ)); - // The log follows the override too, rather than escaping to the real cache - // directory and leaving a test's output in the user's home. try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.log", try logFile(arena, &environ)); } @@ -118,14 +75,11 @@ test "an empty override is not an override" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // `LCC_WATCH_DIR=` from a shell that exports it unset must fall through to - // the real location, not resolve to "/daemon.sock". `repos.path`'s rule. var environ: std.process.Environ.Map = .init(arena); try environ.put("HOME", "/home/someone"); try environ.put("LCC_WATCH_DIR", ""); try std.testing.expectEqualStrings("/home/someone/.config/lcc/daemon.sock", try socket(arena, &environ)); - // And whitespace is not a path either. try environ.put("LCC_WATCH_DIR", " "); try std.testing.expectEqualStrings("/home/someone/.config/lcc/daemon.sock", try socket(arena, &environ)); } @@ -139,8 +93,6 @@ test "the log lands in the cache directory, apart from the state" { var environ: std.process.Environ.Map = .init(arena); try environ.put("HOME", "/home/someone"); - // A log is regenerable and can be deleted at any time; the socket and lock - // are not. `usage_cache` and `remote_cache` already draw this line. try std.testing.expectEqualStrings("/home/someone/.cache/lcc/daemon.log", try logFile(arena, &environ)); try std.testing.expectEqualStrings("/home/someone/.config/lcc/daemon.sock", try socket(arena, &environ)); } @@ -151,8 +103,6 @@ test "a missing HOME is an error, not a path relative to nowhere" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // Without this the log would be built from an unwrapped null, and the - // socket would resolve somewhere unpredictable rather than refusing. var environ: std.process.Environ.Map = .init(arena); try std.testing.expectError(error.NoHomeDirectory, socket(arena, &environ)); try std.testing.expectError(error.NoHomeDirectory, logFile(arena, &environ)); diff --git a/src/watch_session.zig b/src/watch_session.zig index 03fbd43..9160365 100644 --- a/src/watch_session.zig +++ b/src/watch_session.zig @@ -1,10 +1,3 @@ -//! One watched session: its pty, its child, its scrollback and its status. -//! -//! Clients are not here. The daemon owns those, because a client on a control -//! connection belongs to no session and one that switches sessions belongs to -//! two for an instant; keeping them in one list leaves a single place where a -//! socket is closed and forgotten. - const std = @import("std"); const Io = std.Io; const pty = @import("pty.zig"); @@ -13,12 +6,6 @@ const sessions = @import("sessions.zig"); const watch_hooks = @import("watch_hooks.zig"); const watch_status = @import("watch_status.zig"); -/// Keystrokes the pty could not take yet. -/// -/// Bounded, and overflow is counted rather than queued. Darwin's tty input -/// queue is about a kilobyte, so a paste into a busy agent reaches this in -/// ordinary use — and a queue that grew instead would let one child that -/// stopped reading consume the daemon's memory. const input_capacity = 8 * 1024; pub const Session = struct { @@ -30,8 +17,6 @@ pub const Session = struct { pid: std.posix.pid_t, master: std.posix.fd_t, - /// Closed once the child is gone, so the fd is not held for the five - /// minutes the row lingers in the registry. master_open: bool = true, scrollback: ring.Ring, @@ -40,14 +25,7 @@ pub const Session = struct { status: sessions.Status = .starting, status_at: i64, started_at: i64, - /// When the last hook event arrived — what the time-based decay measures. last_event_at: i64, - /// Claude Code's plan mode, as last reported. - /// - /// Held rather than read per event, because half the events lcc registers - /// carry no `permission_mode` at all: without this a `Notification` would - /// look like leaving plan mode, when a permission prompt is the one moment - /// the mode certainly has not changed. plan: bool = false, exit: ?pty.Exit = null, @@ -55,9 +33,6 @@ pub const Session = struct { input_len: usize = 0, input_dropped: u32 = 0, - /// A registry-visible field changed since the last write. Only this, never - /// output, schedules a flush — otherwise every chunk of pty output would - /// rewrite the file. dirty: bool = true, pub const Meta = struct { @@ -105,24 +80,10 @@ pub const Session = struct { self.master_open = false; } - /// The status a reader is shown: the lifecycle status and plan mode - /// together. - /// - /// Everything that leaves the daemon goes through this. `status` on its own - /// is the lifecycle — what the transition function and the decay operate on - /// — and reporting it raw is what would leave a row saying `active` for a - /// session that has not been allowed near a file yet. pub fn shown(self: Session) sessions.Status { return watch_status.present(self.status, self.plan); } - /// Both setters key `status_at` and `dirty` off `shown`, not off the field - /// they write. Two things fall out of that. The old "the same status again - /// is not a change" guard still holds — writing an unchanged field cannot - /// change what it composes to. And a change no reader can see costs no - /// write: an `active` session in plan mode decaying to `idle` still reads - /// `plan`, and rewriting the registry for it would be a file write per - /// session per quarter hour to record nothing. fn setStatus(self: *Session, status: sessions.Status, now: i64) void { const before = self.shown(); self.status = status; @@ -139,10 +100,6 @@ pub const Session = struct { self.dirty = true; } - /// Drain whatever the pty has. Returns true when the session is over. - /// - /// A read of 0 or EIO is the primary death signal: Darwin reports a hung-up - /// master as EIO rather than EOF, and both mean the slave is gone. pub fn onReadable(self: *Session, now: i64) bool { if (!self.master_open) return true; var buf: [64 * 1024]u8 = undefined; @@ -150,8 +107,6 @@ pub const Session = struct { switch (pty.read(self.master, &buf)) { .n => |n| { self.scrollback.append(buf[0..n]); - // First byte out of a child means the exec worked. Status - // beyond that is the hooks' business, not the screen's. if (self.status == .starting) self.setStatus(.idle, now); }, .again => return false, @@ -163,8 +118,6 @@ pub const Session = struct { } } - /// Push queued keystrokes at the pty. Called only while `input_len > 0`, - /// because `POLLOUT` on the master is armed only then. pub fn onWritable(self: *Session) void { while (self.input_len > 0) { switch (pty.write(self.master, self.input[0..self.input_len])) { @@ -182,8 +135,6 @@ pub const Session = struct { } } - /// Counted, never queued past the cap. A dropped keystroke is visible in - /// the registry; unbounded growth would not be until it mattered. pub fn queueInput(self: *Session, bytes: []const u8) void { const room = input_capacity - self.input_len; const take = @min(room, bytes.len); @@ -199,12 +150,6 @@ pub const Session = struct { return self.master_open and self.input_len > 0; } - /// A hook event. Returns true when the registry needs rewriting. - /// - /// An empty `permission_mode` leaves plan mode alone rather than clearing - /// it — most of the events lcc registers carry no mode, and treating their - /// silence as "no longer planning" would flip the row back on the first - /// `Notification` of every plan. pub fn note(self: *Session, event: watch_hooks.Event, permission_mode: []const u8, now: i64) bool { const before = self.shown(); self.last_event_at = now; @@ -213,15 +158,12 @@ pub const Session = struct { return before != self.shown(); } - /// Time-based decay, run on the coarse tick. Separate from `note` so a - /// status only ages when nothing has been reported. pub fn tick(self: *Session, now: i64) bool { const before = self.shown(); self.setStatus(watch_status.resolve(self.status, self.last_event_at, now), now); return before != self.shown(); } - /// `waitpid(WNOHANG)`. Returns true once the child has been reaped. pub fn reap(self: *Session, now: i64) bool { if (self.exit != null) return true; const status = pty.reap(self.pid) orelse return false; @@ -231,11 +173,6 @@ pub const Session = struct { return true; } - /// `SIGTERM` to the child's whole process group, or `SIGKILL` when forced. - /// - /// The group, not the pid: `login_tty` made the child a group leader, so - /// this reaches the shells and builds Claude Code started rather than - /// orphaning them still holding the pty open. pub fn stop(self: *Session, force: bool) void { pty.signalGroup(self.pid, if (force) .KILL else .TERM); } @@ -244,13 +181,10 @@ pub const Session = struct { const e = self.exit orelse return null; return switch (e) { .code => |c| @intCast(c), - // Negated, so a caller can tell "exited 9" from "killed by 9" - // without a second field. .signal => |s| -@as(i32, @intCast(s)), }; } - /// The projection written to `sessions.json`. pub fn entry(self: Session) sessions.Session { return .{ .id = self.id, @@ -268,13 +202,6 @@ pub const Session = struct { } }; -/// The size a pty should take, given every attached client's terminal. -/// -/// The componentwise minimum, which is tmux's rule: anything larger would let -/// one client's rows fall off the bottom of another's window. `null` when -/// nobody is attached — the caller keeps the last size rather than applying -/// one, because a 0x0 winsize makes Ink render nothing at all and a session -/// left that way looks hung. pub fn negotiateSize(client_sizes: []const pty.Size) ?pty.Size { if (client_sizes.len == 0) return null; var out = client_sizes[0]; @@ -286,12 +213,6 @@ pub fn negotiateSize(client_sizes: []const pty.Size) ?pty.Size { return out; } -/// The two sizes that make a full-screen program repaint: one row short, then -/// the real one. -/// -/// Rows rather than columns. A column change makes a terminal rewrap every line -/// it is holding, so poking with one would reflow the whole scrollback to -/// produce a redraw that a row change gets for free. pub fn pokeSizes(current: pty.Size) [2]pty.Size { const shrunk: pty.Size = .{ .rows = if (current.rows > 1) current.rows - 1 else 1, .cols = current.cols }; return .{ shrunk, current }; @@ -300,8 +221,6 @@ pub fn pokeSizes(current: pty.Size) [2]pty.Size { const testing = std.testing; test "two attachers get the smaller terminal, componentwise" { - // tmux's rule. Taking the larger would push rows off the bottom of the - // smaller client's window with no way for it to scroll back to them. const got = negotiateSize(&.{ .{ .rows = 40, .cols = 200 }, .{ .rows = 60, .cols = 120 }, @@ -311,11 +230,7 @@ test "two attachers get the smaller terminal, componentwise" { } test "the last size survives the last detach" { - // A 0x0 winsize makes Ink render nothing, and a session left that way is - // indistinguishable from a hung one. So detaching resizes nothing. try testing.expect(negotiateSize(&.{}) == null); - // A client reporting a degenerate size is refused for the same reason, - // rather than propagated to the pty. try testing.expect(negotiateSize(&.{.{ .rows = 0, .cols = 80 }}) == null); try testing.expect(negotiateSize(&.{.{ .rows = 24, .cols = 0 }}) == null); } @@ -324,18 +239,13 @@ test "the repaint poke moves rows, never columns" { const poke = pokeSizes(.{ .rows = 40, .cols = 120 }); try testing.expectEqual(@as(u16, 39), poke[0].rows); try testing.expectEqual(@as(u16, 40), poke[1].rows); - // Columns held constant on both: changing them rewraps every line the - // terminal is holding, to get a redraw a row change produces for free. try testing.expectEqual(@as(u16, 120), poke[0].cols); try testing.expectEqual(@as(u16, 120), poke[1].cols); - // A one-row terminal cannot shrink below one, or the poke itself becomes - // the 0-row winsize it exists to avoid. const tiny = pokeSizes(.{ .rows = 1, .cols = 80 }); try testing.expectEqual(@as(u16, 1), tiny[0].rows); } -/// A session with no child behind it, for the pure-state tests below. fn stubSession(scratch: *ring.Ring) Session { return .{ .id = "s-test", @@ -365,8 +275,6 @@ test "input past the cap is counted, not queued" { @memset(paste, 'x'); s.queueInput(paste); - // The session's memory does not grow with what someone pasted into a busy - // agent, and the loss is recorded rather than silent. try testing.expectEqual(input_capacity, s.input_len); try testing.expectEqual(@as(u32, 500), s.input_dropped); try testing.expect(s.dirty); @@ -384,12 +292,9 @@ test "a status change marks the registry dirty; repeating one does not" { try testing.expectEqual(@as(i64, 1010), s.status_at); try testing.expect(s.dirty); - // The same status again is not a change. Without this, PreToolUse on every - // tool call would rewrite the registry file continuously through a long turn. s.dirty = false; try testing.expect(!s.note(.waiting, "", 1020)); try testing.expect(!s.dirty); - // But the event still counts as activity, or the decay would fire mid-turn. try testing.expectEqual(@as(i64, 1020), s.last_event_at); } @@ -402,9 +307,6 @@ test "exitCode tells an exit status from a signal" { try testing.expect(s.exitCode() == null); s.exit = .{ .code = 1 }; try testing.expectEqual(@as(i32, 1), s.exitCode().?); - // Negated rather than a second field: "exited 9" and "killed by SIGKILL" - // are different outcomes and a dashboard that conflated them would report - // a crash as a clean exit. s.exit = .{ .signal = 9 }; try testing.expectEqual(@as(i32, -9), s.exitCode().?); } @@ -420,7 +322,6 @@ test "the registry entry carries the status as text" { try testing.expectEqualStrings("active", row.status); try testing.expectEqualStrings("s-test", row.id); try testing.expectEqualStrings("PE-1", row.issue.?); - // And it round-trips back through the reader's side. try testing.expectEqual(sessions.Status.active, row.parsedStatus()); } @@ -430,18 +331,11 @@ test "a session that went silent mid-turn asks to be opened" { defer scratch.deinit(gpa); var s = stubSession(&scratch); - // Measured against a real row: a session whose hooks stopped arriving with a - // turn in flight sat for the full window and then read `idle` — "finished, - // come back whenever" — for a turn that had not ended and never would - // without someone opening it. _ = s.note(.active, "", 1000); try testing.expect(!s.tick(1000 + watch_status.active_decay_seconds)); try testing.expect(s.tick(1000 + watch_status.active_decay_seconds + 1)); try testing.expectEqual(sessions.Status.waiting, s.status); - // An unanswered permission prompt is still unanswered a day later, and - // ageing it into `idle` would hide it exactly when the user has been away - // longest. _ = s.note(.waiting, "", 2000); try testing.expect(!s.tick(2000 + watch_status.active_decay_seconds * 100)); try testing.expectEqual(sessions.Status.waiting, s.status); @@ -453,30 +347,17 @@ test "a session reports plan mode, and keeps reporting it through events that om defer scratch.deinit(gpa); var s = stubSession(&scratch); - // PreToolUse carries the mode, so the very first tool call in a planning - // turn is enough — nothing has to wait for the user to type anything. try testing.expect(s.note(.active, "plan", 1010)); try testing.expectEqualStrings("plan", s.entry().status); - // The lifecycle underneath is untouched: `plan` is what the row shows, not - // a state the transition function or the decay ever has to reason about. try testing.expectEqual(sessions.Status.active, s.status); - // SubagentStart carries no `permission_mode` at all. Reading its silence as - // "no longer planning" would drop the row back to `active` every time the - // agent handed work to a subagent — which during a plan is constantly. try testing.expect(!s.note(.active, "", 1020)); try testing.expectEqualStrings("plan", s.entry().status); - // Neither does Notification, and a permission prompt is the one moment the - // mode certainly has not changed. `waiting` outranks plan mode here: it is - // the only status that means "go here now", and the prompt at the end of a - // plan is exactly when that must not be painted over. try testing.expect(s.note(.waiting, "", 1030)); try testing.expectEqualStrings("waiting", s.entry().status); try testing.expect(s.plan); - // Approving the plan is not an event of its own — the next hook that - // carries a mode simply reports a different one, and that is what ends it. try testing.expect(s.note(.active, "default", 1040)); try testing.expectEqualStrings("active", s.entry().status); try testing.expect(!s.plan); @@ -488,9 +369,6 @@ test "a mode this build has never heard of is not plan mode" { defer scratch.deinit(gpa); var s = stubSession(&scratch); - // Claude Code has six modes today and may have seven tomorrow. An unknown - // one reads as "not planning", which is the safe direction: the row loses a - // marker rather than claiming an agent cannot touch files when it can. _ = s.note(.active, "plan", 1000); _ = s.note(.active, "some-mode-from-2027", 1010); try testing.expectEqualStrings("active", s.entry().status); @@ -505,17 +383,11 @@ test "a change no reader can see costs no registry write" { _ = s.note(.active, "plan", 1000); s.dirty = false; - // A planning session ending a turn: the lifecycle goes `active` → `idle` - // underneath, and both compose to `plan`, so the row is unchanged. The - // common path rather than a corner — every turn of every planning session - // reaches it, and reporting it dirty would be a file write per turn to - // record nothing. try testing.expect(!s.note(.idle, "plan", 1100)); try testing.expect(!s.dirty); try testing.expectEqual(sessions.Status.idle, s.status); try testing.expectEqualStrings("plan", s.entry().status); - // And leaving plan mode then shows the lifecycle that was under it all along. try testing.expect(s.note(.idle, "acceptEdits", 2000)); try testing.expectEqualStrings("idle", s.entry().status); } diff --git a/src/watch_status.zig b/src/watch_status.zig index c42d6de..a0e3a6b 100644 --- a/src/watch_status.zig +++ b/src/watch_status.zig @@ -1,20 +1,3 @@ -//! What a session's status is, given what its hooks have reported. -//! -//! A pure transition function over `watch_hooks.Event`, plus one time-based -//! safety net. It replaced a pattern table matched against stripped pty output; -//! the reasoning for that is in `watch_hooks.zig`. -//! -//! The daemon owns `exited` — `waitpid` answers it, not a hook, because a -//! process that died in a way that skipped `SessionEnd` still died. -//! -//! Plan mode is *projected* here rather than applied. It is a mode, not a -//! transition: nothing fires when a plan is approved — the next hook that -//! carries a `permission_mode` simply reports a different one. Modelling it as -//! a state would put a "which mode am I in" question inside `apply` and a -//! "does this decay" question inside `decay`, to describe something neither of -//! them drives. So the session keeps its lifecycle status, carries plan mode -//! beside it, and `present` decides what the two of them add up to. - const std = @import("std"); const sessions = @import("sessions.zig"); const watch_hooks = @import("watch_hooks.zig"); @@ -22,19 +5,8 @@ const watch_hooks = @import("watch_hooks.zig"); pub const Status = sessions.Status; pub const Event = watch_hooks.Event; -/// How long a session may sit in `active` with nothing further reported before -/// the dashboard stops believing the turn is still moving. -/// -/// A backstop for the hook that never arrived — a handler that failed, a -/// `--settings` that did not reach the session, a Claude Code killed mid-turn. -/// Generous, because a long tool call is ordinary and giving up on a session -/// that is genuinely working is worse than showing it busy a little too long. pub const active_decay_seconds: i64 = 15 * 60; -/// A single reported event applied to the status a session already had. -/// -/// `exited` is terminal: a dead child cannot become busy again, and a late -/// hook arriving after the process is gone must not resurrect the row. pub fn apply(current: Status, event: Event) Status { if (current == .exited) return .exited; return switch (event) { @@ -45,29 +17,7 @@ pub fn apply(current: Status, event: Event) Status { }; } -/// The status after `elapsed` seconds without a further event. -/// -/// Silence resolves to `waiting`, never to `idle`. `idle` is a positive claim — -/// "the turn finished, come back whenever" — and the only thing that licenses it -/// is a `Stop`. What this function actually knows is that a turn was in flight -/// and nothing has been reported about it for a quarter of an hour, which is the -/// opposite of finished: either the turn is wedged, or it is blocked on -/// something whose notification never reached the daemon. Both want a person, -/// and `waiting` is how a row says so. -/// -/// This was `idle`, and the cost of that was the whole point of the dashboard: -/// the one session that had stopped and needed looking at was painted the same -/// dim `○ idle` as every session that had finished cleanly, so it read as the -/// row you could safely ignore. -/// -/// Only `active` and `starting` decay. `waiting` has nowhere further to go — a -/// permission prompt left unanswered overnight is still a permission prompt. And -/// `idle` must not move: a session that reported `Stop` really is finished, and -/// ageing it into `waiting` would claim attention for every row already dealt -/// with, which is the same signal-destroying move in the other direction. pub fn decay(current: Status, seconds_since_event: i64) Status { - // A clock that moved backwards reads as no time passed, never as a very - // long time — the same clamp `remote_cache.fresh` applies. const elapsed = @max(0, seconds_since_event); return switch (current) { .active, .starting => if (elapsed > active_decay_seconds) .waiting else current, @@ -75,29 +25,10 @@ pub fn decay(current: Status, seconds_since_event: i64) Status { }; } -/// The status a session should be shown as, from its last event and when it -/// arrived. `now` is a parameter so this stays pure and its tests use literals. pub fn resolve(current: Status, last_event_at: i64, now: i64) Status { return decay(current, now - last_event_at); } -/// The lifecycle status and plan mode, combined into the one thing a row shows. -/// -/// Only `active` and `idle` give way. The rest outrank plan mode, each for its -/// own reason: -/// -/// `waiting` because it is the only status that means "go here now", and the -/// moment plan mode matters most — the approval prompt at the end of a plan — -/// *is* a permission prompt. Showing `plan` there would replace the signal the -/// dashboard exists for with a restatement of something the user already knows. -/// -/// `exited` and `orphan` because they are facts about the process and the -/// worktree rather than about what the agent is doing, and `unknown` because it -/// means nothing on disk can be believed — including this. -/// -/// `starting` because it means the child has not produced a byte yet, which -/// plan mode does not contradict; it lasts under a second, and its own decay -/// runs off the lifecycle field either way. pub fn present(current: Status, plan: bool) Status { if (!plan) return current; return switch (current) { @@ -109,8 +40,6 @@ pub fn present(current: Status, plan: bool) Status { const testing = std.testing; test "a reported event replaces the status, whatever it was" { - // The whole point of using hooks: no precedence puzzle, no time window, no - // guess. Claude Code said what happened, so that is the status. try testing.expectEqual(Status.waiting, apply(.active, .waiting)); try testing.expectEqual(Status.active, apply(.waiting, .active)); try testing.expectEqual(Status.idle, apply(.active, .idle)); @@ -119,9 +48,6 @@ test "a reported event replaces the status, whatever it was" { } test "exited is terminal, so a late hook cannot resurrect a dead session" { - // Hooks are async and the child may already have been reaped when one - // lands. Without this the dashboard would show a session as busy while its - // process no longer exists. for ([_]Event{ .waiting, .active, .idle, .ended }) |event| { try testing.expectEqual(Status.exited, apply(.exited, event)); } @@ -133,34 +59,18 @@ test "SessionEnd reports exited without waiting for the reap" { } test "a turn that went silent asks for a person, rather than claiming it finished" { - // The backstop for a hook that never arrived — a failed handler, or a - // --settings that did not reach the session. try testing.expectEqual(Status.active, decay(.active, active_decay_seconds)); try testing.expectEqual(Status.waiting, decay(.active, active_decay_seconds + 1)); try testing.expectEqual(Status.waiting, decay(.starting, active_decay_seconds + 1)); - // Stated as the thing it must never be, because that is the bug: silence - // means lcc lost track of a turn that had not ended, and `idle` means one - // that had. Sent there, the single row that needed opening was painted the - // same dim circle as every row that needed nothing. try testing.expect(decay(.active, active_decay_seconds + 1) != .idle); - // A permission prompt left overnight is still a permission prompt. Ageing - // it away would hide the one state the user has to act on, and would do it - // precisely when they have been away longest. try testing.expectEqual(Status.waiting, decay(.waiting, active_decay_seconds * 100)); - // And the other direction is just as destructive: a session that really did - // report `Stop` is finished, and letting it age into `waiting` would light - // up every row the user has already dealt with. try testing.expectEqual(Status.idle, decay(.idle, active_decay_seconds * 100)); - // Nothing resurrects a dead session either. try testing.expectEqual(Status.exited, decay(.exited, active_decay_seconds * 100)); } test "a clock that moved backwards is not a very long silence" { - // NTP can step the wall clock backwards. Read naively, `now - then` goes - // negative and a comparison against the window would flip — this clamps it - // to "no time has passed", which is the safe direction. try testing.expectEqual(Status.active, decay(.active, -active_decay_seconds * 10)); try testing.expectEqual(Status.active, resolve(.active, 2_000, 1_000)); } @@ -172,51 +82,27 @@ test "resolve is decay expressed against a wall clock" { } test "every hook event maps to a status a session can actually be in" { - // Guards against adding an Event with nowhere to go: the switch in `apply` - // is exhaustive, so a new variant fails to compile — but a variant mapped - // to `unknown` or `orphan` would compile and be wrong, since neither is - // something a running session reports about itself. for ([_]Event{ .waiting, .active, .idle, .ended }) |event| { const status = apply(.starting, event); try testing.expect(status != .unknown); try testing.expect(status != .orphan); - // Nor `plan`, which is derived rather than reported. Routing an event - // to it here would compile and would put a mode into the lifecycle - // field, where the decay and the `exited` guard would then have to - // reason about it — the thing `present` exists to avoid. try testing.expect(status != .plan); } } test "plan mode replaces working, and never replaces being blocked" { - // The two that give way. `lcc start` launches every session in plan mode, - // so "a turn is in flight" is nearly always true and nearly never the thing - // worth a column; "has this been approved to touch files" is. try testing.expectEqual(Status.plan, present(.active, true)); try testing.expectEqual(Status.plan, present(.idle, true)); - // The one that must not. `waiting` is the only status meaning "go here - // now", and the approval prompt at the end of a plan is a permission - // prompt — precisely where showing `plan` would trade the signal the - // dashboard exists for against a restatement of what the user just did. try testing.expectEqual(Status.waiting, present(.waiting, true)); - // Facts about the process and the worktree, not about what the agent is - // doing. A dead session in plan mode is dead, and one whose worktree was - // deleted is still the row most worth seeing. try testing.expectEqual(Status.exited, present(.exited, true)); try testing.expectEqual(Status.orphan, present(.orphan, true)); - // `unknown` means nothing on disk can be believed, and that includes the - // mode the same file reported. try testing.expectEqual(Status.unknown, present(.unknown, true)); - // And the child has not spoken yet, which plan mode does not contradict. try testing.expectEqual(Status.starting, present(.starting, true)); } test "without plan mode, present changes nothing at all" { - // The property that lets every existing caller keep its behaviour: for a - // session that is not planning this is the identity, so nothing about the - // other six statuses moved when `plan` was added. for ([_]Status{ .starting, .active, .waiting, .idle, .plan, .exited, .orphan, .unknown }) |status| { try testing.expectEqual(status, present(status, false)); } diff --git a/src/watch_table.zig b/src/watch_table.zig index 0880168..5d9f00a 100644 --- a/src/watch_table.zig +++ b/src/watch_table.zig @@ -1,14 +1,3 @@ -//! The session table: measure, fit to a width, render, and report the line -//! count the redraw depends on. -//! -//! Two things separate this from `lcc list`'s table, and both come from -//! redrawing rather than printing once. A row that wraps costs the frame its -//! line count, and every frame after it inherits the error — so this has a -//! width budget and `list.zig` does not, and every cell is truncated before it -//! is padded (`ui.pad` pads but never truncates). And `render` *returns* the -//! count instead of leaving the caller to add it up, which is what lets the -//! invariant be tested against a buffer with no terminal attached. - const std = @import("std"); const Io = std.Io; const sessions = @import("sessions.zig"); @@ -16,35 +5,16 @@ const term = @import("term.zig"); const ui = @import("ui.zig"); pub const Row = struct { - /// The worktree path, and the cursor's identity. - /// - /// Not the session id: a row exists whether or not a session does, and the - /// worktree is the thing that persists across one starting and stopping. key: []const u8, - /// Null when nothing is running here — an ordinary state, not an error. session_id: ?[]const u8, - /// Null for the same reason. status: ?sessions.Status, issue: ?[]const u8, branch: []const u8, worktree: []const u8, last_activity_at: i64, exit_code: ?i32, - /// The daemon is alive but its projection has gone cold. stale: bool, - /// Whether there is a live pty behind this row to attach to. - /// - /// Having a session *id* is not the same question, and conflating them is - /// what made Enter do nothing on a dead daemon's rows: the id is still in - /// the projection, so the attach was tried, found nothing listening, and - /// returned in silence. `unknown` means the daemon that recorded that id is - /// gone, and `exited` means the child is — in both cases the id names - /// something that no longer exists, and the honest move is to start again. - /// - /// `orphan` *is* attachable: the worktree is missing but the agent is still - /// running, which is the whole reason that state is shown rather than - /// dropped. pub fn attachable(self: Row) bool { if (self.session_id == null) return false; return switch (self.status orelse return false) { @@ -54,16 +24,10 @@ pub const Row = struct { } }; -/// One glyph per status, so a column of them reads at a glance. -/// -/// `waiting` is the only one that means "go here now", and it gets the filled -/// circle and the warm colour for that reason alone. fn glyph(status: ?sessions.Status) []const u8 { return switch (status orelse return "·") { .waiting => "●", .active => "◐", - // The only glyph here that is not a circle, because plan mode is the - // only one of these that is not a point in the lifecycle. .plan => "◈", .idle => "○", .starting => "◌", @@ -77,8 +41,6 @@ fn paint(status: ?sessions.Status, palette: ui.Palette) []const u8 { return switch (status orelse return palette.dim) { .waiting => palette.yellow, .active => palette.green, - // Its own colour, not `active`'s: the whole reason the row says `plan` - // is that those two are different answers to "can it touch my files". .plan => palette.cyan, .orphan => palette.yellow, .exited => palette.red, @@ -93,9 +55,6 @@ pub const Widths = struct { age: usize = 0, worktree: usize = 0, - /// Columns are separated by two spaces and the whole row is inset by the - /// two-column cursor gutter. A width of zero switches a column off, and it - /// then costs nothing — including its separator. pub fn total(self: Widths) usize { var out: usize = 2; var first = true; @@ -113,12 +72,9 @@ pub const Widths = struct { const headers = .{ .issue = "ISSUE", .status = "STATUS", .branch = "BRANCH", .age = "AGE", .worktree = "WORKTREE" }; -/// Widths seeded with the header labels, then grown to the widest cell — the -/// shape `list.zig` and `stats.zig` both use. pub fn measure(rows: []const Row) Widths { var w: Widths = .{ .issue = headers.issue.len, - // Two for the glyph and its space, on top of the longest label. .status = headers.status.len, .branch = headers.branch.len, .age = headers.age.len, @@ -133,14 +89,8 @@ pub fn measure(rows: []const Row) Widths { return w; } -/// Which columns go first when the terminal is too narrow. -/// -/// `status` is absent on purpose — it is the reason the table exists, and a -/// table that dropped it to fit would be narrower and useless. `branch` is -/// absent because it is the flexible one: it shrinks rather than disappearing. pub const drop_order = [_][]const u8{ "worktree", "age", "issue" }; -/// The narrowest a branch column is still worth keeping. const branch_floor = 12; pub fn fit(widths: Widths, cols: usize) Widths { @@ -151,8 +101,6 @@ pub fn fit(widths: Widths, cols: usize) Widths { if (out.total() > cols) @field(out, name) = 0; } - // Still too wide: shrink the branch rather than wrap. Below the floor the - // caller falls back to one line per session — see `renderNarrow`. if (out.total() > cols) { const over = out.total() - cols; out.branch = if (out.branch > over + branch_floor) out.branch - over else branch_floor; @@ -160,15 +108,10 @@ pub fn fit(widths: Widths, cols: usize) Widths { return out; } -/// True when even the shrunk table cannot fit, and the compact form is owed. pub fn tooNarrow(widths: Widths, cols: usize) bool { return fit(widths, cols).total() > cols; } -/// Draws the table and returns how many lines it drew. -/// -/// The count is the contract: `Screen.eraseFrame` walks back up exactly this -/// far, so a number that disagrees with the output corrupts every later frame. pub fn render( out: *Io.Writer, rows: []const Row, @@ -188,9 +131,6 @@ pub fn render( for (rows) |row| { const selected = std.mem.eql(u8, row.key, cursor_id); const gutter = if (selected) "❯ " else " "; - // A worktree nothing has ever run in has no activity to age. Measuring - // from zero dates it to the epoch and prints "56y", which reads as data - // rather than as its absence. const age = if (row.last_activity_at == 0) "—" else @@ -207,7 +147,6 @@ pub fn render( cells[1].text = std.fmt.bufPrint(&status_buf, "{s} {s}{s}", .{ glyph(row.status), statusText(row.status), - // A cold projection is marked rather than silently believed. if (row.stale) "~" else "", }) catch statusText(row.status); @@ -217,8 +156,6 @@ pub fn render( return lines; } -/// A worktree with nothing running in it says so, rather than leaving the -/// column blank — blank reads as missing data, and this is a state. pub fn statusText(status: ?sessions.Status) []const u8 { return if (status) |s| @tagName(s) else "no session"; } @@ -239,8 +176,6 @@ fn headerCells(widths: Widths) []const Cell { return &S.cells; } -/// One line, truncated to `cols` display columns and terminated with exactly -/// one newline — the two properties the redraw's line count rests on. fn writeRow(out: *Io.Writer, cols: usize, gutter: []const u8, colour: []const u8, cells: []const Cell, reset: []const u8) void { var used: usize = 0; out.writeAll(gutter) catch {}; @@ -263,8 +198,6 @@ fn writeRow(out: *Io.Writer, cols: usize, gutter: []const u8, colour: []const u8 out.writeAll(text) catch {}; if (cell.colour.len > 0) out.writeAll(reset) catch {}; const shown = ui.displayWidth(text); - // The last column is not padded: trailing spaces would push the line - // over `cols` for no visible gain. if (shown < cell.width) out.splatByteAll(' ', cell.width - shown) catch {}; used += @max(shown, cell.width); } @@ -272,7 +205,6 @@ fn writeRow(out: *Io.Writer, cols: usize, gutter: []const u8, colour: []const u8 out.writeAll("\n") catch {}; } -/// Below the point where columns help: one line per session, then a count. fn renderNarrow(out: *Io.Writer, rows: []const Row, cols: usize, cursor_id: []const u8, p: ui.Palette) usize { if (cols < 12) { out.print("{d} sessions\n", .{rows.len}) catch {}; @@ -332,15 +264,9 @@ fn testRows() []const Row { test "measure sizes every column to its widest cell, headers included" { const w = measure(testRows()); try testing.expectEqual(ui.displayWidth("feature/pe-256-app-hangs-on-launch"), w.branch); - // The widest cell wins when it beats the header — `PE-256` is six columns. try testing.expectEqual(ui.displayWidth("PE-256"), w.issue); - // Status carries a glyph and a space on top of its widest label, and - // "no session" is wider than any status a running one reports. try testing.expectEqual(ui.displayWidth("no session") + 2, w.status); - // And the header wins when nothing beats it: `AGE` has no cell measured - // against it at all, so seeding from the labels is what keeps the column - // from collapsing to nothing. try testing.expectEqual(@as(usize, "AGE".len), w.age); const empty = measure(&.{}); try testing.expectEqual(@as(usize, "BRANCH".len), empty.branch); @@ -350,10 +276,8 @@ test "fit drops columns in order and never drops the status" { const full = measure(testRows()); try testing.expect(full.total() > 60); - // Wide enough for everything. try testing.expectEqual(full, fit(full, full.total())); - // Worktree is the first to go, then age, then issue. const narrow = fit(full, full.total() - 1); try testing.expectEqual(@as(usize, 0), narrow.worktree); try testing.expect(narrow.status > 0); @@ -362,8 +286,6 @@ test "fit drops columns in order and never drops the status" { try testing.expectEqual(@as(usize, 0), narrower.worktree); try testing.expectEqual(@as(usize, 0), narrower.age); - // Whatever the width, the column the table exists for survives — a table - // that fitted by dropping the status would be narrower and pointless. for ([_]usize{ 120, 80, 60, 40, 20, 10 }) |cols| { try testing.expect(fit(full, cols).status > 0); } @@ -385,12 +307,8 @@ test "render returns exactly the number of lines it drew" { const widths = fit(measure(rows), 120); const lines = render(&w, rows, widths, 120, "/r/.lcc/worktrees/pe-256", 1000); - // The redraw walks back up exactly this far. A count that disagrees with - // the output corrupts every frame after it — which is why this is asserted - // against the bytes rather than trusted from the loop. const drawn = std.mem.count(u8, w.buffered(), "\n"); try testing.expectEqual(drawn, lines); - // One header plus one line per session. try testing.expectEqual(rows.len + 1, lines); } @@ -399,9 +317,6 @@ test "no rendered line is wider than the terminal, at any width" { const full = measure(rows); ui.setColor(false); - // A line that overflows soft-wraps, and a wrapped line makes the frame's - // line count a lie. This is the invariant, checked across the range where - // the layout changes shape. for ([_]usize{ 200, 120, 80, 60, 46, 30, 20, 10 }) |cols| { var buf: [8192]u8 = undefined; var w: Io.Writer = .fixed(&buf); @@ -438,21 +353,13 @@ test "a row is attachable only when something is actually behind it" { }; try testing.expect(row.attachable()); - // The registry still holds an id after the daemon that made it died. Trying - // to attach to it finds nothing listening and returns in silence, which - // reads as the key not working. row.status = .unknown; try testing.expect(!row.attachable()); - // A finished child has no pty either. row.status = .exited; try testing.expect(!row.attachable()); - // But a missing worktree does not mean a missing agent — that is the whole - // reason `orphan` is shown instead of dropped. row.status = .orphan; try testing.expect(row.attachable()); - // A session still writing its plan is very much running, and Enter on it - // should attach rather than try to start a second one in the same worktree. row.status = .plan; try testing.expect(row.attachable()); @@ -479,8 +386,6 @@ test "a planning row says plan, not active" { }}; _ = render(&w, &rows, fit(measure(&rows), 120), 120, "/w", 1000); - // The distinction the status is for: this agent has not been approved to - // touch files, and a row reading `active` would say the opposite. try testing.expect(std.mem.indexOf(u8, w.buffered(), "◈ plan") != null); try testing.expect(std.mem.indexOf(u8, w.buffered(), "active") == null); } @@ -491,8 +396,6 @@ test "a worktree with nothing running shows no age, not one measured from the ep ui.setColor(false); const rows = testRows(); _ = render(&w, rows, fit(measure(rows), 120), 120, rows[0].key, 1_800_000_000); - // The second row has never run. Aging from zero would print something like - // "56y", which looks like a fact rather than the absence of one. try testing.expect(std.mem.indexOf(u8, w.buffered(), "56y") == null); try testing.expect(std.mem.indexOf(u8, w.buffered(), "—") != null); } @@ -503,10 +406,7 @@ test "a stale row is marked rather than silently believed" { ui.setColor(false); const rows = testRows(); _ = render(&w, rows, fit(measure(rows), 120), 120, "/r/.lcc/worktrees/pe-256", 1000); - // The second row has no session at all — a state, not missing data. try testing.expect(std.mem.indexOf(u8, w.buffered(), "no session") != null); - // And the first is stale: a debounced writer can promise nothing more than - // "this was true a moment ago", and the reader should be able to see that. try testing.expect(std.mem.indexOf(u8, w.buffered(), "waiting~") != null); } @@ -517,11 +417,8 @@ test "the selected row is the one whose id matches, not a row index" { const rows = testRows(); _ = render(&w, rows, fit(measure(rows), 120), 120, "/r/.lcc/worktrees/other", 1000); - // Snapshots re-sort as statuses change. A cursor held as an index would - // jump under the user's finger; held as an id it stays on the session they - // were looking at. var it = std.mem.splitScalar(u8, w.buffered(), '\n'); - _ = it.next(); // header + _ = it.next(); const first = it.next().?; const second = it.next().?; try testing.expect(!std.mem.startsWith(u8, first, "❯")); diff --git a/src/wire.zig b/src/wire.zig index 7499f4e..065612f 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -1,42 +1,11 @@ -//! What travels on the daemon socket: a five-byte header, then bytes. -//! -//! Control payloads are JSON, so the protocol stays inspectable and matches the -//! discipline the `--json` command surface already follows. Data payloads — pty -//! output and keystrokes — are raw. They are not text: Claude Code draws box -//! characters and anything it runs can print arbitrary bytes, so `Stringify` -//! would mangle or refuse them, and base64 would be mandatory rather than -//! optional. That would cost an allocation and a copy per chunk, on both sides, -//! for every screen repaint. Length framing never touches the payload. -//! -//! **Two connections per client, not one multiplexed.** Control is JSON at a -//! low rate; an attach connection carries one session's bytes and is opened and -//! closed with the attachment. Splitting them means a 60 KB repaint cannot delay -//! a status update, there is no channel id in the header, and switching -//! sessions stops being a race — the new attachment is established and its -//! replay taken *before* the old one is dropped, so nothing falls in the gap. - const std = @import("std"); const Io = std.Io; const sessions = @import("sessions.zig"); -/// Bumped when the framing or any payload shape changes. `lcc` on PATH is a -/// symlink to `zig-out/bin/lcc`, so a `zig build` swaps the client under a -/// running daemon — this mismatch is a weekly event during development, and it -/// has to produce a sentence rather than a garbled stream into a live session. pub const protocol: u32 = 1; pub const header_len = 5; -/// One cap for every frame, on both connections. -/// -/// A full repaint at 200x60 with colour runs about 50 KB, so one repaint is one -/// frame and the sender splits anything larger. A snapshot is the only control -/// frame that could want more, and at the daemon's session cap it comes to -/// roughly 26 KB — so a second, larger cap would buy nothing and cost the thing -/// that matters: a client's decoder buffer has to be sized at `accept`, before -/// `hello` has said which role the connection is. One size removes that -/// ordering problem entirely. Past the cap the daemon truncates the session -/// list and says so, rather than failing. pub const max_payload = 64 * 1024; pub const Error = error{ @@ -48,15 +17,6 @@ pub const Error = error{ pub const Role = enum { control, attach }; -/// The high nibble carries role and direction, so a frame that arrived on the -/// wrong connection is rejected by arithmetic rather than a lookup table. -/// -/// 0x0_ either connection, either direction -/// 0x1_ client -> daemon, control 0x2_ daemon -> client, control -/// 0x3_ client -> daemon, attach 0x4_ daemon -> client, attach -/// -/// Non-exhaustive: an unknown byte off the wire must be a rejected value, not -/// undefined behaviour. pub const Type = enum(u8) { hello = 0x01, @@ -65,7 +25,6 @@ pub const Type = enum(u8) { subscribe = 0x12, kill = 0x13, stop = 0x14, - /// A Claude Code hook reporting what a session is doing, keyed by cwd. hook = 0x15, snapshot = 0x20, @@ -81,10 +40,6 @@ pub const Type = enum(u8) { attached = 0x40, output = 0x41, - /// Scrollback, as opposed to what the child is writing now. Distinct - /// because a replay must be filtered before it reaches a terminal — see - /// `ansi.ModeFilter` — and the client cannot tell one from the other by - /// looking at the bytes. replay = 0x44, exited = 0x42, input_revoked = 0x43, @@ -122,22 +77,19 @@ pub fn known(t: Type) bool { pub fn allowedOn(t: Type, role: Role) bool { return switch (@intFromEnum(t) >> 4) { - 0x0 => true, // hello, on both + 0x0 => true, 0x1, 0x2 => role == .control, 0x3, 0x4 => role == .attach, else => false, }; } -/// The buffer every decoder must be given, whatever its role. pub fn bufferLen() usize { return max_payload + header_len; } pub const Frame = struct { type: Type, - /// Points into the decoder's buffer, and is invalidated by the next call - /// to `next` or `commit`. payload: []const u8, }; @@ -148,22 +100,14 @@ pub fn encodeHeader(t: Type, len: u32) [header_len]u8 { return out; } -/// Little-endian: both ends are the same binary on the same machine, and -/// `readInt(.little)` is what the platform reads for free. pub fn decodeHeader(bytes: *const [header_len]u8) Error!struct { type: Type, len: u32 } { const t: Type = @enumFromInt(bytes[0]); if (!known(t)) return Error.UnknownFrameType; const len = std.mem.readInt(u32, bytes[1..header_len], .little); - // Checked before anything is sized, indexed or allocated from it. The - // length is whatever the peer wrote, and `Io.Reader.take(n)` past its - // buffer *panics* rather than erroring — over a raw-mode terminal that is a - // stack trace across someone's session. if (len > max_payload) return Error.FrameTooLarge; return .{ .type = t, .len = len }; } -/// Header and payload in one call, so a reader can never observe a header -/// without the body behind it. pub fn writeFrame(w: *Io.Writer, t: Type, payload: []const u8) (Io.Writer.Error || Error)!void { if (payload.len > max_payload) return Error.FrameTooLarge; const header = encodeHeader(t, @intCast(payload.len)); @@ -171,8 +115,6 @@ pub fn writeFrame(w: *Io.Writer, t: Type, payload: []const u8) (Io.Writer.Error try w.writeAll(payload); } -/// The only place JSON and framing meet, so the length in the header and the -/// bytes behind it cannot disagree. pub fn writeControl( gpa: std.mem.Allocator, w: *Io.Writer, @@ -191,20 +133,9 @@ pub fn parse(comptime T: type, gpa: std.mem.Allocator, frame: Frame) !T { }); } -/// Reassembles frames from reads that split them anywhere. -/// -/// Explicit rather than an `Io.Reader`, because the loops that use this poll -/// stdin and a socket together: blocking for the rest of a frame while a -/// keystroke is waiting is the one thing those loops exist to avoid. -/// -/// The buffer is supplied by the caller because the two roles need different -/// sizes, and a 256 KB array cannot live on a stack. pub const Decoder = struct { buf: []u8, - /// Valid bytes in `buf`. len: usize = 0, - /// The frame handed out last time, still occupying the front of `buf`. - /// Dropped on the next call, so the slice stays valid until then. taken: usize = 0, role: Role, @@ -220,8 +151,6 @@ pub const Decoder = struct { self.taken = 0; } - /// Free space to read into, so the socket read lands in the decoder's - /// buffer instead of a scratch one that then has to be copied. pub fn writable(self: *Decoder) []u8 { self.release(); return self.buf[self.len..]; @@ -231,18 +160,13 @@ pub const Decoder = struct { self.len += n; } - /// For callers that already hold the bytes. Reading a socket should use - /// `writable`/`commit` and avoid the copy. pub fn push(self: *Decoder, bytes: []const u8) Error!void { const room = self.writable(); - // Cannot happen once every length is validated at the header: the - // buffer holds the largest frame the role permits, plus its header. if (bytes.len > room.len) return Error.FrameTooLarge; @memcpy(room[0..bytes.len], bytes); self.commit(bytes.len); } - /// The next whole frame, or null when more bytes are needed. pub fn next(self: *Decoder) Error!?Frame { self.release(); if (self.len < header_len) return null; @@ -258,18 +182,6 @@ pub const Decoder = struct { } }; -// --------------------------------------------------------------------------- -// Control payloads -// -// Every key is always present and an absent value is null rather than dropped, -// the same contract `lcc start --json` keeps. The tests below re-declare each -// shape independently and parse with `ignore_unknown_fields = false`, so -// renaming, dropping *or adding* a key fails here rather than in the daemon. -// --------------------------------------------------------------------------- - -/// Frozen. Type byte `0x01`, JSON payload, `protocol` first. Any future version -/// must keep this one frame decodable, or an old client meeting a new daemon -/// gets bytes it cannot parse instead of a sentence it can read. pub const Hello = struct { protocol: u32 = @This().current, role: []const u8, @@ -285,8 +197,6 @@ pub const Register = struct { repo_root: []const u8, program: []const u8, argv: []const []const u8, - /// The client's whole environment. Without it a daemon started days ago by - /// one shell would hand every later session that shell's `PATH`. env: []const []const u8, cols: u16, rows: u16, @@ -303,16 +213,11 @@ pub const Snapshot = struct { daemon_pid: i32, protocol: u32, sessions: []const sessions.Session, - /// Set when the list was cut to fit `max_payload`. A contract, not - /// a failure — a caller that sees it knows the view is partial. truncated: bool, }; -/// One whole session replaces one row. Not a field diff: a diff can desync, and -/// a protocol this small has no reconciliation path back. A full row is a few -/// hundred bytes and cannot. pub const Event = struct { - kind: []const u8, // added | changed | removed + kind: []const u8, session_id: []const u8, session: ?sessions.Session, }; @@ -326,8 +231,6 @@ pub const Attach = struct { pub const Attached = struct { session_id: []const u8, - /// The *effective* size — the componentwise minimum over attached clients, - /// so a client with a larger terminal can see why it has a margin. cols: u16, rows: u16, input: bool, @@ -336,18 +239,10 @@ pub const Attached = struct { pub const Resize = struct { cols: u16, rows: u16 }; -/// What `lcc watch-hook` forwards. `cwd` is the worktree, which is already the -/// key the daemon files sessions under — so a hook needs no session id from -/// lcc's side and no correlation table to keep in step. pub const Hook = struct { cwd: []const u8, session_id: []const u8, event: []const u8, - /// Claude Code's permission mode, when the event that fired carried one. - /// - /// Defaulted rather than required, so this stayed additive: an empty value - /// means "nothing reported", which the daemon already has to handle for the - /// events that never carry a mode at all. permission_mode: []const u8 = "", }; pub const Kill = struct { session_id: []const u8, signal: []const u8 }; @@ -361,33 +256,22 @@ test "a header round-trips, and an unknown type is refused rather than guessed" try std.testing.expectEqual(Type.output, back.type); try std.testing.expectEqual(@as(u32, 4211), back.len); - // A corrupt stream must end the connection. Reinterpreting an unknown byte - // would eventually mean feeding someone's session bytes as keystrokes. const bogus = [_]u8{ 0x7e, 0, 0, 0, 0 }; try std.testing.expectError(Error.UnknownFrameType, decodeHeader(&bogus)); } test "a length past the cap is refused before anything is sized from it" { - // The length field is whatever the peer wrote. `Io.Reader.take(n)` beyond - // its buffer panics rather than erroring, so this check is what stands - // between a hostile or buggy length and a stack trace over a raw terminal. var head = encodeHeader(.output, max_payload + 1); try std.testing.expectError(Error.FrameTooLarge, decodeHeader(&head)); head = encodeHeader(.output, std.math.maxInt(u32)); try std.testing.expectError(Error.FrameTooLarge, decodeHeader(&head)); - // Exactly at the cap is fine — an off-by-one here would drop full repaints. head = encodeHeader(.output, max_payload); _ = try decodeHeader(&head); } test "every frame the protocol permits fits the buffer every decoder is given" { - // The invariant that keeps `bufferLen` honest: a frame larger than the - // buffer it must land in is not a recoverable error, it is a protocol - // contradicting its own sizing. One cap makes this trivially true — the - // test stays because the property is what matters, not the arithmetic, and - // reintroducing a per-type cap would have to keep it. try std.testing.expect(max_payload + header_len <= bufferLen()); } @@ -399,7 +283,6 @@ test "role separation rejects a frame that arrived on the wrong connection" { try std.testing.expect(allowedOn(.output, .attach)); try std.testing.expect(!allowedOn(.output, .control)); - // And the decoder enforces it, rather than leaving it to each call site. var buf: [bufferLen()]u8 = undefined; var dec = try Decoder.init(&buf, .attach); try dec.push(&encodeHeader(.list, 0)); @@ -409,10 +292,6 @@ test "role separation rejects a frame that arrived on the wrong connection" { test "the decoder reassembles frames split at every possible byte offset" { const gpa = std.testing.allocator; - // The test that makes the decoder trustworthy. A socket splits wherever the - // kernel feels like it, and the failure it produces — a frame silently - // reassembled wrong — surfaces as corrupted terminal output a long way from - // here. var stream: std.ArrayList(u8) = .empty; defer stream.deinit(gpa); @@ -437,7 +316,6 @@ test "the decoder reassembles frames split at every possible byte offset" { got += 1; } } - // All three, every time, wherever the cut landed. try std.testing.expectEqual(payloads.len, got); } } @@ -446,9 +324,6 @@ test "a zero-length payload is an empty slice, not a missing frame" { var buf: [bufferLen()]u8 = undefined; var dec = try Decoder.init(&buf, .attach); - // `detach` carries nothing. If an empty payload read as "need more bytes" - // the connection would hang on the one frame whose whole meaning is that - // it arrived. try dec.push(&encodeHeader(.detach, 0)); const frame = (try dec.next()).?; try std.testing.expectEqual(Type.detach, frame.type); @@ -457,9 +332,6 @@ test "a zero-length payload is an empty slice, not a missing frame" { } test "a decoder given too small a buffer says so instead of overflowing later" { - // The mistake this catches is sizing a decoder by hand and getting it - // wrong — the buffer has to hold the largest frame the protocol permits, or - // a legal frame arrives and cannot be assembled. var small: [16]u8 = undefined; try std.testing.expectError(Error.ShortBuffer, Decoder.init(&small, .attach)); var one_short: [max_payload + header_len - 1]u8 = undefined; @@ -476,8 +348,6 @@ test "writeFrame emits the header and the body as one unit" { const head = try decodeHeader(out[0..header_len]); try std.testing.expectEqual(Type.resize, head.type); - // The length in the header has to match the bytes behind it, or every - // subsequent frame on the connection is misaligned. try std.testing.expectEqual(out.len - header_len, head.len); var arena_state: std.heap.ArenaAllocator = .init(gpa); @@ -497,7 +367,6 @@ test "writeFrame refuses an oversized payload rather than emitting a bad length" defer std.testing.allocator.free(huge); @memset(huge, 'x'); try std.testing.expectError(Error.FrameTooLarge, writeFrame(&w, .output, huge)); - // Nothing was written: a truncated header would desync the peer. try std.testing.expectEqual(@as(usize, 0), w.end); } @@ -507,9 +376,6 @@ test "the hello payload keeps the shape a mismatched version must still read" { var w: Io.Writer = .fixed(&buf); try writeControl(gpa, &w, .hello, Hello{ .role = "control", .pid = 4123 }); - // Declared independently and parsed with unknown fields *rejected*, so - // renaming, dropping or adding a key fails here. `hello` is frozen: an old - // client meeting a new daemon has to get a version number it can read. const Schema = struct { protocol: u32, role: []const u8, pid: i32 }; var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); @@ -528,9 +394,6 @@ test "a register carries argv and the whole environment, escaping intact" { var buf: [4096]u8 = undefined; var w: Io.Writer = .fixed(&buf); - // Environment values contain quotes, backslashes and newlines in practice, - // and argv carries the initial prompt — which opens with `---` front matter - // often enough that mangling it would be routine rather than exotic. try writeControl(gpa, &w, .register, Register{ .worktree = "/r/.lcc/worktrees/pe-256", .branch = "feature/pe-256-fix", @@ -569,7 +432,5 @@ test "an absent issue is null in the payload, not a dropped key" { .cols = 80, .rows = 24, }); - // The same contract the `--json` surface keeps: a reader always finds the - // key and never has to tell "absent" from "not applicable". try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\"issue\":null") != null); } diff --git a/src/xcode.zig b/src/xcode.zig index eb70e93..c37960d 100644 --- a/src/xcode.zig +++ b/src/xcode.zig @@ -1,13 +1,8 @@ -//! The Xcode side of a worktree: finding its entry point, opening it, and asking -//! a running Xcode to let go of it before the directory is deleted. - const std = @import("std"); const Io = std.Io; const disk = @import("disk.zig"); const exec = @import("exec.zig"); -// Directories that never hold the project we want to open — skip them while -// searching so we don't descend into dependencies or build output. const ignore_dirs = [_][]const u8{ "node_modules", "Pods", "Carthage", "DerivedData", "vendor" }; pub const Kind = enum { @@ -15,7 +10,6 @@ pub const Kind = enum { project, package, - /// Same depth wins by kind: a workspace references its projects, so prefer it. fn rank(self: Kind) u8 { return switch (self) { .workspace => 0, @@ -46,8 +40,6 @@ const Candidate = struct { pub const Error = error{ XcodeLaunchFailed, XcodeCloseFailed } || std.mem.Allocator.Error; -/// Best Xcode entry point under `root`: shallowest match, then -/// workspace > project > package at the same depth. Null when nothing matches. pub fn findTarget(gpa: std.mem.Allocator, io: Io, root: []const u8, max_depth: u8) !?Target { var found: std.ArrayList(Candidate) = .empty; try walk(gpa, io, root, 0, max_depth, &found); @@ -81,11 +73,11 @@ fn walk( if (entry.kind == .directory) { if (std.mem.endsWith(u8, entry.name, ".xcworkspace")) { try out.append(gpa, .{ .path = full, .kind = .workspace, .depth = depth }); - continue; // bundle — don't descend + continue; } if (std.mem.endsWith(u8, entry.name, ".xcodeproj")) { try out.append(gpa, .{ .path = full, .kind = .project, .depth = depth }); - continue; // bundle — don't descend + continue; } if (std.mem.startsWith(u8, entry.name, ".")) continue; var ignored = false; @@ -110,8 +102,6 @@ pub fn describe(gpa: std.mem.Allocator, target: Target) ![]u8 { }); } -/// `open -a Xcode` returns as soon as the app is handed the document — Xcode is -/// a GUI app, not attached to this process. pub fn open(gpa: std.mem.Allocator, io: Io, target: []const u8) Error!void { const out = exec.run(gpa, io, &.{ "open", "-a", "Xcode", target }, null) catch return Error.XcodeLaunchFailed; @@ -123,14 +113,9 @@ pub fn open(gpa: std.mem.Allocator, io: Io, target: []const u8) Error!void { pub var last_error: []const u8 = ""; -/// One document open in one running Xcode. pub const Document = struct { - /// App bundle of the instance holding it, e.g. `/Applications/Xcode.app`. app: []const u8, - /// Path exactly as Xcode reported it — the string `close` is matched against. path: []const u8, - /// The same path with symlinks resolved, which is what containment is judged - /// by: Xcode answers `/tmp/…` where git says `/private/tmp/…`. resolved: []const u8, pub fn name(self: Document) []const u8 { @@ -138,26 +123,15 @@ pub const Document = struct { } }; -/// What the running Xcodes are holding right now. pub const Open = struct { - /// Project and workspace windows — the documents that can be closed. Closing - /// one takes the editors inside it along. workspaces: []const Document = &.{}, - /// Documents with edits that are not on disk, of any kind. Xcode's scripting - /// interface has no `save` (its documents answer `close` and nothing else), so - /// these are a reason to stop rather than something lcc can settle on the - /// user's behalf. unsaved: []const Document = &.{}, - /// A running Xcode that could not be asked — automation not permitted, or one - /// too busy to answer in time. An empty list from that run means "we don't - /// know", not "nothing is open", and the caller should say so. unanswered: bool = false, pub fn empty(self: Open) bool { return self.workspaces.len == 0 and self.unsaved.len == 0; } - /// The subset sitting at or below `worktree`. pub fn inside(self: Open, gpa: std.mem.Allocator, io: Io, worktree: []const u8) !Open { const root_path = disk.realPath(gpa, io, worktree); return .{ @@ -171,8 +145,6 @@ pub const Open = struct { fn under(gpa: std.mem.Allocator, docs: []const Document, root_path: []const u8) ![]const Document { var kept: std.ArrayList(Document) = .empty; for (docs) |doc| { - // A Swift package opened by its folder is reported as that folder, so the - // worktree root can *be* the document rather than contain one. const at_root = std.mem.eql(u8, doc.resolved, root_path); if (!at_root and !disk.isInside(gpa, root_path, doc.resolved)) continue; try kept.append(gpa, doc); @@ -180,12 +152,6 @@ fn under(gpa: std.mem.Allocator, docs: []const Document, root_path: []const u8) return kept.toOwnedSlice(gpa); } -/// Every document open in every running Xcode. -/// -/// Nothing running, automation refused, an Xcode wedged mid-index — none of those -/// is an error here. They cost lcc the chance to close a window, not the ability -/// to remove the worktree, so they come back as an empty `Open` with `unanswered` -/// set where a live Xcode actually stonewalled us. pub fn openDocuments(gpa: std.mem.Allocator, io: Io) !Open { var workspaces: std.ArrayList(Document) = .empty; var unsaved: std.ArrayList(Document) = .empty; @@ -206,11 +172,6 @@ pub fn openDocuments(gpa: std.mem.Allocator, io: Io) !Open { }; } -/// Closes `docs`, telling each Xcode instance once. -/// -/// `saving no` is deliberate: a CLI must not be able to raise a save dialog nobody -/// is looking at. Callers refuse to get this far while anything under the worktree -/// is unsaved, so by now there is nothing to discard. pub fn closeDocuments(gpa: std.mem.Allocator, io: Io, docs: []const Document) Error!void { var told: std.ArrayList([]const u8) = .empty; for (docs) |doc| { @@ -226,8 +187,6 @@ pub fn closeDocuments(gpa: std.mem.Allocator, io: Io, docs: []const Document) Er fn closeIn(gpa: std.mem.Allocator, io: Io, bundle: []const u8, docs: []const Document) Error!void { const script = try std.fmt.allocPrint(gpa, close_script, .{try quote(gpa, bundle)}); - // The paths go as arguments rather than into the script, so a path lcc did not - // write cannot end up as AppleScript source. var argv: std.ArrayList([]const u8) = .empty; try argv.appendSlice(gpa, &.{ "osascript", "-e", script, "--" }); for (docs) |doc| { @@ -241,13 +200,6 @@ fn closeIn(gpa: std.mem.Allocator, io: Io, bundle: []const u8, docs: []const Doc } } -/// The app bundle of every Xcode running right now, deduplicated. -/// -/// Found through `ps` rather than asked of macOS, because the question is which -/// *bundles* are live: a beta carries the release build's bundle id, so -/// `application id "com.apple.dt.Xcode"` silently picks one of the two — and on a -/// machine running both, the wrong pick is the one holding the worktree. Addressed -/// by path, each instance answers for itself. fn runningApps(gpa: std.mem.Allocator, io: Io) ![]const []const u8 { const out = exec.run(gpa, io, &.{ "ps", "-axo", "comm=" }, null) catch return &.{}; defer out.deinit(gpa); @@ -272,8 +224,6 @@ fn parseApps(gpa: std.mem.Allocator, listing: []const u8) ![]const []const u8 { return apps.toOwnedSlice(gpa); } -/// One round trip per instance: its windows, then whatever it has unsaved. -/// Null when that Xcode did not answer. fn query(gpa: std.mem.Allocator, io: Io, bundle: []const u8) ?[]const u8 { const script = std.fmt.allocPrint(gpa, list_script, .{quote(gpa, bundle) catch return null}) catch return null; @@ -314,7 +264,6 @@ fn collect( } } -/// A path as an AppleScript string literal. fn quote(gpa: std.mem.Allocator, path: []const u8) ![]const u8 { var out: std.ArrayList(u8) = .empty; for (path) |c| { @@ -324,8 +273,6 @@ fn quote(gpa: std.mem.Allocator, path: []const u8) ![]const u8 { return out.toOwnedSlice(gpa); } -/// `with timeout` keeps a busy Xcode from holding the whole command hostage — the -/// default Apple event timeout is a full minute. const list_script = \\set out to "" \\with timeout of 5 seconds @@ -369,7 +316,6 @@ test "shallowest match wins, workspace beats project at equal depth" { defer arena_state.deinit(); const arena = arena_state.allocator(); - // A deep project, plus a workspace and a project side by side one level up. try tmp.dir.createDirPath(io, "App/Deep/Nested/Thing.xcodeproj"); try tmp.dir.createDirPath(io, "App/Thing.xcodeproj"); try tmp.dir.createDirPath(io, "App/Thing.xcworkspace"); @@ -393,7 +339,6 @@ test "Package.swift is found when nothing else is" { defer arena_state.deinit(); try tmp.dir.writeFile(io, .{ .sub_path = "Package.swift", .data = "// swift-tools-version:5.9\n" }); - // Dependency output must never be picked up. try tmp.dir.createDirPath(io, "node_modules/thing.xcodeproj"); const found = (try findTarget(arena_state.allocator(), io, root, 4)).?; @@ -406,8 +351,6 @@ test "a beta running beside the release build is two instances, not one" { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); - // What `ps -axo comm=` looks like with both open: helper processes belonging to - // Xcode are all over it, and only the app executables count. const listing = \\/Applications/Xcode.app/Contents/MacOS/Xcode \\/Users/me/Downloads/Xcode-beta.app/Contents/MacOS/Xcode @@ -443,8 +386,6 @@ test "a listing splits into windows and unsaved work" { try std.testing.expectEqual(@as(usize, 1), unsaved.items.len); try std.testing.expectEqualStrings("App.xcodeproj", workspaces.items[0].name()); - // Only what belongs to this worktree comes back, and the other project's - // window is left out of both lists. const held: Open = .{ .workspaces = workspaces.items, .unsaved = unsaved.items }; const here = try held.inside(arena, io, "/Users/me/Projects/App/.lcc/worktrees/pe-101"); try std.testing.expectEqual(@as(usize, 1), here.workspaces.len); @@ -480,7 +421,6 @@ test "an unanswered Xcode is not an empty one" { const held: Open = .{ .unanswered = true }; try std.testing.expect(held.empty()); - // The doubt has to survive the filter, or the caller reports silence as safety. const here = try held.inside(arena_state.allocator(), io, "/Users/me/Projects/App"); try std.testing.expect(here.unanswered); } From eae9eceb604c6d952dfe27f3aee80504b1c37a07 Mon Sep 17 00:00:00 2001 From: Pfriedrix Date: Fri, 7 Aug 2026 17:16:37 +0300 Subject: [PATCH 2/2] fix(watch): route a hook report to the session that sent it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon matched every report against the worktree path in the payload and took the first session it found there. A worktree is not a unique key — `lcc open` will start a second session in one that already has another — so with four sessions in the same worktree every hook landed on the same row. Measured on PE-289, four sessions deep: the row the reports landed on had already exited, `apply` keeps `exited` terminal, so nothing moved except its `last_event_at`, which is why a dead session carried an activity timestamp two minutes younger than its own death. The two live agents under it were never updated at all — frozen at the `idle` their first byte of output set, whatever they went on to do. The dashboard then merged the worktree onto that same first row, so it reported a corpse for a worktree with two agents working in it, and enter on that row could not attach. Each session now gets its own settings file, `hooks-.json`, with its id baked into every hook command line beside the event — the same reason the event is baked in rather than parsed out of a payload. Reports name their session and route exactly. A report with no id still falls back to the worktree, which is what a session started by an older daemon sends, and both that fallback and the dashboard's merge now prefer a session that is still alive over one that has exited. --- CLAUDE.md | 8 +++ src/commands/watch.zig | 56 +++++++++++++-- src/daemon.zig | 157 +++++++++++++++++++++++++++++++++++++---- src/main.zig | 4 ++ src/watch_client.zig | 2 + src/watch_hooks.zig | 25 ++++--- src/watch_paths.zig | 30 +++++++- src/wire.zig | 1 + 8 files changed, 255 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 52c4eb8..b117c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,14 @@ 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 session's hook settings file is per session, not per daemon.** `watch_paths.hooksFor` + names it `hooks-.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 + one shared `hooks.json` compiles and looks tidier, and it silently routes every session's + hooks to whichever session in that worktree was registered first — including a dead one, + which then eats the live sessions' updates while they sit frozen on whatever their first + byte of output set. The worktree path is *not* a unique key: `lcc open` will happily start + a second session in a worktree that already has one. ## Style diff --git a/src/commands/watch.zig b/src/commands/watch.zig index 74328b1..51e7a8c 100644 --- a/src/commands/watch.zig +++ b/src/commands/watch.zig @@ -26,6 +26,7 @@ pub const Opts = struct { pub const HookOpts = struct { socket: ?[]const u8 = null, event: ?[]const u8 = null, + session: ?[]const u8 = null, }; pub const Row = struct { @@ -418,11 +419,14 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table. return rows.toOwnedSlice(arena); } -fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.Session { +pub fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.Session { + var fallback: ?sessions.Session = null; for (list) |s| { - if (std.mem.eql(u8, s.worktree, worktree)) return s; + if (!std.mem.eql(u8, s.worktree, worktree)) continue; + if (s.parsedStatus() != .exited) return s; + if (fallback == null) fallback = s; } - return null; + return fallback; } fn issueOf(gpa: std.mem.Allocator, branch: []const u8) ?[]const u8 { @@ -494,7 +498,15 @@ 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; - watch_client.report(app, opts.socket, payload.cwd, payload.session_id, event, payload.permission_mode); + watch_client.report( + app, + opts.socket, + payload.cwd, + payload.session_id, + event, + payload.permission_mode, + opts.session orelse "", + ); } test "the --json keys name sessions, never the process behind them" { @@ -519,3 +531,39 @@ test "an empty snapshot still carries both flags, rather than dropping them" { try std.testing.expect(std.mem.indexOf(u8, body, "\"sessions_live\": false") != null); try std.testing.expect(std.mem.indexOf(u8, body, "\"outdated_build\": false") != null); } + +test "a worktree row shows the session that is alive, not the first one recorded" { + const dead: sessions.Session = .{ + .id = "s-00000005", + .worktree = "/w/pe-289", + .branch = "feature/pe-289", + .issue = "PE-289", + .repo_root = "/r", + .pid = 34387, + .status = "exited", + .status_at = 1000, + .started_at = 900, + .last_activity_at = 1000, + .exit_code = 0, + }; + var live = dead; + live.id = "s-00000009"; + live.status = "idle"; + live.exit_code = null; + + const picked = findSession(&.{ dead, live }, "/w/pe-289").?; + if (!std.mem.eql(u8, picked.id, "s-00000009")) { + std.debug.print( + "picked {s} ({s}) over the live {s}: the row reports a corpse while an agent " ++ + "is working in that worktree, and enter on it starts yet another session " ++ + "instead of attaching.\n", + .{ picked.id, picked.status, live.id }, + ); + return error.TestExpectedEqual; + } + + const only_dead = findSession(&.{dead}, "/w/pe-289").?; + try std.testing.expectEqualStrings("s-00000005", only_dead.id); + + try std.testing.expect(findSession(&.{ dead, live }, "/w/somewhere-else") == null); +} diff --git a/src/daemon.zig b/src/daemon.zig index 6bb1cfc..84a41b3 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -170,7 +170,6 @@ const Loop = struct { app: app_mod.App, opts: Options, bound: *Bound, - hooks_path: []const u8, list: std.ArrayList(watch_session.Session) = .empty, clients: std.ArrayList(Client) = .empty, wake_r: std.posix.fd_t, @@ -192,10 +191,21 @@ const Loop = struct { } fn findByWorktree(self: *Loop, cwd: []const u8) ?*watch_session.Session { + var fallback: ?*watch_session.Session = null; for (self.list.items) |*s| { - if (std.mem.eql(u8, s.worktree, cwd)) return s; + if (!std.mem.eql(u8, s.worktree, cwd)) continue; + if (s.status != .exited) return s; + if (fallback == null) fallback = s; } - return null; + return fallback; + } + + fn route(self: *Loop, session: []const u8, cwd: []const u8) ?*watch_session.Session { + if (session.len > 0) { + if (self.find(session)) |s| return s; + return null; + } + return self.findByWorktree(cwd); } fn send(self: *Loop, client: *Client, t: wire.Type, payload: []const u8) void { @@ -281,18 +291,17 @@ pub fn run(app: app_mod.App, opts: Options) !void { .app = app, .opts = opts, .bound = &bound, - .hooks_path = try writeHookSettings(app), .wake_r = wake[0], .started_at = app_mod.nowSeconds(app.io), }; try serve(&loop); } -fn writeHookSettings(app: app_mod.App) ![]const u8 { +fn writeHookSettings(app: app_mod.App, session_id: []const u8) ![]const u8 { const exe = try exec.selfPath(app.gpa, app.io); const socket_path = try watch_paths.socket(app.gpa, app.environ); - const body = try watch_hooks.settingsJson(app.gpa, exe, socket_path); - const path = try watch_paths.hooks(app.gpa, app.environ); + const body = try watch_hooks.settingsJson(app.gpa, exe, socket_path, session_id); + const path = try watch_paths.hooksFor(app.gpa, app.environ, session_id); try Io.Dir.cwd().writeFile(app.io, .{ .sub_path = path, .data = body }); return path; } @@ -483,7 +492,7 @@ fn handleFrame(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) void { .hook => { const body = wire.parse(wire.Hook, gpa, frame) catch return; const event = watch_hooks.Event.parse(body.event) orelse return; - const session = loop.findByWorktree(body.cwd) orelse return; + const session = loop.route(body.session, body.cwd) orelse return; if (session.note(event, body.permission_mode, at)) loop.dirty = true; }, .attach => attachClient(loop, client, frame), @@ -515,8 +524,13 @@ fn registerSession(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) voi const id = std.fmt.allocPrint(gpa, "s-{x:0>8}", .{loop.next_id}) catch return; loop.next_id += 1; + const hooks_path = writeHookSettings(loop.app, id) catch { + loop.fail(client, "hooks_unwritable", "Could not write the session's hook settings."); + return; + }; + var argv: std.ArrayList([]const u8) = .empty; - argv.appendSlice(gpa, &.{ "--settings", loop.hooks_path }) catch return; + argv.appendSlice(gpa, &.{ "--settings", hooks_path }) catch return; argv.appendSlice(gpa, body.argv) catch return; const session = watch_session.Session.start(gpa, .{ @@ -1160,11 +1174,11 @@ test "a hook reports to the socket it was handed, not to the one its environment } }.get; - 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)); - 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; @@ -1173,6 +1187,125 @@ test "a hook reports to the socket it was handed, not to the one its environment try testing.expect(waited > 0); } +test "two sessions in one worktree each get their own status" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + + var environ: std.process.Environ.Map = .init(arena); + try environ.put("LCC_WATCH_DIR", base); + const socket_path = watch_paths.socket(arena, &environ) catch |err| switch (err) { + error.SocketPathTooLong => return error.SkipZigTest, + else => return err, + }; + + var daemon_arena: std.heap.ArenaAllocator = .init(gpa); + defer daemon_arena.deinit(); + var out_buf: [4096]u8 = undefined; + var err_buf: [4096]u8 = undefined; + var out_w: Io.Writer = .fixed(&out_buf); + var err_w: Io.Writer = .fixed(&err_buf); + const daemon_app: app_mod.App = .{ + .gpa = daemon_arena.allocator(), + .io = io, + .environ = &environ, + .ui = .{ .io = io, .out = &out_w, .err = &err_w }, + }; + + const thread = try std.Thread.spawn(.{}, runForTest, .{ daemon_app, Options{ + .foreground = true, + .idle_exit_seconds = 3600, + } }); + defer thread.join(); + + var budget: i32 = 15_000; + while (budget > 0) : (budget -= 50) { + if (Io.Dir.cwd().statFile(io, socket_path, .{})) |_| break else |_| {} + io.sleep(.fromMilliseconds(50), .awake) catch {}; + } + + var conn = try TestConn.open(arena, io, socket_path); + defer conn.close(); + var b: i32 = 15_000; + try conn.hello(arena, &b); + defer conn.send(arena, .stop, wire.Stop{ .force = true }) catch {}; + + const register = struct { + fn go(c: *TestConn, a: std.mem.Allocator, i: Io, root: []const u8, budget_ms: *i32) ![]const u8 { + try c.send(a, .register, wire.Register{ + .worktree = root, + .branch = "feature/pe-289-two-in-one-worktree", + .issue = "PE-289", + .repo_root = root, + .program = try standIn(a, i, root, stand_in_cat), + .argv = &.{}, + .env = &.{"TERM=dumb"}, + .cols = 80, + .rows = 24, + }); + const registered = try wire.parse(wire.Registered, a, try c.recv(.registered, budget_ms)); + return registered.session_id; + } + }.go; + + const first = try register(&conn, arena, io, base, &b); + const second = try register(&conn, arena, io, base, &b); + try testing.expect(!std.mem.eql(u8, first, second)); + + const statusOf = struct { + fn get(c: *TestConn, a: std.mem.Allocator, id: []const u8, budget_ms: *i32) ![]const u8 { + try c.send(a, .list, .{}); + const view = try wire.parse(wire.Snapshot, a, try c.recv(.snapshot, budget_ms)); + for (view.sessions) |s| { + if (std.mem.eql(u8, s.id, id)) return s.status; + } + return ""; + } + }.get; + + var hook_out: Io.Writer = .fixed(&out_buf); + var hook_err: Io.Writer = .fixed(&err_buf); + const hook_app: app_mod.App = .{ + .gpa = arena, + .io = io, + .environ = &environ, + .ui = .{ .io = io, .out = &hook_out, .err = &hook_err }, + }; + + watch_client.report(hook_app, socket_path, base, "claude-uuid", "waiting", "", second); + var waited: i32 = 5_000; + while (waited > 0) : (waited -= 100) { + if (std.mem.eql(u8, try statusOf(&conn, arena, second, &b), "waiting")) break; + io.sleep(.fromMilliseconds(100), .awake) catch {}; + } + if (waited <= 0) { + std.debug.print( + "{s} named itself in the report and still reads `{s}`: the daemon routed on " ++ + "the worktree instead, so the session that spoke is not the one that moved. " ++ + "A live agent stays frozen at whatever its first byte of output set.\n", + .{ second, try statusOf(&conn, arena, second, &b) }, + ); + return error.TestExpectedEqual; + } + + const stayed = try statusOf(&conn, arena, first, &b); + if (std.mem.eql(u8, stayed, "waiting")) { + std.debug.print( + "the report reached {s} as well as {s}: routing fell back to the worktree, " ++ + "which both sessions share. That is how a dead session ate the live one's " ++ + "hooks and the live one never left `idle`.\n", + .{ first, second }, + ); + return error.TestExpectedEqual; + } +} + test "a session is launched with the hook settings, read back off the real process" { const gpa = testing.allocator; const io = testing.io; @@ -1190,7 +1323,6 @@ test "a session is launched with the hook settings, read back off the real proce error.SocketPathTooLong => return error.SkipZigTest, else => return err, }; - const hooks_path = try watch_paths.hooks(arena, &environ); var daemon_arena: std.heap.ArenaAllocator = .init(gpa); defer daemon_arena.deinit(); @@ -1239,6 +1371,7 @@ test "a session is launched with the hook settings, read back off the real proce const pid = try std.fmt.allocPrint(arena, "{d}", .{body.pid}); const line = try exec.capture(arena, io, &.{ "ps", "-p", pid, "-ww", "-o", "command=" }, null); + const hooks_path = try watch_paths.hooksFor(arena, &environ, body.session_id); const flag = try std.fmt.allocPrint(arena, "--settings {s}", .{hooks_path}); try testing.expect(std.mem.indexOf(u8, line, flag) != null); try Io.Dir.cwd().access(io, hooks_path, .{}); diff --git a/src/main.zig b/src/main.zig index a159586..93b6422 100644 --- a/src/main.zig +++ b/src/main.zig @@ -500,6 +500,10 @@ fn watchHookCommand(app: app_mod.App, args: []const []const u8) !void { i += 1; if (i >= args.len) return error.MissingOptionValue; opts.event = args[i]; + } else if (eq(args[i], "--session")) { + i += 1; + if (i >= args.len) return error.MissingOptionValue; + opts.session = args[i]; } else return error.UnknownOption; } watch_cmd.hook(app, opts) catch {}; diff --git a/src/watch_client.zig b/src/watch_client.zig index 1d6a875..791ff68 100644 --- a/src/watch_client.zig +++ b/src/watch_client.zig @@ -186,6 +186,7 @@ pub fn report( session_id: []const u8, event: []const u8, permission_mode: []const u8, + session: []const u8, ) void { const opened = if (socket) |path| connectAt(app, .control, path) @@ -198,6 +199,7 @@ pub fn report( .session_id = session_id, .event = event, .permission_mode = permission_mode, + .session = session, }) catch {}; } diff --git a/src/watch_hooks.zig b/src/watch_hooks.zig index 7836d29..3ddcbf8 100644 --- a/src/watch_hooks.zig +++ b/src/watch_hooks.zig @@ -41,9 +41,15 @@ pub const blocking_matchers = [_][]const u8{ "agent_needs_input", }; -fn command(gpa: std.mem.Allocator, exe: []const u8, socket: []const u8, event: Event) ![]const u8 { - return std.fmt.allocPrint(gpa, "{s} watch-hook --socket {s} --event {s}", .{ - exe, socket, @tagName(event), +fn command( + gpa: std.mem.Allocator, + exe: []const u8, + socket: []const u8, + session: []const u8, + event: Event, +) ![]const u8 { + return std.fmt.allocPrint(gpa, "{s} watch-hook --socket {s} --session {s} --event {s}", .{ + exe, socket, session, @tagName(event), }); } @@ -51,11 +57,12 @@ pub fn settingsJson( gpa: std.mem.Allocator, exe: []const u8, socket: []const u8, + session: []const u8, ) ![]u8 { - const waiting = try command(gpa, exe, socket, .waiting); - const active = try command(gpa, exe, socket, .active); - const idle = try command(gpa, exe, socket, .idle); - const ended = try command(gpa, exe, socket, .ended); + const waiting = try command(gpa, exe, socket, session, .waiting); + const active = try command(gpa, exe, socket, session, .active); + const idle = try command(gpa, exe, socket, session, .idle); + const ended = try command(gpa, exe, socket, session, .ended); var blocking: std.ArrayList(Entry) = .empty; for (blocking_matchers) |matcher| { @@ -111,7 +118,7 @@ test "the settings name every event and bake the state into each command" { defer arena_state.deinit(); const arena = arena_state.allocator(); - const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/home/me/.config/lcc/daemon.sock"); + const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/home/me/.config/lcc/daemon.sock", "s-00000007"); const Schema = struct { hooks: struct { @@ -238,7 +245,7 @@ test "plan mode needs no hook of its own" { defer arena_state.deinit(); const arena = arena_state.allocator(); - const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/s.sock"); + const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/s.sock", "s-00000001"); 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| { diff --git a/src/watch_paths.zig b/src/watch_paths.zig index 18b6a95..95ceff5 100644 --- a/src/watch_paths.zig +++ b/src/watch_paths.zig @@ -31,9 +31,14 @@ pub fn lock(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![] return std.fs.path.join(gpa, &.{ base, "daemon.lock" }); } -pub fn hooks(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 { +pub fn hooksFor( + gpa: std.mem.Allocator, + environ: *const std.process.Environ.Map, + session_id: []const u8, +) ![]const u8 { const base = try dir(gpa, environ); - return std.fs.path.join(gpa, &.{ base, "hooks.json" }); + const name = try std.fmt.allocPrint(gpa, "hooks-{s}.json", .{session_id}); + return std.fs.path.join(gpa, &.{ base, name }); } pub fn logFile(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 { @@ -65,10 +70,29 @@ test "LCC_WATCH_DIR moves the socket, the lock and the hooks together" { try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.sock", try socket(arena, &environ)); try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.lock", try lock(arena, &environ)); - try std.testing.expectEqualStrings("/tmp/lcc-test/hooks.json", try hooks(arena, &environ)); try std.testing.expectEqualStrings("/tmp/lcc-test/daemon.log", try logFile(arena, &environ)); } +test "each session gets a settings file of its own, named for it" { + 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"); + + try std.testing.expectEqualStrings( + "/tmp/lcc-test/hooks-s-00000001.json", + try hooksFor(arena, &environ, "s-00000001"), + ); + try std.testing.expect(!std.mem.eql( + u8, + try hooksFor(arena, &environ, "s-00000001"), + try hooksFor(arena, &environ, "s-00000002"), + )); +} + test "an empty override is not an override" { const gpa = std.testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(gpa); diff --git a/src/wire.zig b/src/wire.zig index 065612f..6378f2c 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -244,6 +244,7 @@ pub const Hook = struct { session_id: []const u8, event: []const u8, permission_mode: []const u8 = "", + session: []const u8 = "", }; pub const Kill = struct { session_id: []const u8, signal: []const u8 }; pub const Stop = struct { force: bool };