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
54 changes: 29 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ lcc start PE-256 --plan plan.md # a plan already exists — skip plan mode
lcc start PE-256 --json # resolve it and print the result instead of launching an agent
lcc start PE-256 --no-watch # run in this terminal; the session dies with it
lcc start --all # ignore the activeStates filter
lcc watch # the sessions the daemon is running, live
lcc watch --json # the same as a one-shot, for a caller that parses it
lcc daemon --status # is one running, and how many sessions
lcc daemon --stop # ask it to exit
lcc issue show PE-256 # state, project, labels, description — reads nothing else
lcc issue show PE-256 --json # the same, for a caller that parses it
lcc issue state PE-256 "In Progress" # move it, by the name on the board
Expand All @@ -78,6 +74,8 @@ lcc config watchByDefault false # or name one directly, with no prompt
lcc list # dashboard of every worktree (--local to skip the network columns)
lcc stats # what each worktree has spent in Claude Code
lcc open # the worktrees, and what is running in them
lcc open --json # the sessions as a one-shot, for a caller that parses it
lcc open --stop-all # end every session running in the background
lcc open xcode # pick a worktree and open it in Xcode instead
lcc remove # select worktrees, remove them + their branches + Xcode build data
lcc remove --merged # bulk: every worktree and branch whose work already landed
Expand All @@ -93,14 +91,14 @@ Token statistics and session cleanup read Claude Code transcripts under `~/.clau

### Sessions that outlive the terminal

`lcc start` hands the session to a small daemon that owns its pty, rather than
taking over the terminal itself. Closing the window, losing an ssh link or
rebooting the terminal emulator no longer kills the agent — the work carries on
and `lcc watch` shows it.
`lcc start` runs the session in the background rather than taking over the
terminal it was called from. Closing the window, losing an ssh link or rebooting
the terminal emulator no longer kills the agent — the work carries on and
`lcc open` shows it.

`lcc open` is the dashboard: every worktree of the repo you are in, and
whatever is running in each. Enter opens one — attaching if a session is
already there, starting one if not. `lcc watch` is an alias.
already there, starting one if not.

```
ISSUE STATUS BRANCH AGE
Expand Down Expand Up @@ -145,24 +143,28 @@ schedules each still disturbed its rendering. Doing it properly means emulating
a terminal, which is more than one line of text is worth, so the row went back
to the agent.

**Two things worth knowing.** Sessions do not survive the *daemon* dying —
closing the pty revokes it and the agent gets a hangup, the same property tmux
has. And `lcc remove` does not yet check whether a worktree has a live session
in it, so check `lcc watch` before removing one.
`lcc open --stop-all` ends every background session at once, and `--force`
kills them rather than letting them finish. Otherwise they retire on their own
30 minutes after the last one is done, so nothing accumulates from a month of
finished work.

A daemon outlives rebuilds. It holds the image it started with, so after
`zig build` the daemon answering you can be many commits behind the `lcc` that
is asking — same path, same protocol, older behaviour, and every symptom of it
looks like a bug in the new code. The dashboard says so when it happens
(`daemon_outdated` in `--json`). It retires itself 30 minutes after the last
session ends; `lcc daemon --stop` is immediate but signals every session's
process group, which ends them.
**Two things worth knowing.** Sessions run in one shared background process, and
they do not survive it: if it dies the ptys are revoked and every agent gets a
hangup, the same property tmux has. And `lcc remove` does not yet check whether a
worktree has a live session in it, so check `lcc open` before removing one.

Sessions also hold the build of `lcc` they started under. After `zig build` the
sessions still running can be many commits behind the `lcc` you are typing —
older behaviour reached through the same command, and every symptom of it looks
like a bug in the new code. The dashboard says so when it happens
(`outdated_build` in `--json`), and `lcc open --stop-all` clears it, at the cost
of ending the work in flight.

