Repository navigation
Conversation
…ease workflow Daemon runs claude upgrade, reads ~/.claude/daemon/rc.json and supervises one 'claude rc' service per repo (systemd user units, launchd, or in-process fallback). CLI talks to it over a unix socket. Standalone binaries and a Homebrew formula template are published by release-claude-rc.yaml. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
Use statSync instead of GNU stat -c, and compare against realpath of the temp dir since macOS resolves /var to /private/var. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
claude rc exits without a TTY. The daemon now launches every child as 'claude-rc-daemon pty-run -- claude rc ...', which allocates a PTY with Bun's terminal spawn option, mirrors output to the service log, forwards signals and propagates the exit code. CLAUDE_RC_PTY=0 disables it. tmux was evaluated and rejected: new-session -d detaches in ~16ms, so systemd/launchd would loop-restart the service. The e2e fake claude now refuses to run without a TTY, so e2e on every OS proves the wrapper. Release workflow smoke-tests the compiled binary. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
- Desktop notifications when a repo's `claude rc` starts, exits or restarts (osascript on macOS, notify-send on Linux; absent notify-send is a no-op). - Once-a-day update check for claude-rc (GitHub releases) and Claude Code (npm), each new version announced once; persisted in state.json. - Loopback web UI served by the daemon (token + Host/Origin checks) to monitor, add/remove, restart/stop/start repos and edit settings; `claude-rc ui` opens it. New config keys: notifications, updates, ui.port. - doctor warns when notify-send is missing; spec D7 records why there is no native tray icon. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
Minimal containers such as Fedora ship no `which` binary, so the notifier never detected notify-send there and the e2e notification test timed out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
There was a problem hiding this comment.
⚠️ Ship-worthy with two P1 safety nits — tight loopback web UI, strong test coverage, good spec
⚠️ trust.ts RMW is not atomic against concurrent claude writes (spec claims it is)
⚠️ IPC unix-socket perms race between listen() and chmod 0600
⚠️ updates.ts only reads first 50 GitHub releases, no pagination
⚠️ Env values accept \n/NUL → malformed systemd-run --setenv
⚠️ Release workflow mass-closes PRs by title substring, no author scope
- ❔
basename(path) default repo name can fail NAME_RE validation
- ✅ Loopback UI defenses are solid (token + Host + Origin + CSP + JSON-only mutations)
- ✅ Tests-first across every module; real-process e2e with fake
claude
- ✅ Supervisor abstraction is clean; adapters are small and swappable
- ✅ PTY wrapper is well-justified over tmux with measurement
Click to expand for full details
trust.ts RMW is not atomic against concurrent claude writes (spec claims it is)listen() and chmod 0600updates.ts only reads first 50 GitHub releases, no pagination\n/NUL → malformed systemd-run --setenvbasename(path) default repo name can fail NAME_RE validationclaudeScope and context
6,242 added lines across three new packages (lib/claude-rc-core, services/claude-rc-daemon, apps/claude-rc-cli), a Homebrew formula template, a per-app release workflow, and a cross-OS e2e job. Spec at docs/specs/draft/claude-rc.md and plan at .claude/plans/claude-rc.md match the implementation closely; defaults are called out and justified (autoTrust=true, non-fatal claude upgrade failure, degraded-but-up on bad rc.json, children stop on daemon SIGTERM). No prior reviews on this PR.
How I evaluated
Read the spec and plan end-to-end, then walked the core lib (config, trust, ipc, pty-run, notify, updates, spec, reconcile, inproc, systemd, launchd, pick), the daemon (main, daemon, web, web-page, pty-cmd, upgrade), the CLI (args, commands), and the release/e2e workflows plus the formula template. Spot-checked tests for the modules with the highest risk (web, pty-run, trust, systemd, e2e).
Strengths (why this scores high)
- Loopback UI defenses are layered: 32-byte random token with
timingSafeEqual, mandatory loopbackHost, same-originOriginwhen sent, JSON-only mutations, strict CSP (default-src 'none',connect-src 'self'), token stripped from URL viahistory.replaceStateand persisted only insessionStorage, DOM built withtextContentonly.ui.jsonis written 0600 andchmodSync'd after the fact to handle pre-existing files. This is better than most locally-served admin UIs I review. - Tests-first discipline is real: every module has a focused unit test;
apps/claude-rc-cli/src/e2e.test.tsspawns real daemon + CLI processes against a fakeclaudeand asserts observable behavior (upgrade-then-reconcile, auto-trust writes to.claude.json, kill-and-restart gets a new pid, manual stop survives reload, invalid config keeps the current services, hand-editedrc.jsonis picked up by the file watcher). - PTY decision is well-researched: the "16ms detach" measurement rules out
tmux new-session -das default and is captured in spec D6;pty-runreports127on spawn failure and128+signalon signalled exit; the fakeclaude rcin e2e refuses to run without a TTY, so every passing e2e run also proves the wrapper works on that OS. - Supervisor abstraction:
Supervisorinterface +Execdep injection meanssystemd/launchdcan be exercised in unit tests without any system state;pickSupervisorthrows on an explicit native request that is unavailable (no silent degradation). - Workspace trust logic refuses
$HOMEand resolves worktree → main checkout correctly (trustKeyFor), with explicit tests. - Env allowlist in
spec.tskeeps the daemon's incidental env (tokens, local dev paths) out of child units and plists.
P1 findings (should fix before merge)
trust.ts read-modify-write races against claude
The spec says "atomic read-modify-write (claude edits the file too)" — the rename is atomic, but the full RMW (readFileSync on line 38 → mutate → writeFileSync(tmp) on line 51 → renameSync on line 53) is not. If claude rename-replaces ~/.claude.json between our read and our rename, our rename wins and claude's edits are lost. The daemon only enters this path on reconcile and the window is small, so loss is rare, but the spec promises more than the code delivers. Options are a flock-based lock, optimistic concurrency on mtime, or weakening the spec wording. (Inline comment on lib/claude-rc-core/src/trust.ts:32-55.)
IPC socket perms race
server.listen(path) creates the socket inode with permissions derived from the current umask. chmodSync(path, 0o600) runs immediately after, but between the two syscalls the socket is reachable by other local processes (and on a user with umask 022 the inode starts life mode 0755). The parent ~/.claude/daemon is created recursively with the default mkdir mode (also umask-derived), so it doesn't close the window. Easiest fix: process.umask(0o077) around the listen, or mkdir the daemon/run/log dirs with mode: 0o700. (Inline on lib/claude-rc-core/src/ipc.ts:62-66.)
P2 findings (polish; follow-up PR is fine)
updates.tspagination:?per_page=50with no further pages. In a monorepo publishing many apps, 50 unrelated-release stacking will hide the latestclaude-rc-v*release. Not blocking today, but a hazard as the repo accrues apps.config.tsenv values: no rejection of control chars; a stray\nin a value turns into malformedsystemd-run --setenv=KEY=multi\nline. Suggested one-line regex guard in the inline comment.- Release workflow
gh pr list --search "chore: update claude-rc to": no author filter. A human-opened PR with a similar title gets force-closed. Scope with--author "@me"(or the App slug). basename(path)default repo name can produce a name that fails[A-Za-z0-9][A-Za-z0-9._-]*validation (e.g., directory with spaces), yielding a cryptic error.commands.tspast-tense surgery:cmd + "ped"|"ed"+.replace("stoped","stopped")is harder to follow than a tiny{start:"started",stop:"stopped",restart:"restarted"}map.
What I didn't verify (per PR description)
The PR description already flags these: no real claude rc run, no real systemd/launchd exercise outside CI, no real desktop notification side-effects, no real brew install. I agree these are reasonable to defer to CI and manual smoke testing — the shape of the code and tests makes them likely to work.
Verdict
COMMENT. No bug that unambiguously breaks the feature shipped here, but the two P1 races (trust-file RMW and socket chmod window) are real and worth closing before this ends up in homebrew-devsetup. The feature set is well-scoped and the tests are strong; this is close to green.
Follow-ups:
- P1:
lib/claude-rc-core/src/trust.ts— close the RMW race (flock sidecar, optimistic re-read, or weaken spec wording to match code). - P1:
lib/claude-rc-core/src/ipc.ts— set umask aroundlisten()ormkdir(..., {mode: 0o700})on the daemon/run/log dirs so the socket is never world-connectable. - P2:
lib/claude-rc-core/src/updates.ts— paginate GitHub releases (bounded) so the latestclaude-rc-v*tag isn't buried by unrelated releases. - P2:
lib/claude-rc-core/src/config.ts— reject control chars inenvvalues. - P2:
.github/workflows/release-claude-rc.yaml— scope the "supersede older PRs" step to the automation account. - P2:
apps/claude-rc-cli/src/args.ts+services/claude-rc-daemon/src/web.ts— sanitize or surface the default name derived frombasename(path). - P2 (nit):
apps/claude-rc-cli/src/commands.ts— map-based past tense.
References
- Workflow run: https://github.com/nsheaps/.ai-agent-henry/actions/runs/38395752412/attempts/1
- GitHub API — list releases pagination behavior: https://docs.github.com/en/rest/releases/releases#list-releases
systemd-run(1)on--setenv: https://www.freedesktop.org/software/systemd/man/systemd-run.html- Unix-socket permission semantics (Linux
unix(7)bind path mode comes from umask): https://man7.org/linux/man-pages/man7/unix.7.html
| export function ensureTrusted(claudeJsonPath: string, key: string): "added" | "unchanged" { | ||
| let data: Record<string, any> = {}; | ||
| let mode = 0o600; | ||
| if (existsSync(claudeJsonPath)) { | ||
| mode = statSync(claudeJsonPath).mode & 0o777; | ||
| try { | ||
| data = JSON.parse(readFileSync(claudeJsonPath, "utf8")); | ||
| } catch { | ||
| throw new Error(`${claudeJsonPath} is not valid JSON; refusing to modify it`); | ||
| } | ||
| if (typeof data !== "object" || data === null || Array.isArray(data)) { | ||
| throw new Error(`${claudeJsonPath} is not a JSON object; refusing to modify it`); | ||
| } | ||
| } | ||
| const projects = (data.projects ??= {}); | ||
| const entry = (projects[key] ??= {}); | ||
| if (entry.hasTrustDialogAccepted === true) return "unchanged"; | ||
| entry.hasTrustDialogAccepted = true; | ||
| const tmp = `${claudeJsonPath}.claude-rc.${process.pid}.tmp`; | ||
| writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode }); | ||
| chmodSync(tmp, mode); | ||
| renameSync(tmp, claudeJsonPath); | ||
| return "added"; | ||
| } |
There was a problem hiding this comment.
P1 — Read-modify-write is not atomic against concurrent claude writers. The spec promises "atomic read-modify-write (claude edits the file too)" but the implementation is read → mutate in memory → writeFileSync(tmp) → renameSync(tmp, …). The rename is atomic for this write, but the window between the readFileSync on line 38 and the renameSync on line 53 is unlocked. If claude itself rename-replaces ~/.claude.json in that window, this call clobbers claude's changes — exactly the scenario the spec acknowledges.
The window is small (one JSON parse + stringify), and the daemon only enters it on reconcile, so in practice loss is rare. But it is a real data-loss hazard worth closing. Options:
- Advisory lock on a sidecar file (
~/.claude.json.lock) viaflock(2)around the whole RMW. - Re-read inside the write path and bail if
mtime/digest changed since the initial read (optimistic concurrency; retry a bounded number of times). - Document explicitly that this races and that
claude-rc trustshould be run only whileclaudeis quiescent.
At minimum the spec comment ("atomic read-modify-write") should be weakened — the rename is atomic, the RMW is not.
| await new Promise<void>((res, rej) => { | ||
| server.once("error", rej); | ||
| server.listen(path, () => res()); | ||
| }); | ||
| chmodSync(path, 0o600); |
There was a problem hiding this comment.
P1 — Socket permission race on listen() + chmod. server.listen(path) creates the inode with permissions derived from the daemon's umask (0022 on most Linux systems ⇒ socket mode 0755). The subsequent chmodSync(path, 0o600) closes the window, but any local process that races between the two syscalls can connect() and issue IPC commands to the daemon.
The ~/.claude/daemon directory is created recursively with the default mkdir mode in services/claude-rc-daemon/src/main.ts:34-35, which inherits the user's umask too — on a typical 0022 umask it ends up 0755, so parent-directory perms don't close the window either.
Suggested fix (one of):
const prev = typeof process.umask === "function" ? process.umask(0o077) : undefined;
try {
await new Promise<void>((res, rej) => {
server.once("error", rej);
server.listen(path, () => res());
});
} finally {
if (prev !== undefined) process.umask(prev);
}
// chmodSync still useful as a belt-and-braces on platforms that honor it after bind
chmodSync(path, 0o600);Or, ensure dirname(socketPath) is mkdir'd with mode 0o700 so the parent directory keeps strangers out even if the socket inode is briefly loose. (Daemon dir, run dir, log dir — all three should be 0700 to be safe.)
| async function latestClaudeRc(fetch: FetchLike): Promise<string | undefined> { | ||
| const list = await getJson(fetch, RELEASES_URL, "claude-rc releases"); | ||
| if (!Array.isArray(list)) throw new Error("claude-rc releases: unexpected response"); | ||
| let best: string | undefined; | ||
| for (const r of list as Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>) { | ||
| if (!r.tag_name?.startsWith(TAG_PREFIX) || r.draft || r.prerelease) continue; | ||
| const v = r.tag_name.slice(TAG_PREFIX.length); | ||
| if (!best || compareVersions(v, best) > 0) best = v; | ||
| } | ||
| return best; | ||
| } |
There was a problem hiding this comment.
P2 — Update check only looks at the first 50 releases, no pagination. nsheaps/agents is a monorepo and may accumulate releases from many apps. If 50+ non-claude-rc-v* releases are published after the latest claude-rc-v*, this scanner never sees the latest claude-rc release and silently keeps reporting "up to date".
Options:
- Paginate (
page=1,2,…) until a matching tag is found or GitHub returns an empty page, with a sensible page cap (say 10) so a repo full of unrelated releases eventually bails. - Switch to the
releases/tags/<prefix>…lookup via/releases/latest-by-tag-prefix-style client-side filter after fetching tag list — but GitHub still returns 30/page there, so pagination is required either way.
Not blocking today (there is no other app released from this repo yet), but worth fixing before this gets lost in a growing release stream.
| function checkEnv(v: unknown, where: string, errors: string[]): Record<string, string> { | ||
| if (v === undefined) return {}; | ||
| if (!isObject(v)) { | ||
| errors.push(`${where}: env must be an object of string values`); | ||
| return {}; | ||
| } | ||
| const out: Record<string, string> = {}; | ||
| for (const [k, val] of Object.entries(v)) { | ||
| if (!ENV_KEY_RE.test(k)) errors.push(`${where}: env key "${k}" is not a valid variable name`); | ||
| else if (typeof val !== "string") | ||
| errors.push(`${where}: env value for "${k}" must be a string`); | ||
| else out[k] = val; | ||
| } | ||
| return out; | ||
| } |
There was a problem hiding this comment.
P2 — Env values accept arbitrary control chars. checkEnv only enforces typeof val === "string". A newline or NUL in a value makes systemd.ts:36 emit --setenv=KEY=multi\nline, which systemd-run will either reject with an opaque message or quote in a way that confuses later unit-file parsing. Same risk (less severe) in the plist renderer.
Suggested extra check inside the else-if branch:
| function checkEnv(v: unknown, where: string, errors: string[]): Record<string, string> { | |
| if (v === undefined) return {}; | |
| if (!isObject(v)) { | |
| errors.push(`${where}: env must be an object of string values`); | |
| return {}; | |
| } | |
| const out: Record<string, string> = {}; | |
| for (const [k, val] of Object.entries(v)) { | |
| if (!ENV_KEY_RE.test(k)) errors.push(`${where}: env key "${k}" is not a valid variable name`); | |
| else if (typeof val !== "string") | |
| errors.push(`${where}: env value for "${k}" must be a string`); | |
| else out[k] = val; | |
| } | |
| return out; | |
| } | |
| else if (/[\x00-\x08\x0A-\x1F\x7F]/.test(val)) | |
| errors.push(`${where}: env value for "${k}" contains a control character`); | |
| else out[k] = val; |
(Tabs are intentionally allowed; everything else below 0x20 and DEL is rejected.)
| for pr in $(gh pr list --state open --search "chore: update claude-rc to" --json number --jq '.[].number' || true); do | ||
| gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}." | ||
| done |
There was a problem hiding this comment.
P2 — "Supersede older PRs" step closes PRs by title substring without author scope. gh pr list --state open --search "chore: update claude-rc to" matches any open PR whose title contains that substring, regardless of author. A human-opened correction PR (e.g., "chore: update claude-rc to pin SHA for CVE-…") would be force-closed by this workflow.
Scope the search to the automation account, e.g.:
| for pr in $(gh pr list --state open --search "chore: update claude-rc to" --json number --jq '.[].number' || true); do | |
| gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}." | |
| done | |
| for pr in $(gh pr list --state open --author "@me" --search "chore: update claude-rc to in:title" --json number --jq '.[].number' || true); do | |
| gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}." | |
| done |
(or filter by the GitHub App slug if you prefer).
| case "start": | ||
| case "stop": | ||
| case "restart": { | ||
| const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name }); | ||
| if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning); | ||
| if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error); | ||
| ctx.out( | ||
| `${cmd.cmd}${cmd.cmd === "stop" ? "ped" : cmd.cmd === "start" ? "ed" : "ed"} ${cmd.name ?? "all repos"}`.replace( | ||
| "stoped", | ||
| "stopped", | ||
| ), | ||
| ); | ||
| return EXIT.ok; |
There was a problem hiding this comment.
P2 (nit) — past-tense built via string surgery. The "ped"/"ed" branch plus .replace("stoped","stopped") is harder to follow than a direct map.
| case "start": | |
| case "stop": | |
| case "restart": { | |
| const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name }); | |
| if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning); | |
| if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error); | |
| ctx.out( | |
| `${cmd.cmd}${cmd.cmd === "stop" ? "ped" : cmd.cmd === "start" ? "ed" : "ed"} ${cmd.name ?? "all repos"}`.replace( | |
| "stoped", | |
| "stopped", | |
| ), | |
| ); | |
| return EXIT.ok; | |
| case "start": | |
| case "stop": | |
| case "restart": { | |
| const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name }); | |
| if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning); | |
| if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error); | |
| const past = { start: "started", stop: "stopped", restart: "restarted" } as const; | |
| ctx.out(`${past[cmd.cmd]} ${cmd.name ?? "all repos"}`); | |
| return EXIT.ok; | |
| } |
| if (req.method === "POST" && p === "/api/repos") { | ||
| return edit(() => { | ||
| const path = body?.path; | ||
| if (typeof path !== "string" || !path) throw new Error("path is required"); | ||
| if (!isAbsolute(path)) throw new Error(`path must be absolute (got "${path}")`); | ||
| if (!existsSync(path) || !statSync(path).isDirectory()) | ||
| throw new Error(`path does not exist or is not a directory: ${path}`); | ||
| addRepo(opts.paths.configPath, { | ||
| name: typeof body.name === "string" && body.name ? body.name : basename(path), | ||
| path, | ||
| ...(typeof body.spawn === "string" ? { spawn: body.spawn as never } : {}), | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
P2 — Default name from basename(path) can fail NAME_RE at write time. If a user drops /home/alice/My Project into the "Add repo" form without setting a name, basename(path) is My Project, which fails [A-Za-z0-9][A-Za-z0-9._-]* in parseConfig. The user sees "name must match /…/" without obvious guidance that the problem is their directory name.
Either sanitize the derived name (replace invalid chars with _ and lowercase, say) or surface the derived value in the error so the user knows what they're being asked to override:
const derived = typeof body.name === "string" && body.name ? body.name : basename(path);
addRepo(opts.paths.configPath, { name: derived, path, ... });Same consideration applies to apps/claude-rc-cli/src/args.ts:60.
- Bind the IPC socket under umask 077 and create daemon/log/run dirs 0700. - ensureTrusted re-reads and redoes if ~/.claude.json changed mid-update; spec wording no longer overclaims atomicity. - Update check pages through GitHub releases (bounded to 5 pages). - Reject control characters in env values. - Derive a valid repo name from the directory (e.g. "My Project"). - Release workflow only supersedes PRs opened by its own account. - Replace string surgery for past-tense output with a map. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
|
Addressed all seven findings from the review in 732972d:
format, lint, build and tests pass locally (core 330, daemon 67, CLI 78). Generated by Claude Code |
Summary
Adds
claude-rc(CLI) andclaude-rc-daemon, shipped in one Homebrew formula published to nsheaps/homebrew-devsetup.claude upgrade, reads~/.claude/daemon/rc.json, and runsclaude rc --spawn <mode> --verbosein each repo as its own service. Services auto-restart but do not survive reboot; the daemon does and recreates them.gui/<uid>(macOS), in-process fallback (--supervisor).~/.claude.json; each child runs under a built-inpty-runwrapper soclaude rcgets a terminal (tmux rejected, spec D6).claude rcstarts, exits or is auto-restarted (macOSosascript, Linuxnotify-send; absentnotify-sendis a no-op anddoctorwarns).claude-rc-v*GitHub release) and Claude Code (npm). Each new version is announced once; the timestamp is persisted so daemon restarts don't re-check. Each source is toggled inupdates.claudeRc/updates.claude.claude-rc uiopens a page served by the daemon on 127.0.0.1 to monitor repos, add/remove/restart/stop/start them and edit settings. Token on every request, loopback Host check, same-origin Origin check, JSON-only mutations, CSP. There is no native tray icon (your choice: TypeScript only; spec D7).lib/claude-rc-core,services/claude-rc-daemon,apps/claude-rc-cli.claude-rc-vX.Y.Zbuilds 4 compiled binaries, uploads a GitHub release, rendersapps/claude-rc-cli/Formula/claude-rc.rb.gotmpl, and opens a PR in homebrew-devsetup.claude-rc-e2e.yamlruns e2e on ubuntu/debian/fedora containers, native systemd, and macOS launchd.New
rc.jsonkeys (all optional, defaults shown):{ "notifications": { "enabled": true, "onStart": true, "onExit": true }, "updates": { "claudeRc": true, "claude": true }, "ui": { "port": 47474 } }Spec:
docs/specs/draft/claude-rc.md(section 12a for this feature). Plan:.claude/plans/claude-rc.md.Testing
Tests written first.
nx run-many -t format lint build testpasses for the three packages (core 302, CLI 78, daemon 66). The e2e runs the real daemon and CLI against a fakeclaude, a fakenotify-send, and the real web API. The page was also loaded in headless Chromium (repos render, a hostile path is escaped). I also scoped the daemontestscript tosrcbecause compileddist/*.test.jscopies resolvedmain.tsincorrectly.Not verified yet (please check)
claude rcrunning after pre-trust (not available in the authoring sandbox).brew installof the rendered formula are covered only by CI jobs or manual runs.notify-send; on macOS theosascriptcall can't be observed headless) and the real GitHub/npm update calls (tests inject fetch).claude upgradefailure is non-fatal; invalid rc.json leaves the daemon degraded; daemon SIGTERM stops its children; user-initiated stop does not notify.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p