@hraness/slopcamera 3.4.0 → 3.5.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.
Files changed (38) hide show
  1. package/README.md +29 -17
  2. package/apps/desktop/application/operation.ts +1 -1
  3. package/apps/desktop/cli/args.ts +1 -1
  4. package/apps/desktop/cli/commands.ts +8 -1
  5. package/apps/desktop/cli/help.ts +33 -9
  6. package/apps/desktop/cli/menubar.ts +91 -15
  7. package/apps/desktop/dist/cli/main.js +313 -281
  8. package/apps/desktop/html-overlay/catalog.ts +1 -0
  9. package/apps/desktop/html-overlay/spatial.ts +10 -6
  10. package/apps/desktop/workflows/index.ts +1 -0
  11. package/dist/cli.js +1 -1
  12. package/dist/{index-sy1n4zfy.js → index-24w75s6a.js} +1 -1
  13. package/dist/{index-qtn3s421.js → index-8bhp7cj2.js} +1 -1
  14. package/dist/{index-abnk4h9c.js → index-jab2mg0s.js} +2 -2
  15. package/dist/{index-qry58nj2.js → index-mnp0bftq.js} +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/operations.js +1 -1
  18. package/dist/vectorize/worker.js +1 -1
  19. package/dist/workflow.js +1 -1
  20. package/docs/README.md +3 -3
  21. package/examples/studio/vgpu/README.md +1 -1
  22. package/package.json +4 -4
  23. package/skills/slopcamera/SKILL.md +4 -3
  24. package/skills/slopcamera/references/brand-illustrations.md +237 -0
  25. package/skills/slopcamera/references/gateway-media.md +1 -1
  26. package/skills/slopcamera/references/install.md +10 -1
  27. package/skills/slopcamera/references/patent-drawings.md +4 -4
  28. package/skills/slopcamera/references/support.md +1 -1
  29. package/skills/slopcamera/references/vectorization.md +3 -1
  30. package/src/code/public-operations.ts +9 -0
  31. package/src/mcp/tools.ts +4 -0
  32. package/src/operations.ts +4 -0
  33. package/src/support.ts +1 -1
  34. package/src/vectorize/command.ts +6 -1
  35. package/src/vectorize/supervisor.ts +23 -3
  36. package/src/vectorize/tool.ts +8 -4
  37. package/src/version.ts +1 -1
  38. package/src/visual-style.ts +4 -1
package/README.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # Slopcamera
2
2
 
3
- [![Slopcamera: a visual studio for coding agents](https://slopcamera.com/og.png)](https://slopcamera.com)
3
+ [![Slopcamera: Visual work your agent can keep revising.](https://slopcamera.com/og.png)](https://slopcamera.com)
4
4
 
5
- **Slopcamera (formerly Atet) is a local visual studio for coding agents. Author scenes, combine
6
- generated and recorded media, and export images, diagrams, animation, and video
7
- from retained sources.**
5
+ **Slopcamera is a media studio for coding agents. Codex, Claude Code, and other
6
+ agents make images, diagrams, animation, 3D scenes, and edited video from source
7
+ files they can keep revising.**
8
8
 
9
- Describe a finished result to Codex, Claude, or another coding agent. Slopcamera gives
10
- it a Bun CLI, TypeScript SDK, and version-matched Agent Skill to inspect sources,
11
- direct cameras, edit a composition, and render the result. A separate MCP server
12
- exposes fixed tools for diagrams, images, and scene inspection and planning. There is no Slopcamera account or hosted
13
- project database.
9
+ Describe the result you want to Codex, Claude Code, or another coding agent.
10
+ Slopcamera gives the agent a Bun CLI, a TypeScript SDK, and an Agent Skill matched
11
+ to the CLI version, so it can inspect sources, set up cameras, edit a composition,
12
+ and render. `slopcamera mcp` runs an MCP server with a smaller, fixed set of diagram,
13
+ image, and scene tools. Projects stay in local files, and there is no Slopcamera
14
+ account. Generation uses your own Vercel AI Gateway account or, for prompt-only
15
+ images, prepaid Hraness Credits.
14
16
 
15
17
  [Install](#install-slopcamera) · [Make a first diagram](#make-your-first-diagram) · [Capabilities](#what-slopcamera-does) · [Documentation](https://slopcamera.com/docs) · [slopcamera.com](https://slopcamera.com)
16
18
 
@@ -38,20 +40,21 @@ the rendered output with source, runtime requirements and reproduction steps.
38
40
  timing, framing, captions, and effects as project decisions. Preview and final
39
41
  renders use the same timeline and composition.
40
42
  - **Choose where computation happens.** Local rendering and editing use your
41
- machine. Optional model-backed work uses your Vercel AI Gateway access, with
42
- explicit acknowledgement before named local media is uploaded. Native Python
43
+ machine. Optional model-backed work uses your Vercel AI Gateway access and
44
+ uploads named local media only after you acknowledge it. Prompt-only images can
45
+ instead run on the hosted API with prepaid Hraness Credits. Native Python
43
46
  authoring requires separate trust because it runs as your current user.
44
47
 
45
48
  ## Install Slopcamera
46
49
 
47
- Slopcamera v3.3.6 fixes `credits wait` dropping the issued device token on pickup, on top of v3.3.5's paid hosted image generation through Hraness Credits top-up (`slopcamera credits`, `ai image --hosted`), public hosted model discovery, and checked patent-style vector drawing sheets.
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.
48
51
 
49
52
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the verified
50
- [Slopcamera v3.3.6 release](https://github.com/hraness/slopcamera/releases/tag/v3.3.6)
53
+ [Slopcamera v3.4.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.4.0)
51
54
  from its canonical archive:
52
55
 
53
56
  ```sh
54
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.3.6/hraness-slopcamera-3.3.6.tgz
57
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.4.0/hraness-slopcamera-3.4.0.tgz
55
58
  slopcamera skill install --target agents
56
59
  ```
57
60
 
@@ -134,7 +137,8 @@ architecture from dimensions, [build a parametric design](https://slopcamera.com
134
137
 
135
138
  ## What Slopcamera does
136
139
 
137
- Slopcamera is a local visual studio for coding agents.
140
+ Slopcamera lets your coding agent make images, diagrams, animation, 3D scenes,
141
+ and video from source files it can keep revising.
138
142
 
139
143
  ### Author scenes and direct cameras
140
144
 
@@ -153,7 +157,7 @@ rigged/morph profile in v3.3.1 admits bounded skins and morph targets. Saved spl
153
157
  capture appearance; they do not establish collision geometry or editable native
154
158
  meshes. See [Directed scenes](docs/spatial-scenes.md).
155
159
 
156
- Slopcamera v3.3.1 includes the cinematic planning loop: a `slopcamera.spatial-direction`
160
+ Slopcamera v3.3.1 introduced the cinematic planning loop: a `slopcamera.spatial-direction`
157
161
  document describes beats, actions, camera coverage, and look intents, compiles
158
162
  into proposed performance/camera/cinema/material-lighting/shot documents, and
159
163
  fans out into bounded galleries per axis. Declared effects bind into renders,
@@ -308,7 +312,7 @@ declarative graphs, approvals, and resuming work.
308
312
  workflows run with the current user's access. Hashes and receipts identify
309
313
  observed inputs and outputs; they do not make arbitrary code hermetic.
310
314
  - **MCP is a subset.** Its 17 fixed tools check and render diagrams, plan and audit
311
- scenes, and run six bounded portable operation codes in v3.3.1. It does not
315
+ scenes, and run six bounded operation codes. It does not
312
316
  expose every local CLI operation and never mutates project state.
313
317
 
314
318
  ## Design and trust
@@ -333,6 +337,14 @@ source identity alone does not promise identical pixels on another machine.
333
337
  See [Architecture](docs/architecture.md), [`SECURITY.md`](SECURITY.md),
334
338
  [`PRIVACY.md`](PRIVACY.md), and [`NOTICE.md`](NOTICE.md) for the detailed boundaries.
335
339
 
340
+ Your agent renders images, diagrams, scenes, animation, and video from source
341
+ files you keep, and important operations record their inputs and outputs in
342
+ receipts, so a rendered result arrives with the record of how it was made: the
343
+ design every Hraness project shares. [The thread through
344
+ hraness](https://hraness.com/writing/the-thread-through-hraness) follows that
345
+ design across the projects, and the [ALGAL
346
+ vision](https://algal.computer/docs/vision/) states the bet behind it.
347
+
336
348
  ## Documentation
337
349
 
338
350
  - **Learn:** [Your first diagram](docs/tutorials/first-diagram.md) · [Your first native film](docs/tutorials/first-native-film.md).
@@ -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.4.0" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.5.0" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -3659,7 +3659,7 @@ 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] | install|uninstall|status");
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.");
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
3664
  return { kind: "menubar", action: (positionals[0] as "install" | "uninstall" | "status" | undefined) ?? "run", mode: optionFlag(parsed, "--background") ? "background" : "foreground", json: optionFlag(parsed, "--json") };
3665
3665
  }
@@ -362,7 +362,7 @@ import {
362
362
  workflowRunStore,
363
363
  } from "./workflow-runs";
364
364
 
365
- export const SLOPCAMERA_VERSION = "3.4.0";
365
+ export const SLOPCAMERA_VERSION = "3.5.0";
366
366
 
367
367
  // Legacy direct renders predate per-target output contracts. Keep them
368
368
  // bounded generously enough for long-form production while preventing one
@@ -739,6 +739,13 @@ function humanTime(microseconds: number): string {
739
739
 
740
740
  function requireCapability(capabilities: readonly Capability[], name: Capability["name"]): string {
741
741
  const capability = capabilityByName(capabilities, name);
742
+ if ((!capability.available || capability.command === undefined) && name === "face-analyzer") {
743
+ throw new CliError(
744
+ "unavailable",
745
+ "Face analysis needs a small helper that isn't built on this Mac yet. In a Slopcamera checkout, run bun run build:desktop:analysis:macos; it uses Apple's command line tools and asks before installing them.",
746
+ { capability: name, ...(capability.reason === undefined ? {} : { reason: capability.reason }) },
747
+ );
748
+ }
742
749
  if (!capability.available || capability.command === undefined) {
743
750
  throw new CliError(
744
751
  "unavailable",
@@ -3,13 +3,13 @@ import { HTML_OVERLAY_SCAFFOLD_KINDS } from "../html-overlay";
3
3
  const HTML_OVERLAY_SCAFFOLD_KIND_HELP =
4
4
  HTML_OVERLAY_SCAFFOLD_KINDS.join("|");
5
5
 
6
- const GLOBAL_HELP = `slopcamera — creative scenes and non-destructive media editing for coding agents
6
+ const GLOBAL_HELP = `slopcamera: a media studio for coding agents
7
7
 
8
8
  Usage: slopcamera <command> [options]
9
9
 
10
10
  Commands:
11
- capabilities Discover the exact static capability modules and qualification status
12
- operations list|show Discover host-owned typed operations and policies
11
+ capabilities List what this build can do and how well each part is tested
12
+ operations list|show List the operations agents can run and their limits
13
13
  diagram init|check|render Create, validate, or render portable diagram sources
14
14
  diagram sheets init|check|render
15
15
  Create and render black-and-white drawing sheets
@@ -35,8 +35,9 @@ Commands:
35
35
  credits status|topup|wait|forget
36
36
  Buy and inspect hosted generation credits
37
37
  media audio|color Apply local non-destructive audio and video effects
38
- menubar [--background] Run the prebuilt menu-bar companion
39
- menubar install|uninstall|status Manage its per-user LaunchAgent
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
40
41
  support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
41
42
  Optional support; no feature requires payment
42
43
  outputs Print the agent outputs directory
@@ -55,13 +56,36 @@ Commands:
55
56
  render plan|run <recording> Resolve or execute a render plan
56
57
  assets emoji search|resolve Find checked local emoji overlays
57
58
 
58
- Agents: after useful work, read slopcamera support protocol --json for the optional
59
- closeout protocol. Discovery uses stderr; it does not claim an invitation.
60
- HRANESS_SUPPORT_AUDIENCE=off suppresses offers and incidental discovery.
59
+ Examples:
60
+ slopcamera doctor Check what this Mac can render
61
+ slopcamera html scaffold plain --output intro.html
62
+ Start an HTML scene
63
+ slopcamera render run REC --output out.mp4 Render a recording
61
64
 
62
- Run slopcamera help <command> for command-specific help.`;
65
+ Run slopcamera help <command> for command-specific help.
66
+ Optional support: slopcamera support · Turn off: HRANESS_SUPPORT_AUDIENCE=off`;
63
67
 
64
68
  const HELP: Readonly<Record<string, string>> = {
69
+ menubar: `Usage:
70
+ slopcamera menubar [--foreground|--background] [--json]
71
+ slopcamera menubar install|uninstall|status [--json]
72
+
73
+ Shows 📷 in the menu bar with your recent outputs. It only lists and opens
74
+ files in the outputs folder (slopcamera outputs).
75
+
76
+ 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
79
+ menubar uninstall Stop opening it at login and remove the installed copy
80
+ menubar status Check whether it opens at login and is running
81
+
82
+ If it's already in your menu bar, starting it again says so and succeeds.
83
+ macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
84
+ time in System Settings › General › Login Items & Extensions.
85
+
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.`,
65
89
  capabilities: `Usage: slopcamera capabilities [--json]
66
90
 
67
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.`,
@@ -208,33 +208,109 @@ export function uninstallLaunchAgent(_binary: string, environment: Environment =
208
208
  if (infoAt(stable) !== null) rmSync(stable);
209
209
  return "absent";
210
210
  }
211
- async function runBinary(binary: string, foreground: boolean): Promise<number> {
211
+ /** `slopcamera-menubar` exits with this code when another copy already holds the menu bar. */
212
+ export const ALREADY_RUNNING_EXIT = 3;
213
+ 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
+
216
+ export type Launched = "running" | "already-running" | "exited";
217
+ export type BinaryRunner = (binary: string, foreground: boolean) => Promise<number | null>;
218
+
219
+ /** Resolves the exit code, or null when a background copy is still running after startup. */
220
+ async function spawnBinary(binary: string, foreground: boolean): Promise<number | null> {
212
221
  let child: Bun.Subprocess;
213
- try { child = Bun.spawn([binary], foreground ? { stdin: "inherit", stdout: "inherit", stderr: "inherit" } : { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); } catch { throw new CliError("unavailable", "The Slopcamera menu bar could not start."); }
214
- if (!foreground) { child.unref(); const settled = await Promise.race([child.exited.then((code) => code as number | null), Bun.sleep(SETTLE_MS).then(() => null)]); if (settled !== null) throw new CliError("unavailable", "The Slopcamera menu bar exited during startup."); return 0; }
222
+ try { child = Bun.spawn([binary], foreground ? { stdin: "inherit", stdout: "inherit", stderr: "inherit" } : { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); } catch { throw new CliError("unavailable", "The Slopcamera menu bar couldn't start."); }
223
+ if (!foreground) { child.unref(); return await Promise.race([child.exited.then((code) => code as number | null), Bun.sleep(SETTLE_MS).then(() => null)]); }
215
224
  return await child.exited;
216
225
  }
217
- export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground"): Promise<void> {
226
+
227
+ /** Interprets the companion's exit: another running copy is success, not a failure. */
228
+ export function launchOutcome(code: number | null, foreground: boolean): Launched {
229
+ if (code === ALREADY_RUNNING_EXIT) return "already-running";
230
+ if (code === null && !foreground) return "running";
231
+ if (code === 0 && foreground) return "exited";
232
+ throw new CliError("unavailable", "The Slopcamera menu bar stopped while starting. Run it in the foreground to see why: slopcamera menubar --foreground");
233
+ }
234
+
235
+ interface Marks { readonly ok: string; readonly warn: string; readonly note: string; readonly next: string }
236
+ /** ✓ ⚠ 🔐 → with ASCII fallbacks for dumb terminals and non-UTF-8 locales. */
237
+ export function marks(env: Readonly<Record<string, string | undefined>>): Marks {
238
+ const utf8 = [env.LC_ALL, env.LC_CTYPE, env.LANG].some((value) => value !== undefined && /utf-?8/i.test(value));
239
+ const ascii = env.TERM === "dumb" || !utf8 || env.HRANESS_ASCII === "1";
240
+ return ascii ? { ok: "OK", warn: "WARN", note: "NOTE", next: "->" } : { ok: "✓", warn: "⚠", note: "🔐", next: "→" };
241
+ }
242
+
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
+ export function launchMessage(outcome: Launched, env: Readonly<Record<string, string | undefined>>): string {
249
+ const mark = marks(env);
250
+ if (outcome === "already-running") return `${mark.ok} Slopcamera is already in your menu bar. Look for 📷.`;
251
+ if (outcome === "running") return `${mark.ok} Slopcamera is in your menu bar. Look for 📷.`;
252
+ return "The Slopcamera menu bar closed.";
253
+ }
254
+
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
+ export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground", runBinary: BinaryRunner = spawnBinary): Promise<void> {
218
292
  const binary = resolveMenubarBinary(repositoryRoot, io.env);
219
- if (binary === null) throw new CliError("unavailable", "The Slopcamera menu bar is not installed. Build it separately or set SLOPCAMERA_MENUBAR.");
220
- const code = await runBinary(binary, mode === "foreground");
221
- if (code !== 0) throw new CliError("unavailable", "The Slopcamera menu bar exited during startup.");
222
- if (asJson) writeJson(io, { running: mode === "background", foreground: mode === "foreground" });
223
- else writeLine(io, mode === "foreground" ? "Slopcamera menu bar exited." : "Slopcamera menu bar is running.");
293
+ if (binary === null) throw new CliError("unavailable", NOT_BUILT);
294
+ const outcome = launchOutcome(await runBinary(binary, mode === "foreground"), mode === "foreground");
295
+ if (asJson) writeJson(io, { running: outcome !== "exited", foreground: mode === "foreground", alreadyRunning: outcome === "already-running" });
296
+ else writeLine(io, launchMessage(outcome, io.env));
224
297
  }
225
- export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean): Promise<void> {
298
+ export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean, runLaunchctl: LaunchctlRunner = launchctl, probe: ProcessProbe = companionProcessRunning): Promise<void> {
226
299
  assertMac(io.platform);
227
300
  const stable = installedBinaryPath(io.env);
228
301
  let state: LaunchAgentState;
229
302
  if (action === "install") {
230
303
  const binary = resolveMenubarBinary(repositoryRoot, io.env);
231
- if (binary === null) throw new CliError("unavailable", "The Slopcamera menu bar is not installed. Build it separately or set SLOPCAMERA_MENUBAR.");
232
- state = installLaunchAgent(binary, io.env, io.platform);
304
+ 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);
233
307
  } else {
234
- state = action === "uninstall" ? uninstallLaunchAgent(stable, io.env, io.platform) : launchAgentState(stable, io.env);
308
+ state = action === "uninstall" ? uninstallLaunchAgent(stable, io.env, io.platform, runLaunchctl) : launchAgentState(stable, io.env);
235
309
  }
236
- if (asJson) writeJson(io, { launchAgent: state });
237
- else writeLine(io, `Slopcamera menu-bar LaunchAgent: ${state}.`);
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));
238
314
  }
239
315
  export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
240
316
  const directory = outputsRoot(stateRoot);