@hraness/slopcamera 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,14 +47,14 @@ the rendered output with source, runtime requirements and reproduction steps.
47
47
 
48
48
  ## Install Slopcamera
49
49
 
50
- Slopcamera v3.4.0 includes 17 reusable film and animation direction profiles, deterministic exposure helpers, and a sample renderer that defaults to native 4K. Use the [style direction guide](docs/how-to/direct-visual-styles.md) to author cel animation, pixel art, mathematical explanations, and historical film treatments from retained sources.
50
+ Slopcamera v3.5.0 includes 17 reusable film and animation direction profiles, deterministic exposure helpers, and a sample renderer that defaults to native 4K. Use the [style direction guide](docs/how-to/direct-visual-styles.md) to author cel animation, pixel art, mathematical explanations, and historical film treatments from retained sources. On macOS, `slopcamera menubar` puts Slopcamera in the menu bar and explains each macOS notice before it appears.
51
51
 
52
52
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the verified
53
- [Slopcamera v3.4.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.4.0)
53
+ [Slopcamera v3.5.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.5.0)
54
54
  from its canonical archive:
55
55
 
56
56
  ```sh
57
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.4.0/hraness-slopcamera-3.4.0.tgz
57
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.5.0/hraness-slopcamera-3.5.0.tgz
58
58
  slopcamera skill install --target agents
59
59
  ```
60
60
 