Turn the whole thing off with `lcc config watchByDefault false`, or per
invocation with `lcc start --no-watch`. Either way `lcc start` goes back to
running Claude Code in the terminal it was called from. If the daemon cannot be
reached, `lcc start` says so and falls back to that on its own rather than
failing.
running Claude Code in the terminal it was called from. If a session cannot be
put in the background, `lcc start` says so and falls back to that on its own
rather than failing.

### The worktree dashboard

Expand Down Expand Up @@ -470,7 +472,9 @@ Failures take the same shape as `lcc start --json` — JSON on stdout, the human

### `lcc open`

Bare `lcc open` opens Claude Code. `lcc open claude` and `lcc open xcode` name the target explicitly, case-insensitively. Claude resumes only when the worktree has transcripts; `--no-resume` starts it fresh.
Bare `lcc open` is the dashboard above; `lcc open claude` names that target explicitly and `lcc open xcode` picks the other one, case-insensitively.

`--json` prints the sessions as a one-shot instead of drawing the dashboard, and `--stop-all` (with `--force` to kill rather than ask) ends every background session. Both are non-interactive, so both work from a script or a tool call, where the dashboard cannot. Neither means anything to `lcc open xcode`, which rejects them rather than ignoring them.

### `lcc open xcode`

Expand Down Expand Up @@ -669,7 +673,7 @@ months after anyone typed it.
| `startTaskCommand` | `""` | Passed to Claude Code as its initial prompt. Placeholders: `{identifier}`, `{branch}`, `{url}`, `{plan}` (the `--plan` path, empty without it — and required in the template before `--plan` is accepted) |
| `linkPatterns` | `[".env", ".env.*", "CLAUDE.md", "CLAUDE.local.md", ".claude/settings.local.json"]` | Which files to symlink into each worktree |
| `linkExclude` | `[".env.example", ".env.sample", ".env.template"]` | Which of those to skip |
| `watchByDefault` | `true` | Hand new sessions to the daemon so they outlive the terminal |
| `watchByDefault` | `true` | Run new sessions in the background so they outlive the terminal |
| `planMode` | `true` | Open new sessions in plan mode. `--plan <file>` turns it off regardless |
| `resumeSessions` | `true` | `lcc open` resumes the worktree's last session |
| `showTokens` | `true` | The TOKENS column in `lcc list`. Off skips reading transcripts, which is that column's whole cost |
Expand Down
34 changes: 9 additions & 25 deletions src/commands/daemon.zig
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
//! `lcc daemon` — run the session daemon, stop it, or ask what it is doing.
//! `lcc daemon` — run the session daemon, or ask what it is doing.
//!
//! Named explicitly rather than left implicit. `lcc start --watch` starts one
//! on demand, but a background process a user cannot see, inspect or stop is
//! not something to acquire by accident.
//! **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");
const daemon = @import("../daemon.zig");
const sessions = @import("../sessions.zig");
const ui = @import("../ui.zig");
const watch_client = @import("../watch_client.zig");
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,
stop: bool = false,
/// SIGKILL the sessions rather than asking them to finish.
force: bool = false,
status: bool = false,
json: bool = false,
};

pub fn run(app: app_mod.App, opts: Opts) !void {
if (opts.status) return status(app, opts);
if (opts.stop) return stop(app, opts);
return daemon.run(app, .{ .foreground = opts.foreground });
}

Expand Down Expand Up @@ -67,18 +66,3 @@ fn status(app: app_mod.App, opts: Opts) !void {
});
app.ui.hint("Socket: {s}", .{socket_path});
}

fn stop(app: app_mod.App, opts: Opts) !void {
var conn = (watch_client.connectExisting(app, .control) catch null) orelse {
app.ui.info("No daemon running.", .{});
return;
};
defer conn.close(app.io);

try conn.sendControl(app.gpa, .stop, wire.Stop{ .force = opts.force });
if (opts.force) {
app.ui.success("Asked the daemon to stop and kill its sessions.", .{});
} else {
app.ui.success("Asked the daemon to stop.", .{});
}
}
10 changes: 7 additions & 3 deletions src/commands/start.zig
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ pub fn run(app: app_mod.App, opts: Opts) !void {
})) |started| {
if (opts.no_attach) {
app.ui.success("Session {s} running in the background.", .{started.id});
app.ui.hint("It survives this terminal closing — `lcc watch` shows it.", .{});
app.ui.hint("It survives this terminal closing — `lcc open` shows it.", .{});
return;
}
app.ui.flush();
Expand All @@ -232,8 +232,12 @@ pub fn run(app: app_mod.App, opts: Opts) !void {
// 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.
app.ui.warn("Could not reach the lcc daemon ({s}) — starting in this terminal instead.", .{@errorName(err)});
app.ui.hint("The session will not survive this terminal closing. `lcc daemon --status` says more.", .{});
//
// 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.", .{});
}
}

Expand Down
122 changes: 102 additions & 20 deletions src/commands/watch.zig
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
//! `lcc watch` — what the daemon is running, and the handler its hooks call.
//! `lcc open` — the worktrees and the sessions running in them, and the handler
//! its hooks call.
//!
//! Only the non-interactive half exists so far: `--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`.
//! 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;
Expand All @@ -22,9 +27,18 @@ const linear = @import("../linear.zig");
const mcp = @import("../mcp.zig");
const start_cmd = @import("start.zig");
const watch_table = @import("../watch_table.zig");
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
Expand All @@ -34,7 +48,7 @@ pub const HookOpts = struct {
event: ?[]const u8 = null,
};

const Row = struct {
pub const Row = struct {
id: []const u8,
issue: ?[]const u8,
branch: []const u8,
Expand All @@ -53,6 +67,10 @@ const Row = struct {
/// 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`,
Expand All @@ -63,11 +81,28 @@ pub fn run(app: app_mod.App, opts: Opts) anyerror!void {
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 watch --json`.", .{});
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.", .{});
return;
};
defer conn.close(app.io);

try conn.sendControl(app.gpa, .stop, wire.Stop{ .force = opts.force });
if (opts.force) {
app.ui.success("Killed every background session.", .{});
} else {
app.ui.success("Asked every background session to finish.", .{});
}
}

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.
Expand Down Expand Up @@ -105,10 +140,14 @@ fn outdatedDaemon(app: app_mod.App, arena: std.mem.Allocator, built: ?i64) bool
}

/// What to say about it, in one line, in the two places a person will be looking.
const outdated_warning = "The daemon running these sessions is an older build of lcc than this one.";
/// Naming what `--stop` costs, because it signals every session's process group
/// and a hint that omitted that would be advice to lose work.
const outdated_hint = "It retires itself 30 minutes after the last session ends. `lcc daemon --stop` is immediate, but ends them now.";
///
/// 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 {
return .{
Expand All @@ -125,16 +164,27 @@ 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 });
}

fn emit(app: app_mod.App, opts: Opts, rows: []const Row, live: bool, outdated: bool, now: i64) !void {
if (opts.json) {
const body = try std.json.Stringify.valueAlloc(app.gpa, .{
.daemon_running = live,
// Always present, like every other key here. A caller that sees it
// true knows the daemon may not behave the way this binary's
// contract says — which is otherwise indistinguishable from a bug.
.daemon_outdated = outdated,
.sessions = rows,
}, .{ .whitespace = .indent_2 });
const body = try snapshotJson(app.gpa, rows, live, outdated);
app.ui.payload("{s}\n", .{body});
app.ui.flush();
return;
Expand All @@ -148,7 +198,7 @@ fn emit(app: app_mod.App, opts: Opts, rows: []const Row, live: bool, outdated: b
return;
}

if (!live) app.ui.warn("No daemon is running — showing the last recorded state.", .{});
if (!live) app.ui.warn("Nothing is running these sessions — showing the last recorded state.", .{});
if (live and outdated) {
app.ui.warn("{s}", .{outdated_warning});
app.ui.hint("{s}", .{outdated_hint});
Expand Down Expand Up @@ -606,3 +656,35 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void {

watch_client.report(app, payload.cwd, payload.session_id, event);
}

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

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