Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ jobs:
workspaces: desktop
- name: Build the menu-bar companion
run: cargo build --release --locked --manifest-path desktop/Cargo.toml
- name: Test the menu-bar companion and its menu fixtures
run: cargo test --locked --manifest-path desktop/Cargo.toml
- name: Lint menu fixtures
run: >-
npx --yes
--package=https://github.com/hraness/desktop-foundation/releases/download/v0.8.0/hraness-desktop-foundation-0.8.0.tgz
companion lint-menu --strict desktop/menubar/fixtures/*.json

site:
name: Slopcamera site
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
- The fixed `slopcamera.studio.run` operation is an explicit native-authoring exception: it may execute a previously retained, hash-bound Python or native-scene bundle through a closed Blender/Manim/CadQuery host adapter. It requires a separate invocation-scoped trusted-current-user envelope; source import, pure planning and inspection stay inert. Runtime paths and argv remain host-owned, and the profile declares no OS sandbox or hermetic dependency closure.
- Preserve the root module and `./workflow` v0.8 imperative APIs. Add declarative graphs through `./code` and lower-level compiler contracts through `./code/advanced`; do not add `./code/testing` or `./code/workflows` exports.
- Treat explicitly imported workflow modules as trusted current-user Bun code. Compile graphs only against the host's closed capability projection, reject an unsupported capability before executor or resource admission, and do not add an open operation-registration hook.
- Treat the portable SDK and core as the canonical graph contract. The complete local host consumes that contract through its own closed projection and owns durable media execution. The optional Rust menu-bar companion under `desktop/` is a disposable outputs-directory client with no product authority.
- Treat the portable SDK and core as the canonical graph contract. The complete local host consumes that contract through its own closed projection and owns durable media execution. The optional Rust menu-bar companion under `desktop/` is a disposable client of the outputs directory and the CLI's status file, with no product authority.
- Model one project as immutable source plus explicit revisions, candidates, selections, and delivery variants. Keep ready work bounded by resource claims and serialize expensive encodes by default.
- Keep imperative workflows as explicitly imported trusted Bun modules over the fixed operation registry. Parse runtime input, bound and uniquely identify steps, drain dispatched work before returning, retain completed-step receipts on every failure path, and do not load caller-selected source paths.
- Keep semantic SDK, CLI, MCP, and workflow resource admission machine-global and crash-safe on macOS and Linux, with a truthful process-local fallback elsewhere. Preserve profile identity, FIFO admission for overlapping claims, callback-settlement ownership, and inherited lease descriptors through spawned vectorizer processes.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ Native engines install separately. The [source-install guide](docs/how-to/use-cu
explains workspace placement and durable-run identity; the
[capability reference](docs/reference/capabilities.md) names runtime requirements.

Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion can show
the files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion shows
what Slopcamera is rendering, how the last job ended, your last known credits
balance and the newest files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
companion directly and does not install or launch an application bundle. The
companion is a thin product binary over the shared
[desktop-foundation](https://github.com/hraness/desktop-foundation) crate, pinned
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3659,8 +3659,10 @@ export function parseCliArgs(argv: readonly string[]): CliCommand {
case "menubar": {
const parsed = parseOptions(argv.slice(1), { ...JSON_SPEC, "--foreground": "flag", "--background": "flag" });
const positionals = parsed.positionals;
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.");
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.");
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.");
// `start` opens it now and returns, like --background; the login item's hints use it.
if (positionals[0] === "start") return { kind: "menubar", action: "run", mode: "background", json: optionFlag(parsed, "--json") };
return { kind: "menubar", action: (positionals[0] as "install" | "uninstall" | "status" | undefined) ?? "run", mode: optionFlag(parsed, "--background") ? "background" : "foreground", json: optionFlag(parsed, "--json") };
}
case "outputs": {
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { activityLabel, recordActivity, recordCredits } from "./menubar-status";
import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
import { SlopcameraCloudError } from "../../../src/cloud-errors";
import {
Expand Down Expand Up @@ -5548,6 +5549,7 @@ async function handleCreditsStatus(
} catch (error) {
throw creditsErrorToCli(error);
}
recordCredits(context.stateRoot, { usd: balance.balance.usd, low: balance.lowBalance, checkedAt: context.clock() });
const view = {
configured: true,
tokenSource: loaded.source,
Expand Down Expand Up @@ -8419,6 +8421,7 @@ async function resolveMutationTarget(
export async function runCli(argv: readonly string[], dependencies: CliDependencies = {}): Promise<number> {
const io = dependencies.io ?? processIo;
let jsonRequested = argv.includes("--json") || argv.includes("--jsonl");
let activity: { readonly stateRoot: string; readonly label: string; readonly startedAt: number } | undefined;
try {
const command = parseCliArgs(argv);
jsonRequested = "json" in command ? command.json : command.kind === "events" && command.format !== "human";
Expand Down Expand Up @@ -8513,6 +8516,11 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
? {}
: { renderGalleryScene: dependencies.renderGalleryScene }),
};
const label = activityLabel(command);
if (label !== undefined) {
activity = { stateRoot, label, startedAt: context.clock() };
recordActivity(stateRoot, { state: "running", label, startedAt: activity.startedAt });
}
const mutationTarget = await resolveMutationTarget(paths, stateRoot, command);
await withCommandHostResources(context, command, async admittedContext => {
if (mutationTarget === undefined) {
Expand All @@ -8535,9 +8543,15 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
|| command.kind === "project-render" && command.action === "run" && !command.dryRun) {
reportUsefulResult(dependencies.onUsefulResult);
}
if (activity !== undefined) {
recordActivity(activity.stateRoot, { state: "done", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime() });
}
return 0;
} catch (error) {
const failure = asCliError(error);
if (activity !== undefined) {
recordActivity(activity.stateRoot, { state: "failed", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime(), error: failure.code });
}
const payload = {
error: {
code: failure.code,
Expand Down
34 changes: 17 additions & 17 deletions apps/desktop/cli/help.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { slopcameraSupportAdvancedHelp, slopcameraSupportHelpLine } from "../../../src/support";

import { HTML_OVERLAY_SCAFFOLD_KINDS } from "../html-overlay";

const HTML_OVERLAY_SCAFFOLD_KIND_HELP =
HTML_OVERLAY_SCAFFOLD_KINDS.join("|");

const GLOBAL_HELP = `slopcamera: a media studio for coding agents
const globalHelp = () => `slopcamera: a media studio for coding agents

Usage: slopcamera <command> [options]

Expand Down Expand Up @@ -35,11 +37,6 @@ Commands:
credits status|topup|wait|forget
Buy and inspect hosted generation credits
media audio|color Apply local non-destructive audio and video effects
menubar [--foreground|--background]
Show your outputs in the menu bar
menubar install|uninstall|status Open it at login, stop that, or check it
support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
Optional support; no feature requires payment
outputs Print the agent outputs directory
recordings list List recording bundles
projects list|create List projects or create one from a recording
Expand All @@ -63,29 +60,32 @@ Examples:
slopcamera render run REC --output out.mp4 Render a recording

Run slopcamera help <command> for command-specific help.
Optional support: slopcamera support · Turn off: HRANESS_SUPPORT_AUDIENCE=off`;
${slopcameraSupportHelpLine()}`;

const HELP: Readonly<Record<string, string>> = {
advanced: `${slopcameraSupportAdvancedHelp()}`,
menubar: `Usage:
slopcamera menubar [--foreground|--background] [--json]
slopcamera menubar install|uninstall|status [--json]
slopcamera menubar install|uninstall|status|start [--json]

Shows 📷 in the menu bar with your recent outputs. It only lists and opens
files in the outputs folder (slopcamera outputs).
Shows what Slopcamera is doing in the menu bar: a render in progress, how the
last job ended and your last known credits balance, plus your newest outputs.
It only reads that status and the outputs folder (slopcamera outputs).

menubar Open it now; it closes when this command ends
menubar --background Open it now and return
menubar install Open it now and every time you log in
menubar start Open it now and return (same as --background)
menubar install Open it every time you log in
menubar uninstall Stop opening it at login and remove the installed copy
menubar status Check whether it opens at login and is running

If it's already in your menu bar, starting it again says so and succeeds.
macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
time in System Settings › General › Login Items & Extensions.

The menu bar isn't in released packages yet. In a Slopcamera checkout, build it
with cargo build --release --manifest-path desktop/Cargo.toml and slopcamera
finds it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
The menu bar isn't in released packages yet, so it isn't listed in
slopcamera help. In a Slopcamera checkout, build it with
cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
capabilities: `Usage: slopcamera capabilities [--json]

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.`,
Expand Down Expand Up @@ -701,8 +701,8 @@ Long inactivity is analyzed and removed by default; use --keep-inactivity to opt
};

export function commandHelp(topic: readonly string[]): string {
if (topic.length === 0) return GLOBAL_HELP;
return HELP[topic[0]!] ?? GLOBAL_HELP;
if (topic.length === 0) return globalHelp();
return HELP[topic[0]!] ?? globalHelp();
}

export function completions(words: readonly string[]): readonly string[] {
Expand Down
80 changes: 80 additions & 0 deletions apps/desktop/cli/menubar-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";

import { parseCliArgs } from "./args";
import { activityLabel, MENUBAR_STATUS_FILE, readMenubarStatus, recordActivity, recordCredits } from "./menubar-status";
import { runCli } from "./commands";
import type { CliIo } from "./io";

const roots: string[] = [];
function root(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "slopcamera-status-")));
roots.push(dir);
return dir;
}
afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); });

describe("menu bar status file", () => {
test("labels only the commands that make media", () => {
expect(activityLabel(parseCliArgs(["render", "run", "rec", "--output", "out.mp4"]))).toBe("Rendering a recording");
expect(activityLabel(parseCliArgs(["render", "run", "rec", "--output", "out.mp4", "--dry-run"]))).toBeUndefined();
expect(activityLabel(parseCliArgs(["doctor"]))).toBeUndefined();
expect(activityLabel(parseCliArgs(["outputs"]))).toBeUndefined();
});

test("keeps activity and credits side by side in one owner-only file", () => {
const dir = root();
recordActivity(dir, { state: "running", label: "Rendering a recording", startedAt: 1 });
recordCredits(dir, { usd: "4.20", low: false, checkedAt: 2 });
recordActivity(dir, { state: "failed", label: "Rendering a recording", startedAt: 1, finishedAt: 3, error: "subprocess" });
expect(readMenubarStatus(dir)).toEqual({
schemaVersion: 1,
activity: { state: "failed", label: "Rendering a recording", startedAt: 1, finishedAt: 3, error: "subprocess" },
credits: { usd: "4.20", low: false, checkedAt: 2 },
});
expect(lstatSync(join(dir, MENUBAR_STATUS_FILE)).mode & 0o777).toBe(0o600);
expect(readdirSync(dir)).toEqual([MENUBAR_STATUS_FILE]);
});

test("a held lock skips the write instead of racing it", () => {
const dir = root();
writeFileSync(join(dir, `.${MENUBAR_STATUS_FILE}.lock`), "1\n", { mode: 0o600 });
recordCredits(dir, { usd: "4.20", low: false, checkedAt: 2 });
expect(readMenubarStatus(dir)).toEqual({ schemaVersion: 1 });
});

test("a stale lock from a crashed writer is reclaimed", () => {
const dir = root();
const lock = join(dir, `.${MENUBAR_STATUS_FILE}.lock`);
writeFileSync(lock, "999999\n", { mode: 0o600 });
const old = new Date(Date.now() - 60_000);
utimesSync(lock, old, old);
recordCredits(dir, { usd: "4.20", low: false, checkedAt: 2 });
expect(readMenubarStatus(dir).credits?.usd).toBe("4.20");
expect(readdirSync(dir)).toEqual([MENUBAR_STATUS_FILE]);
});

test("never throws and ignores files it didn't write", () => {
const dir = root();
writeFileSync(join(dir, MENUBAR_STATUS_FILE), "not json");
expect(readMenubarStatus(dir)).toEqual({ schemaVersion: 1 });
recordActivity(join(dir, "missing"), { state: "done", label: "Rendering a recording", startedAt: 1 });
rmSync(join(dir, MENUBAR_STATUS_FILE));
symlinkSync("/etc/hosts", join(dir, MENUBAR_STATUS_FILE));
expect(readMenubarStatus(dir)).toEqual({ schemaVersion: 1 });
});

test("a failed media command records its fixed error code", async () => {
const dir = root();
const io: CliIo = { cwd: () => dir, env: {}, now: () => new Date(5_000), platform: "darwin", stdout: () => undefined, stderr: () => undefined };
const code = await runCli(["render", "run", "missing-recording", "--output", join(dir, "out.mp4")], { io, stateRoot: dir, clock: () => 4_000 });
expect(code).not.toBe(0);
const status = JSON.parse(readFileSync(join(dir, MENUBAR_STATUS_FILE), "utf8"));
expect(status.activity.state).toBe("failed");
expect(status.activity.label).toBe("Rendering a recording");
expect(typeof status.activity.error).toBe("string");
expect(status.activity.startedAt).toBe(4_000);
});
});
Loading
Loading