diff --git a/CLAUDE.md b/CLAUDE.md index 88c186c..b117c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,12 +116,29 @@ 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 -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/README.md b/README.md index 60628e4..11c1d53 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,14 @@ prompt or a question. `◐ active` is a turn in flight, `○ idle` is finished. Those come from Claude Code's own hooks rather than from reading its screen, so a new Claude Code release cannot quietly make them wrong. +A session also reads `● waiting` when it has been `active` for a quarter of an +hour with nothing reported at all. That is not a turn Claude Code said anything +about — it is one lcc has lost track of, because the turn wedged or because it +is blocked on something whose notification never arrived. Either way it wants a +person, and `○ idle` would be a claim only a finished turn earns: a session that +had stopped and needed opening used to be painted the same dim circle as the +ones that had nothing left to do. + `◈ plan` is a turn in flight too, in Claude Code's plan mode — it is researching and writing a plan, and has not been approved to touch any files yet. Since `planMode` defaults to on, every session starts there and leaves when you 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..51e7a8c 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,24 +19,14 @@ 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, + session: ?[]const u8 = null, }; pub const Row = struct { @@ -64,33 +42,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 +69,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 +84,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 +112,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 +129,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 +166,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 +182,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 +198,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 +211,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 +223,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 +238,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 +247,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 +260,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 +275,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 +294,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 +312,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 +352,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 +360,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 +379,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); @@ -561,30 +419,22 @@ 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; } -/// `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 +469,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 +482,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,10 +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; - // `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); + 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" { @@ -669,28 +515,55 @@ 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); 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/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..84a41b3 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,10 +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, wake_r: std.posix.fd_t, @@ -287,12 +190,22 @@ 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 { + 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 { @@ -316,12 +229,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 +241,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 +249,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 +278,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); @@ -400,26 +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); } -/// 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 { +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; } @@ -432,11 +314,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 +331,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 +342,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 +369,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 +385,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 +393,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 +416,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 +454,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,10 +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; - // 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; + 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), @@ -670,14 +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; - // 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. + 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, .{ @@ -702,9 +555,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 +565,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 +594,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 +618,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 +635,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 +647,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 +671,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 +683,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 +732,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 +751,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 +759,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 +790,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 +809,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 +834,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 +851,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 +879,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 +913,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 +940,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 +953,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 +971,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 +987,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 +1012,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 +1027,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 +1037,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 +1054,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 +1079,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 +1093,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 +1139,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 +1155,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,16 +1174,11 @@ 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", ""); + watch_client.report(hook_app, null, base, "s", "waiting", "", ""); io.sleep(.fromMilliseconds(300), .awake) catch {}; try testing.expectEqualStrings("starting", try statusNow(&conn, arena, &b)); - // Handed the daemon's own socket, exactly as `watch_hooks.settingsJson` - // writes it, the same report lands. - watch_client.report(hook_app, socket_path, base, "s", "waiting", ""); + watch_client.report(hook_app, socket_path, base, "s", "waiting", "", ""); var waited: i32 = 5_000; while (waited > 0) : (waited -= 100) { if (std.mem.eql(u8, try statusNow(&conn, arena, &b), "waiting")) break; @@ -1429,13 +1187,126 @@ 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" { - // 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); @@ -1452,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(); @@ -1485,9 +1355,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 +1371,11 @@ 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 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, .{}); - // 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 +1454,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 +1463,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 +1526,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 +1608,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..93b6422 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; } @@ -524,11 +500,12 @@ 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; } - // 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 +526,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 +535,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..791ff68 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, @@ -246,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) @@ -258,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 8ea093e..3ddcbf8 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,43 +35,34 @@ 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", "agent_needs_input", }; -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), +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), }); } -/// 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, 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| { @@ -166,32 +82,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 +103,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, @@ -219,50 +118,43 @@ 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"); - // 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 +164,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 +188,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 +197,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 +219,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 +232,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,11 +245,7 @@ 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"); + 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| { @@ -403,8 +254,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..95ceff5 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,35 @@ 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 { +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 }); } -/// 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,30 +68,42 @@ 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)); } +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); 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 +117,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 +127,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 2ca9783..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,11 +322,10 @@ 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()); } -test "a decayed session goes idle, and a waiting one never does" { +test "a session that went silent mid-turn asks to be opened" { const gpa = testing.allocator; var scratch = try ring.Ring.init(gpa, 64); defer scratch.deinit(gpa); @@ -433,11 +334,8 @@ test "a decayed session goes idle, and a waiting one never does" { _ = 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.idle, s.status); + 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); @@ -449,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); @@ -484,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); @@ -501,15 +383,11 @@ test "a change no reader can see costs no registry write" { _ = s.note(.active, "plan", 1000); s.dirty = false; - // The lifecycle decays `active` to `idle` underneath, but both compose to - // `plan`, so the row is unchanged. Reporting this as dirty would be a file - // write per session per quarter hour to record nothing. - try testing.expect(!s.tick(1000 + watch_status.active_decay_seconds + 1)); + 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 decay that already happened. 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 43685c1..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 -/// it decays to `idle`. -/// -/// 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 a session wrongly shown -/// as finished is worse than one shown as 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,44 +17,18 @@ pub fn apply(current: Status, event: Event) Status { }; } -/// The status after `elapsed` seconds without a further event. -/// -/// Only `active` and `starting` decay. `waiting` must not: a permission prompt -/// left unanswered overnight is still a permission prompt, and ageing it into -/// `idle` would hide the one state the user actually has to act on. 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) .idle else current, + .active, .starting => if (elapsed > active_decay_seconds) .waiting else current, else => current, }; } -/// 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) { @@ -94,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)); @@ -104,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)); } @@ -117,82 +58,51 @@ test "SessionEnd reports exited without waiting for the reap" { try testing.expectEqual(Status.exited, apply(.waiting, .ended)); } -test "active decays to idle, but waiting never does" { - // The backstop for a hook that never arrived — a failed handler, or a - // --settings that did not reach the session. +test "a turn that went silent asks for a person, rather than claiming it finished" { try testing.expectEqual(Status.active, decay(.active, active_decay_seconds)); - try testing.expectEqual(Status.idle, decay(.active, active_decay_seconds + 1)); - try testing.expectEqual(Status.idle, decay(.starting, active_decay_seconds + 1)); + try testing.expectEqual(Status.waiting, decay(.active, active_decay_seconds + 1)); + try testing.expectEqual(Status.waiting, decay(.starting, active_decay_seconds + 1)); + + 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 nothing resurrects a finished session. - try testing.expectEqual(Status.exited, decay(.exited, active_decay_seconds * 100)); try testing.expectEqual(Status.idle, decay(.idle, active_decay_seconds * 100)); + 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)); } test "resolve is decay expressed against a wall clock" { try testing.expectEqual(Status.active, resolve(.active, 1_000, 1_000 + active_decay_seconds)); - try testing.expectEqual(Status.idle, resolve(.active, 1_000, 1_000 + active_decay_seconds + 1)); + try testing.expectEqual(Status.waiting, resolve(.active, 1_000, 1_000 + active_decay_seconds + 1)); try testing.expectEqual(Status.waiting, resolve(.waiting, 1_000, 1_000 + 86_400)); } 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..6378f2c 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,19 +239,12 @@ 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 = "", + session: []const u8 = "", }; pub const Kill = struct { session_id: []const u8, signal: []const u8 }; pub const Stop = struct { force: bool }; @@ -361,33 +257,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 +284,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 +293,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 +317,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 +325,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 +333,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 +349,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 +368,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 +377,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 +395,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 +433,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); }