@@ -90,8 +90,9 @@ Native engines install separately. The [source-install guide](docs/how-to/use-cu
90
90
  explains workspace placement and durable-run identity; the
91
91
  [capability reference](docs/reference/capabilities.md) names runtime requirements.
92
92
 
93
- Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion can show
94
- the files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
93
+ Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion shows
94
+ what Slopcamera is rendering, how the last job ended, your last known credits
95
+ balance and the newest files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
95
96
  companion directly and does not install or launch an application bundle. The
96
97
  companion is a thin product binary over the shared
97
98
  [desktop-foundation](https://github.com/hraness/desktop-foundation) crate, pinned
@@ -4,7 +4,7 @@ import type { z } from "zod";
4
4
 
5
5
  import type { ApplicationContext } from "./context";
6
6
 
7
- export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.5.0" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.6.0" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -3659,8 +3659,10 @@ export function parseCliArgs(argv: readonly string[]): CliCommand {
3659
3659
  case "menubar": {
3660
3660
  const parsed = parseOptions(argv.slice(1), { ...JSON_SPEC, "--foreground": "flag", "--background": "flag" });
3661
3661
  const positionals = parsed.positionals;
3662
- if (positionals.length > 1 || (positionals[0] !== undefined && !["install", "uninstall", "status"].includes(positionals[0]))) throw new CliError("usage", "Use slopcamera menubar [--foreground|--background] or slopcamera menubar install|uninstall|status. See slopcamera help menubar.");
3662
+ if (positionals.length > 1 || (positionals[0] !== undefined && !["install", "uninstall", "status", "start"].includes(positionals[0]))) throw new CliError("usage", "Use slopcamera menubar [--foreground|--background] or slopcamera menubar install|uninstall|status|start. See slopcamera help menubar.");
3663
3663
  if (positionals[0] !== undefined && (optionFlag(parsed, "--foreground") || optionFlag(parsed, "--background"))) throw new CliError("usage", "Choose a menu-bar action or a run mode, not both.");
3664
+ // `start` opens it now and returns, like --background; the login item's hints use it.
3665
+ if (positionals[0] === "start") return { kind: "menubar", action: "run", mode: "background", json: optionFlag(parsed, "--json") };
3664
3666
  return { kind: "menubar", action: (positionals[0] as "install" | "uninstall" | "status" | undefined) ?? "run", mode: optionFlag(parsed, "--background") ? "background" : "foreground", json: optionFlag(parsed, "--json") };
3665
3667
  }
3666
3668
  case "outputs": {
@@ -1,3 +1,4 @@
1
+ import { activityLabel, recordActivity, recordCredits } from "./menubar-status";
1
2
  import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
2
3
  import { SlopcameraCloudError } from "../../../src/cloud-errors";
3
4
  import {
@@ -362,7 +363,7 @@ import {
362
363
  workflowRunStore,
363
364
  } from "./workflow-runs";
364
365
 
365
- export const SLOPCAMERA_VERSION = "3.5.0";
366
+ export const SLOPCAMERA_VERSION = "3.6.0";
366
367
 
367
368
  // Legacy direct renders predate per-target output contracts. Keep them
368
369
  // bounded generously enough for long-form production while preventing one
@@ -5548,6 +5549,7 @@ async function handleCreditsStatus(
5548
5549
  } catch (error) {
5549
5550
  throw creditsErrorToCli(error);
5550
5551
  }
5552
+ recordCredits(context.stateRoot, { usd: balance.balance.usd, low: balance.lowBalance, checkedAt: context.clock() });
5551
5553
  const view = {
5552
5554
  configured: true,
5553
5555
  tokenSource: loaded.source,
@@ -8419,6 +8421,7 @@ async function resolveMutationTarget(
8419
8421
  export async function runCli(argv: readonly string[], dependencies: CliDependencies = {}): Promise<number> {
8420
8422
  const io = dependencies.io ?? processIo;
8421
8423
  let jsonRequested = argv.includes("--json") || argv.includes("--jsonl");
8424
+ let activity: { readonly stateRoot: string; readonly label: string; readonly startedAt: number } | undefined;
8422
8425
  try {
8423
8426
  const command = parseCliArgs(argv);
8424
8427
  jsonRequested = "json" in command ? command.json : command.kind === "events" && command.format !== "human";
@@ -8513,6 +8516,11 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
8513
8516
  ? {}
8514
8517
  : { renderGalleryScene: dependencies.renderGalleryScene }),
8515
8518
  };
8519
+ const label = activityLabel(command);
8520
+ if (label !== undefined) {
8521
+ activity = { stateRoot, label, startedAt: context.clock() };
8522
+ recordActivity(stateRoot, { state: "running", label, startedAt: activity.startedAt });
8523
+ }
8516
8524
  const mutationTarget = await resolveMutationTarget(paths, stateRoot, command);
8517
8525
  await withCommandHostResources(context, command, async admittedContext => {
8518
8526
  if (mutationTarget === undefined) {
@@ -8535,9 +8543,15 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
8535
8543
  || command.kind === "project-render" && command.action === "run" && !command.dryRun) {
8536
8544
  reportUsefulResult(dependencies.onUsefulResult);
8537
8545
  }
8546
+ if (activity !== undefined) {
8547
+ recordActivity(activity.stateRoot, { state: "done", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime() });
8548
+ }
8538
8549
  return 0;
8539
8550
  } catch (error) {
8540
8551
  const failure = asCliError(error);
8552
+ if (activity !== undefined) {
8553
+ recordActivity(activity.stateRoot, { state: "failed", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime(), error: failure.code });
8554
+ }
8541
8555
  const payload = {
8542
8556
  error: {
8543
8557
  code: failure.code,
@@ -1,9 +1,11 @@
1
+ import { slopcameraSupportAdvancedHelp, slopcameraSupportHelpLine } from "../../../src/support";
2
+
1
3
  import { HTML_OVERLAY_SCAFFOLD_KINDS } from "../html-overlay";
2
4
 
3
5
  const HTML_OVERLAY_SCAFFOLD_KIND_HELP =
4
6
  HTML_OVERLAY_SCAFFOLD_KINDS.join("|");
5
7
 
6
- const GLOBAL_HELP = `slopcamera: a media studio for coding agents
8
+ const globalHelp = () => `slopcamera: a media studio for coding agents
7
9
 
8
10
  Usage: slopcamera <command> [options]
9
11
 
@@ -35,11 +37,6 @@ Commands:
35
37
  credits status|topup|wait|forget
36
38
  Buy and inspect hosted generation credits
37
39
  media audio|color Apply local non-destructive audio and video effects
38
- menubar [--foreground|--background]
39
- Show your outputs in the menu bar
40
- menubar install|uninstall|status Open it at login, stop that, or check it
41
- support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
42
- Optional support; no feature requires payment
43
40
  outputs Print the agent outputs directory
44
41
  recordings list List recording bundles
45
42
  projects list|create List projects or create one from a recording
@@ -63,19 +60,21 @@ Examples:
63
60
  slopcamera render run REC --output out.mp4 Render a recording
64
61
 
65
62
  Run slopcamera help <command> for command-specific help.
66
- Optional support: slopcamera support · Turn off: HRANESS_SUPPORT_AUDIENCE=off`;
63
+ ${slopcameraSupportHelpLine()}`;
67
64
 
68
65
  const HELP: Readonly<Record<string, string>> = {
66
+ advanced: `${slopcameraSupportAdvancedHelp()}`,
69
67
  menubar: `Usage:
70
68
  slopcamera menubar [--foreground|--background] [--json]
71
- slopcamera menubar install|uninstall|status [--json]
69
+ slopcamera menubar install|uninstall|status|start [--json]
72
70
 
73
- Shows 📷 in the menu bar with your recent outputs. It only lists and opens
74
- files in the outputs folder (slopcamera outputs).
71
+ Shows what Slopcamera is doing in the menu bar: a render in progress, how the
72
+ last job ended and your last known credits balance, plus your newest outputs.
73
+ It only reads that status and the outputs folder (slopcamera outputs).
75
74
 
76
75
  menubar Open it now; it closes when this command ends
77
- menubar --background Open it now and return
78
- menubar install Open it now and every time you log in
76
+ menubar start Open it now and return (same as --background)
77
+ menubar install Open it every time you log in
79
78
  menubar uninstall Stop opening it at login and remove the installed copy
80
79
  menubar status Check whether it opens at login and is running
81
80
 
@@ -83,9 +82,10 @@ If it's already in your menu bar, starting it again says so and succeeds.
83
82
  macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
84
83
  time in System Settings › General › Login Items & Extensions.
85
84
 
86
- The menu bar isn't in released packages yet. In a Slopcamera checkout, build it
87
- with cargo build --release --manifest-path desktop/Cargo.toml and slopcamera
88
- finds it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
85
+ The menu bar isn't in released packages yet, so it isn't listed in
86
+ slopcamera help. In a Slopcamera checkout, build it with
87
+ cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
88
+ it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
89
89
  capabilities: `Usage: slopcamera capabilities [--json]
90
90
 
91
91
  Print the exact statically assembled capability manifest for this CLI build. Each module owns named operation, workflow, MCP-tool, command, and runtime-profile identities. The manifest includes trust classes, resource/effect policies, runtime requirements, qualification status, and a canonical SHA-256. It reads no workspace source, loads no plugin, probes no runtime, and grants no authority. Use doctor separately to inspect this machine's currently available executables.`,
@@ -701,8 +701,8 @@ Long inactivity is analyzed and removed by default; use --keep-inactivity to opt
701
701
  };
702
702
 
703
703
  export function commandHelp(topic: readonly string[]): string {
704
- if (topic.length === 0) return GLOBAL_HELP;
705
- return HELP[topic[0]!] ?? GLOBAL_HELP;
704
+ if (topic.length === 0) return globalHelp();
705
+ return HELP[topic[0]!] ?? globalHelp();
706
706
  }
707
707
 
708
708
  export function completions(words: readonly string[]): readonly string[] {
@@ -0,0 +1,155 @@
1
+ import { chmodSync, lstatSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeSync, constants } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+
5
+ import type { CliCommand } from "./args";
6
+
7
+ /**
8
+ * The small status file the menu bar reads: what Slopcamera is doing now,
9
+ * how the last render ended, and the last credits balance a command saw.
10
+ * It holds plain labels, times and fixed error codes only — never paths,
11
+ * prompts, tokens or account identifiers. Writes are best effort: a status
12
+ * that can't be saved never fails the command.
13
+ */
14
+ export const MENUBAR_STATUS_FILE = "menubar-status.json";
15
+ const MAX_STATUS_BYTES = 16 * 1024;
16
+
17
+ export type ActivityState = "running" | "done" | "failed";
18
+
19
+ export interface MenubarActivity {
20
+ readonly state: ActivityState;
21
+ readonly label: string;
22
+ readonly startedAt: number;
23
+ readonly finishedAt?: number;
24
+ readonly error?: string;
25
+ }
26
+
27
+ export interface MenubarCredits {
28
+ readonly usd: string;
29
+ readonly low: boolean;
30
+ readonly checkedAt: number;
31
+ }
32
+
33
+ export interface MenubarStatus {
34
+ readonly schemaVersion: 1;
35
+ readonly activity?: MenubarActivity;
36
+ readonly credits?: MenubarCredits;
37
+ }
38
+
39
+ /** Plain labels for the commands that make media. Everything else leaves the status alone. */
40
+ export function activityLabel(command: CliCommand): string | undefined {
41
+ switch (command.kind) {
42
+ case "render-run": return command.dryRun ? undefined : "Rendering a recording";
43
+ case "project-render": return command.action === "run" && !command.dryRun ? "Rendering a project" : undefined;
44
+ case "project-cinema": return "Rendering a project";
45
+ case "html-render": return "Rendering an HTML scene";
46
+ case "diagram-render": return "Rendering a diagram";
47
+ case "image-vectorize": return "Vectorizing an image";
48
+ case "ai-image-generate": return "Generating an image";
49
+ case "ai-video-generate": return "Generating a video";
50
+ case "ai-speech-generate": return "Generating speech";
51
+ case "directing": return "Directing a clip";
52
+ case "studio": return "Running a studio job";
53
+ case "spatial-scene": return command.action === "render" ? "Rendering a 3D scene" : undefined;
54
+ case "workflows-run": return "Running a workflow";
55
+ case "code-run": return "Running a workflow";
56
+ default: return undefined;
57
+ }
58
+ }
59
+
60
+ function parse(text: string): MenubarStatus {
61
+ try {
62
+ const value: unknown = JSON.parse(text);
63
+ if (typeof value !== "object" || value === null || (value as { schemaVersion?: unknown }).schemaVersion !== 1) return { schemaVersion: 1 };
64
+ const record = value as Record<string, unknown>;
65
+ const activity = record.activity as MenubarActivity | undefined;
66
+ const credits = record.credits as MenubarCredits | undefined;
67
+ return {
68
+ schemaVersion: 1,
69
+ ...(activity !== undefined && typeof activity === "object" && typeof activity.label === "string" ? { activity } : {}),
70
+ ...(credits !== undefined && typeof credits === "object" && typeof credits.usd === "string" ? { credits } : {}),
71
+ };
72
+ } catch {
73
+ return { schemaVersion: 1 };
74
+ }
75
+ }
76
+
77
+ export function readMenubarStatus(stateRoot: string): MenubarStatus {
78
+ const path = join(stateRoot, MENUBAR_STATUS_FILE);
79
+ try {
80
+ const info = lstatSync(path);
81
+ if (!info.isFile() || info.size > MAX_STATUS_BYTES) return { schemaVersion: 1 };
82
+ return parse(readFileSync(path, "utf8"));
83
+ } catch {
84
+ return { schemaVersion: 1 };
85
+ }
86
+ }
87
+
88
+ const LOCK_FILE = `.${MENUBAR_STATUS_FILE}.lock`;
89
+ /** A held lock blocks a write for this long before it is skipped. */
90
+ const LOCK_BUDGET_MS = 250;
91
+ /** A lock older than this is a crashed writer's and is reclaimed. */
92
+ const LOCK_STALE_MS = 10_000;
93
+
94
+ function sleepSync(ms: number): void {
95
+ Bun.sleepSync(ms);
96
+ }
97
+
98
+ /**
99
+ * Runs `update` holding a small exclusive lock file, so a render finishing
100
+ * while `credits status` runs cannot overwrite the other slot. Returns
101
+ * undefined when the lock never comes free; the write is then skipped
102
+ * rather than lost-halfway.
103
+ */
104
+ function withStatusLock<T>(stateRoot: string, update: () => T): T | undefined {
105
+ const lock = join(stateRoot, LOCK_FILE);
106
+ const deadline = Date.now() + LOCK_BUDGET_MS;
107
+ for (;;) {
108
+ let fd: number;
109
+ try {
110
+ fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return undefined;
113
+ try {
114
+ if (Date.now() - lstatSync(lock).mtimeMs > LOCK_STALE_MS) rmSync(lock, { force: true });
115
+ } catch { /* races with the lock holder */ }
116
+ if (Date.now() >= deadline) return undefined;
117
+ sleepSync(5);
118
+ continue;
119
+ }
120
+ closeSync(fd);
121
+ try {
122
+ return update();
123
+ } finally {
124
+ try { rmSync(lock, { force: true }); } catch { /* best effort */ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Replaces the status file atomically with an owner-only file. Never throws. */
130
+ export function writeMenubarStatus(stateRoot: string, update: (current: MenubarStatus) => MenubarStatus): void {
131
+ withStatusLock(stateRoot, () => {
132
+ const path = join(stateRoot, MENUBAR_STATUS_FILE);
133
+ const staged = join(stateRoot, `.${MENUBAR_STATUS_FILE}.${randomUUID()}.tmp`);
134
+ try {
135
+ const root = lstatSync(stateRoot);
136
+ if (!root.isDirectory()) return;
137
+ const text = `${JSON.stringify(update(readMenubarStatus(stateRoot)))}\n`;
138
+ if (Buffer.byteLength(text) > MAX_STATUS_BYTES) return;
139
+ const fd = openSync(staged, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
140
+ try { writeSync(fd, text); } finally { closeSync(fd); }
141
+ chmodSync(staged, 0o600);
142
+ renameSync(staged, path);
143
+ } catch {
144
+ try { rmSync(staged, { force: true }); } catch { /* best effort */ }
145
+ }
146
+ });
147
+ }
148
+
149
+ export function recordActivity(stateRoot: string, activity: MenubarActivity): void {
150
+ writeMenubarStatus(stateRoot, (current) => ({ ...current, activity }));
151
+ }
152
+
153
+ export function recordCredits(stateRoot: string, credits: MenubarCredits): void {
154
+ writeMenubarStatus(stateRoot, (current) => ({ ...current, credits }));
155
+ }
@@ -7,6 +7,7 @@ import { writeJson, writeLine } from "./io";
7
7
  import { ensurePrivateDirectory } from "./paths";
8
8
 
9
9
  const SETTLE_MS = 400;
10
+ /** The label earlier releases used; the menu bar now manages its own login item. */
10
11
  const LABEL = "com.hraness.slopcamera.menubar";
11
12
 
12
13
  function xml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;"); }
@@ -109,109 +110,86 @@ function readAgent(path: string, environment: Environment): string | null {
109
110
  return readRegular(path, 16 * 1024).toString("utf8");
110
111
  }
111
112
 
112
- /** Configuration state only; it does not claim the launchd service is running. */
113
- export type LaunchAgentState = "absent" | "installed" | "conflict";
114
- export type LaunchctlRunner = (args: readonly string[], allowMissing?: boolean) => string | null;
115
- function launchctl(args: readonly string[], allowMissing = false): string | null {
116
- const uid = process.getuid?.();
117
- if (uid === undefined) throw new CliError("unavailable", "The current user has no launchd GUI domain.");
118
- const result = Bun.spawnSync(["/bin/launchctl", ...args.map((arg) => arg.replaceAll("{uid}", String(uid)))], { stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 10_000, maxBuffer: 64 * 1024 });
119
- return interpretLaunchctlResult(result, uid, allowMissing);
120
- }
121
- export function interpretLaunchctlResult(result: { success: boolean; exitCode: number; stdout: Uint8Array; stderr: Uint8Array }, uid: number, allowMissing: boolean): string | null {
122
- if (result.stdout.byteLength + result.stderr.byteLength > 64 * 1024) throw new CliError("unavailable", "launchctl exceeded the bounded response size.");
123
- if (result.success && result.exitCode === 0) return Buffer.from(result.stdout).toString();
124
- if (allowMissing && result.exitCode === 113 && result.stdout.length === 0 && Buffer.from(result.stderr).toString() === `Bad request.\nCould not find service "${LABEL}" in domain for user gui: ${uid}\n`) return null;
125
- throw new CliError("unavailable", "launchctl could not reconcile the Slopcamera menu-bar LaunchAgent. Run menubar install again to retry.");
126
- }
127
-
128
- function loadedOwned(path: string, binary: string, current: string | null, run: LaunchctlRunner): boolean {
129
- const output = run(["print", `gui/{uid}/${LABEL}`], true);
130
- if (output === null) return false;
131
- const uid = process.getuid?.();
132
- if (current === null || Buffer.byteLength(output) > 64 * 1024 || !output.startsWith(`gui/${uid}/${LABEL} = {\n`)) {
133
- throw new CliError("conflict", "The loaded menu-bar service has an unverified identity.");
134
- }
135
- const lines = output.split("\n").map(line => line.trim());
136
- const field = (key: string): string | null => {
137
- const values = lines.filter(line => line.startsWith(`${key} = `));
138
- return values.length === 1 ? values[0]!.slice(key.length + 3) : null;
139
- };
140
- const argumentsStart = lines.indexOf("arguments = {");
141
- const argumentsEnd = lines.indexOf("}", argumentsStart + 1);
142
- if (field("path") !== path || field("program") !== binary || argumentsStart < 0 || argumentsEnd !== argumentsStart + 2 || lines[argumentsStart + 1] !== binary) {
143
- throw new CliError("conflict", "The loaded menu-bar service does not match this install; it was left untouched.");
144
- }
145
- return true;
146
- }
147
- export function launchAgentState(binary: string, environment: Environment = process.env): LaunchAgentState {
148
- const content = readAgent(launchAgentPath(environment), environment);
149
- if (content === null) return "absent";
150
- checkParents(binary, environment);
151
- assertOwnedFile(binary);
152
- return content === launchAgentPlist(binary) && exists(binary) ? "installed" : "conflict";
153
- }
154
- function assertMac(platform: NodeJS.Platform): void {
155
- if (platform !== "darwin") throw new CliError("unsupported-plan", "Slopcamera menu-bar LaunchAgents are supported only on macOS.");
156
- }
157
- export function installLaunchAgent(binary: string, environment: Environment = process.env, platform: NodeJS.Platform = process.platform, runLaunchctl: LaunchctlRunner = launchctl): LaunchAgentState {
158
- assertMac(platform);
159
- if (!isAbsolute(binary)) throw new CliError("unsafe-path", "LaunchAgent binary must be an absolute path.");
113
+ /**
114
+ * Copies the built menu bar into its installed location, replacing an
115
+ * earlier copy. Existing shared directory permissions are preserved;
116
+ * symbolic links and files other users can write are refused.
117
+ */
118
+ export function installBinary(binary: string, environment: Environment = process.env): string {
119
+ if (!isAbsolute(binary)) throw new CliError("unsafe-path", "The menu-bar build must be an absolute path.");
160
120
  const stable = installedBinaryPath(environment);
161
- const path = launchAgentPath(environment);
162
- const current = readAgent(path, environment);
163
- const expected = launchAgentPlist(stable);
164
121
  checkParents(stable, environment);
165
122
  assertOwnedFile(stable);
166
- if (current !== null && current !== expected || current === null && infoAt(stable) !== null) {
167
- throw new CliError("conflict", "The existing Slopcamera menu-bar install is not owned by this command.");
168
- }
169
123
  const bytes = readRegular(binary, MAX_BINARY_BYTES, true);
170
- loadedOwned(path, stable, current, runLaunchctl);
171
- checkParents(path, environment, true);
172
124
  checkParents(stable, environment, true);
173
- // Unique private staging directories prevent predictable temporary-file clobbering.
174
- const binaryStage = mkdtempSync(join(dirname(stable), ".install-"));
175
- let agentStage: string | undefined;
125
+ const stage = mkdtempSync(join(dirname(stable), ".install-"));
176
126
  try {
177
- agentStage = mkdtempSync(join(dirname(path), ".slopcamera-install-"));
178
- const stagedBinary = join(binaryStage, "binary");
179
- const stagedAgent = join(agentStage, "agent.plist");
180
- writeFileSync(stagedBinary, bytes, { mode: 0o700, flag: "wx" });
181
- writeFileSync(stagedAgent, expected, { mode: 0o600, flag: "wx" });
182
- if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
127
+ const staged = join(stage, "binary");
128
+ writeFileSync(staged, bytes, { mode: 0o700, flag: "wx" });
183
129
  assertOwnedFile(stable);
184
- // Retry bootstrap even when the plist matches: a previous bootstrap may have failed.
185
- if (loadedOwned(path, stable, current, runLaunchctl)) runLaunchctl(["bootout", `gui/{uid}/${LABEL}`], true);
186
- renameSync(stagedBinary, stable);
187
- renameSync(stagedAgent, path);
188
- runLaunchctl(["bootstrap", "gui/{uid}", path]);
189
- return "installed";
130
+ renameSync(staged, stable);
131
+ return stable;
190
132
  } finally {
191
- rmSync(binaryStage, { recursive: true });
192
- if (agentStage !== undefined) rmSync(agentStage, { recursive: true });
133
+ rmSync(stage, { recursive: true });
193
134
  }
194
135
  }
195
- export function uninstallLaunchAgent(_binary: string, environment: Environment = process.env, platform: NodeJS.Platform = process.platform, runLaunchctl: LaunchctlRunner = launchctl): LaunchAgentState {
196
- assertMac(platform);
197
- const stable = installedBinaryPath(environment);
136
+
137
+ /**
138
+ * Removes the login item earlier releases wrote under the old label, but
139
+ * only when it is exactly the file they wrote. It never calls launchctl: a
140
+ * copy that is already running keeps running until you quit it or log out.
141
+ */
142
+ export function removeLegacyAgent(environment: Environment = process.env): "absent" | "removed" | "foreign" {
198
143
  const path = launchAgentPath(environment);
199
144
  const current = readAgent(path, environment);
200
145
  if (current === null) return "absent";
201
- if (current !== launchAgentPlist(stable)) throw new CliError("conflict", "The existing Slopcamera menu-bar LaunchAgent is not owned by this command.");
202
- checkParents(stable, environment);
203
- assertOwnedFile(stable);
204
- if (loadedOwned(path, stable, current, runLaunchctl)) runLaunchctl(["bootout", `gui/{uid}/${LABEL}`], true);
205
- if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
206
- assertOwnedFile(stable);
146
+ if (current !== launchAgentPlist(installedBinaryPath(environment))) return "foreign";
207
147
  rmSync(path);
208
- if (infoAt(stable) !== null) rmSync(stable);
209
- return "absent";
148
+ return "removed";
149
+ }
150
+
151
+ function assertMac(platform: NodeJS.Platform): void {
152
+ if (platform !== "darwin") throw new CliError("unsupported-plan", "The Slopcamera menu bar runs only on macOS.");
153
+ }
154
+
155
+ export interface HelperResult { readonly code: number; readonly stdout: string; readonly stderr: string }
156
+ /** Runs the installed menu bar's own install, uninstall or status command. */
157
+ export type HelperRunner = (binary: string, args: readonly string[], env: Environment) => Promise<HelperResult>;
158
+
159
+ async function runHelper(binary: string, args: readonly string[], env: Environment): Promise<HelperResult> {
160
+ const child = Bun.spawn([binary, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...env } as Record<string, string> });
161
+ const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
162
+ return { code, stdout: stdout.slice(0, 64 * 1024), stderr: stderr.slice(0, 64 * 1024) };
210
163
  }
164
+
165
+ const AGENT_MARKERS = ["AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CURSOR_AGENT", "GEMINI_CLI"];
166
+
167
+ /**
168
+ * The helper decides between human and agent output from its environment.
169
+ * Its output is captured here, so a person's terminal is passed on as
170
+ * `HRANESS_AUDIENCE=human`; agents and explicit settings pass through.
171
+ */
172
+ export function helperEnvironment(env: Environment): Environment {
173
+ const agent = AGENT_MARKERS.some((name) => (env[name] ?? "") !== "");
174
+ return env.HRANESS_AUDIENCE !== undefined || agent ? env : { ...env, HRANESS_AUDIENCE: "human" };
175
+ }
176
+
177
+ function helperFailure(result: HelperResult): CliError {
178
+ // The helper prints the login-item notice on stderr before the error;
179
+ // the failure is the last ✗ line plus the → hint that follows it.
180
+ const lines = result.stderr.split("\n");
181
+ let start = -1;
182
+ for (const [index, line] of lines.entries()) {
183
+ const trimmed = line.trimStart();
184
+ if (trimmed.startsWith("✗") || trimmed.startsWith("FAIL")) start = index;
185
+ }
186
+ const text = (start === -1 ? result.stderr : lines.slice(start).join("\n")).trim().replace(/^(✗|FAIL)\s+/u, "");
187
+ return new CliError(result.code === 2 ? "usage" : "unavailable", text === "" ? "The Slopcamera menu bar couldn't change its login item." : text);
188
+ }
189
+
211
190
  /** `slopcamera-menubar` exits with this code when another copy already holds the menu bar. */
212
191
  export const ALREADY_RUNNING_EXIT = 3;
213
192
  const NOT_BUILT = "The Slopcamera menu bar isn't in this release yet. Build it with cargo build --release --manifest-path desktop/Cargo.toml in a Slopcamera checkout; slopcamera finds it there, or set SLOPCAMERA_MENUBAR to the built file.";
214
- const LOGIN_ITEMS = "System Settings › General › Login Items & Extensions";
215
193
 
216
194
  export type Launched = "running" | "already-running" | "exited";
217
195
  export type BinaryRunner = (binary: string, foreground: boolean) => Promise<number | null>;
@@ -240,11 +218,6 @@ export function marks(env: Readonly<Record<string, string | undefined>>): Marks
240
218
  return ascii ? { ok: "OK", warn: "WARN", note: "NOTE", next: "->" } : { ok: "✓", warn: "⚠", note: "🔐", next: "→" };
241
219
  }
242
220
 
243
- /** The LOGIN_ITEM notice (notifies, so there is no confirm line). The requester is the bare companion until Slopcamera ships as an app. */
244
- export function loginItemNotice(env: Readonly<Record<string, string | undefined>>): string {
245
- return `${marks(env).note} macOS will show a notice that slopcamera-menubar can open at login. That's Slopcamera's menu bar.\n It lists your outputs and opens when you log in. Nothing else runs in the background. Turn it off any time in ${LOGIN_ITEMS}.\n`;
246
- }
247
-
248
221
  export function launchMessage(outcome: Launched, env: Readonly<Record<string, string | undefined>>): string {
249
222
  const mark = marks(env);
250
223
  if (outcome === "already-running") return `${mark.ok} Slopcamera is already in your menu bar. Look for 📷.`;
@@ -252,42 +225,6 @@ export function launchMessage(outcome: Launched, env: Readonly<Record<string, st
252
225
  return "The Slopcamera menu bar closed.";
253
226
  }
254
227
 
255
- export function stateMessage(action: "install" | "uninstall" | "status", state: LaunchAgentState, running: boolean, env: Readonly<Record<string, string | undefined>>): string {
256
- const mark = marks(env);
257
- if (state === "conflict") return `${mark.warn} Another menu-bar setup that this command didn't create is in the way. It was left untouched.\n Remove ~/Library/LaunchAgents/${LABEL}.plist yourself if you don't need it.`;
258
- if (state === "absent") return action === "uninstall" ? `${mark.ok} Slopcamera no longer opens in your menu bar at login.` : `Slopcamera doesn't open at login.\n${mark.next} slopcamera menubar install`;
259
- if (action === "install") return `${mark.ok} Slopcamera opens in your menu bar now and at every login. Look for 📷.\n Remove it any time: slopcamera menubar uninstall`;
260
- return running
261
- ? `${mark.ok} Slopcamera opens at login and is in your menu bar now.`
262
- : `${mark.warn} Slopcamera is set to open at login but isn't running. It may be turned off in ${LOGIN_ITEMS}.\n${mark.next} slopcamera menubar install`;
263
- }
264
-
265
- /** Whether launchd reports the loaded companion as running. Reads state only. */
266
- export function serviceRunning(runLaunchctl: LaunchctlRunner): boolean {
267
- const output = runLaunchctl(["print", `gui/{uid}/${LABEL}`], true);
268
- return output !== null && output.split("\n").some((line) => line.trim() === "state = running");
269
- }
270
-
271
- export type ProcessProbe = () => boolean;
272
- /** Whether any copy of the companion runs for this user, including one started by hand. */
273
- function companionProcessRunning(): boolean {
274
- const uid = process.getuid?.();
275
- if (uid === undefined) return false;
276
- try {
277
- return Bun.spawnSync(["/usr/bin/pgrep", "-x", "-U", String(uid), "slopcamera-menubar"], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000 }).exitCode === 0;
278
- } catch { return false; }
279
- }
280
-
281
- /** The menu bar is showing when launchd runs the login item or another copy holds it. */
282
- async function menubarShowing(runLaunchctl: LaunchctlRunner, probe: ProcessProbe, waitMs: number): Promise<boolean> {
283
- const deadline = Date.now() + waitMs;
284
- for (;;) {
285
- if (serviceRunning(runLaunchctl) || probe()) return true;
286
- if (Date.now() >= deadline) return false;
287
- await Bun.sleep(200);
288
- }
289
- }
290
-
291
228
  export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground", runBinary: BinaryRunner = spawnBinary): Promise<void> {
292
229
  const binary = resolveMenubarBinary(repositoryRoot, io.env);
293
230
  if (binary === null) throw new CliError("unavailable", NOT_BUILT);
@@ -295,23 +232,38 @@ export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: b
295
232
  if (asJson) writeJson(io, { running: outcome !== "exited", foreground: mode === "foreground", alreadyRunning: outcome === "already-running" });
296
233
  else writeLine(io, launchMessage(outcome, io.env));
297
234
  }
298
- export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean, runLaunchctl: LaunchctlRunner = launchctl, probe: ProcessProbe = companionProcessRunning): Promise<void> {
235
+ export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean, run: HelperRunner = runHelper): Promise<void> {
299
236
  assertMac(io.platform);
300
237
  const stable = installedBinaryPath(io.env);
301
- let state: LaunchAgentState;
238
+ const args = asJson ? [action, "--json"] : [action];
239
+ const env = helperEnvironment(io.env);
240
+ const forward = (result: HelperResult): void => {
241
+ if (result.stdout !== "") io.stdout(result.stdout);
242
+ if (result.code !== 0) throw helperFailure(result);
243
+ if (result.stderr !== "") io.stderr(result.stderr);
244
+ };
302
245
  if (action === "install") {
303
246
  const binary = resolveMenubarBinary(repositoryRoot, io.env);
304
247
  if (binary === null) throw new CliError("unavailable", NOT_BUILT);
305
- if (!asJson) io.stderr(loginItemNotice(io.env));
306
- state = installLaunchAgent(binary, io.env, io.platform, runLaunchctl);
307
- } else {
308
- state = action === "uninstall" ? uninstallLaunchAgent(stable, io.env, io.platform, runLaunchctl) : launchAgentState(stable, io.env);
248
+ const installed = binary === stable ? stable : installBinary(binary, io.env);
249
+ forward(await run(installed, args, env));
250
+ // The old login item goes only after the new one is in place, so a
251
+ // failed install never leaves nothing to start at login.
252
+ removeLegacyAgent(io.env);
253
+ return;
309
254
  }
310
- // A fresh login item may take a moment to start, so install waits briefly.
311
- const running = state === "installed" && await menubarShowing(runLaunchctl, probe, action === "install" ? 1_000 : 0);
312
- if (asJson) writeJson(io, { launchAgent: state, running });
313
- else writeLine(io, stateMessage(action, state, running, io.env));
255
+ if (action === "uninstall") removeLegacyAgent(io.env);
256
+ if (!exists(stable)) {
257
+ if (asJson) writeJson(io, { login: "off", running: false, installed: false });
258
+ else if (action === "uninstall") writeLine(io, `${marks(io.env).ok} Slopcamera won't open at login.`);
259
+ else writeLine(io, `Slopcamera's menu bar isn't installed.\n${marks(io.env).next} slopcamera menubar install`);
260
+ return;
261
+ }
262
+ const result = await run(stable, args, env);
263
+ forward(result);
264
+ if (action === "uninstall") rmSync(stable);
314
265
  }
266
+
315
267
  export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
316
268
  const directory = outputsRoot(stateRoot);
317
269
  await ensurePrivateDirectory(directory);