Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<session id>.json` and `watch_hooks.settingsJson` bakes that id into every
hook command line, so a report says which session it came from. Collapsing them back into
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/`.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 0 additions & 25 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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", .{});

Expand All @@ -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| {
Expand All @@ -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,
Expand All @@ -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=<identity>` 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,
Expand All @@ -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| {
Expand Down
53 changes: 0 additions & 53 deletions src/ansi.zig
Original file line number Diff line number Diff line change
@@ -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| {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
};
Expand All @@ -116,37 +73,27 @@ 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[<u\x1b[>1u\x1b[>4;2m\x1b[?2026h";
const kept = f.filter(startup, &out);

for ([_][]const u8{ "\x1b[>1u", "\x1b[<u", "\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);
}

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));
}

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));
}

14 changes: 0 additions & 14 deletions src/app.zig
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -17,38 +15,28 @@ 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 {
entry: git.WorktreeEntry,
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);
Expand All @@ -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));
Expand Down Expand Up @@ -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| {
Expand Down
7 changes: 0 additions & 7 deletions src/claude.zig
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading