diff --git a/README.md b/README.md index a7d4ce3..8d7a103 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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` @@ -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 ` 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 | diff --git a/src/commands/daemon.zig b/src/commands/daemon.zig index d8ed8a4..d17aa53 100644 --- a/src/commands/daemon.zig +++ b/src/commands/daemon.zig @@ -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 }); } @@ -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.", .{}); - } -} diff --git a/src/commands/start.zig b/src/commands/start.zig index b32eaba..5270fe1 100644 --- a/src/commands/start.zig +++ b/src/commands/start.zig @@ -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(); @@ -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.", .{}); } } diff --git a/src/commands/watch.zig b/src/commands/watch.zig index 31cc090..be29791 100644 --- a/src/commands/watch.zig +++ b/src/commands/watch.zig @@ -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; @@ -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 @@ -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, @@ -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`, @@ -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. @@ -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 .{ @@ -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; @@ -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}); @@ -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); +} diff --git a/src/main.zig b/src/main.zig index 9bad816..2f9fbff 100644 --- a/src/main.zig +++ b/src/main.zig @@ -36,19 +36,12 @@ const usage = \\ --plan start from a plan that already exists instead of \\ opening in plan mode — reaches the agent as {plan} \\ in startTaskCommand, as a path, not inlined - \\ --no-watch run in this terminal instead of handing the session - \\ to the lcc daemon — it then dies with the terminal. - \\ The default is to watch; `lcc config watchByDefault - \\ false` makes --no-watch the default instead - \\ --watch hand it to the daemon even when that is off + \\ --no-watch run in this terminal, so the session dies with it. + \\ The default is to run it in the background; + \\ `lcc config watchByDefault false` makes --no-watch + \\ the default instead + \\ --watch run it in the background even when that is off \\ --no-attach print the session id instead of opening the dashboard - \\ watch Alias for `open` - \\ daemon Run or control the session daemon - \\ --foreground stay attached to this terminal - \\ --stop ask a running daemon to exit - \\ --force kill its sessions rather than letting them finish - \\ --status whether one is running, and how many sessions - \\ --json with --status, print it instead of a summary \\ issue PE-N Read or write one Linear issue — no repository needed \\ show state, project, labels and description — read-only \\ state "" move it, by the team's own workflow-state name @@ -81,6 +74,8 @@ const usage = \\ open | o The worktrees, and what is running in them \\ enter opens one · n takes another issue · x kills \\ --json print the sessions instead of the dashboard + \\ --stop-all end every session running in the background + \\ --force kill them rather than letting them finish \\ open xcode Pick a worktree and open it in Xcode instead \\ remove | rm Select and remove one or more worktrees, branches, and build data \\ --merged bulk: every worktree and branch already merged @@ -155,12 +150,16 @@ fn dispatch(app: app_mod.App, args: []const []const u8) !void { if (eq(first, "config")) return configCommand(app, args[1..]); if (eq(first, "list") or eq(first, "ls")) return listCommand(app, args[1..]); if (eq(first, "open") or eq(first, "o")) return openCommand(app, args[1..]); - if (eq(first, "watch")) return watchCommand(app, args[1..]); if (eq(first, "remove") or eq(first, "rm")) return removeCommand(app, args[1..]); if (eq(first, "clean")) return cleanCommand(app, args[1..]); 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; @@ -353,14 +352,18 @@ fn authCommand(app: app_mod.App, args: []const []const u8) !void { fn openCommand(app: app_mod.App, args: []const []const u8) !void { var target_arg: ?[]const u8 = null; var resume_opt: ?bool = null; - var passthrough: std.ArrayList([]const u8) = .empty; + var watch_opts: watch_cmd.Opts = .{}; for (args) |arg| { if (eq(arg, "--no-resume")) { resume_opt = false; } else if (eq(arg, "--resume")) { resume_opt = true; } else if (eq(arg, "--json")) { - try passthrough.append(app.gpa, arg); + watch_opts.json = true; + } else if (eq(arg, "--stop-all")) { + watch_opts.stop_all = true; + } else if (eq(arg, "--force")) { + watch_opts.force = true; } else if (std.mem.startsWith(u8, arg, "-")) { return error.UnknownOption; } else if (target_arg == null) { @@ -377,7 +380,17 @@ fn openCommand(app: app_mod.App, args: []const []const u8) !void { // 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) return watchCommand(app, passthrough.items); + 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); return open_cmd.run(app, target, !orConfig(resume_opt, cfg.resumeSessions)); @@ -499,20 +512,6 @@ fn configCommand(app: app_mod.App, args: []const []const u8) !void { return config_cmd.run(machine, opts); } -fn watchCommand(app: app_mod.App, args: []const []const u8) !void { - var opts: watch_cmd.Opts = .{}; - for (args) |arg| { - if (eq(arg, "--json")) { - opts.json = true; - } 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 watch_cmd.run(machine, opts); -} - fn watchHookCommand(app: app_mod.App, args: []const []const u8) !void { var opts: watch_cmd.HookOpts = .{}; var i: usize = 0; @@ -538,10 +537,6 @@ fn daemonCommand(app: app_mod.App, args: []const []const u8) !void { for (args) |arg| { if (eq(arg, "--foreground")) { opts.foreground = true; - } else if (eq(arg, "--stop")) { - opts.stop = true; - } else if (eq(arg, "--force")) { - opts.force = true; } else if (eq(arg, "--status")) { opts.status = true; } else if (eq(arg, "--json")) { @@ -568,6 +563,25 @@ fn eq(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b); } +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.