@hraness/slopcamera 3.4.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.
Files changed (45) hide show
  1. package/README.md +32 -19
  2. package/apps/desktop/application/operation.ts +1 -1
  3. package/apps/desktop/cli/args.ts +3 -1
  4. package/apps/desktop/cli/commands.ts +22 -1
  5. package/apps/desktop/cli/help.ts +37 -13
  6. package/apps/desktop/cli/menubar-status.ts +155 -0
  7. package/apps/desktop/cli/menubar.ts +129 -101
  8. package/apps/desktop/dist/cli/main.js +334 -312
  9. package/apps/desktop/html-overlay/catalog.ts +1 -0
  10. package/apps/desktop/html-overlay/spatial.ts +10 -6
  11. package/apps/desktop/workflows/index.ts +1 -0
  12. package/dist/cli.js +7 -8
  13. package/dist/generate.js +1 -1
  14. package/dist/{index-qtn3s421.js → index-4z9y2wa2.js} +1 -1
  15. package/dist/{index-sy1n4zfy.js → index-fevf4ppk.js} +1 -1
  16. package/dist/{index-qry58nj2.js → index-mnp0bftq.js} +2 -2
  17. package/dist/index-nxtmy18t.js +3 -0
  18. package/dist/{index-abnk4h9c.js → index-qxfexvj3.js} +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/operations.js +1 -1
  21. package/dist/vectorize/worker.js +1 -1
  22. package/dist/workflow.js +1 -1
  23. package/docs/README.md +3 -3
  24. package/examples/studio/vgpu/README.md +1 -1
  25. package/package.json +5 -6
  26. package/skills/slopcamera/SKILL.md +4 -3
  27. package/skills/slopcamera/references/brand-illustrations.md +237 -0
  28. package/skills/slopcamera/references/gateway-media.md +1 -1
  29. package/skills/slopcamera/references/install.md +10 -1
  30. package/skills/slopcamera/references/patent-drawings.md +4 -4
  31. package/skills/slopcamera/references/support.md +1 -1
  32. package/skills/slopcamera/references/vectorization.md +3 -1
  33. package/src/cli.ts +13 -10
  34. package/src/code/public-operations.ts +9 -0
  35. package/src/generate.ts +44 -1
  36. package/src/generation-pricing.ts +217 -0
  37. package/src/mcp/tools.ts +4 -0
  38. package/src/operations.ts +4 -0
  39. package/src/support.ts +7 -1
  40. package/src/vectorize/command.ts +6 -1
  41. package/src/vectorize/supervisor.ts +23 -3
  42. package/src/vectorize/tool.ts +8 -4
  43. package/src/version.ts +1 -1
  44. package/src/visual-style.ts +4 -1
  45. package/dist/index-77fjfg9f.js +0 -3
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.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.
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.5.0 release](https://github.com/hraness/slopcamera/releases/tag/v3.5.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.5.0/hraness-slopcamera-3.5.0.tgz
55
58
  slopcamera skill install --target agents
56
59
  ```
57
60
 
@@ -87,8 +90,9 @@ Native engines install separately. The [source-install guide](docs/how-to/use-cu
87
90
  explains workspace placement and durable-run identity; the
88
91
  [capability reference](docs/reference/capabilities.md) names runtime requirements.
89
92
 
90
- Slopcamera is a CLI. On macOS, an optional unbundled menu-bar companion can show
91
- 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
92
96
  companion directly and does not install or launch an application bundle. The
93
97
  companion is a thin product binary over the shared
94
98
  [desktop-foundation](https://github.com/hraness/desktop-foundation) crate, pinned
@@ -134,7 +138,8 @@ architecture from dimensions, [build a parametric design](https://slopcamera.com
134
138
 
135
139
  ## What Slopcamera does
136
140
 
137
- Slopcamera is a local visual studio for coding agents.
141
+ Slopcamera lets your coding agent make images, diagrams, animation, 3D scenes,
142
+ and video from source files it can keep revising.
138
143
 
139
144
  ### Author scenes and direct cameras
140
145
 
@@ -153,7 +158,7 @@ rigged/morph profile in v3.3.1 admits bounded skins and morph targets. Saved spl
153
158
  capture appearance; they do not establish collision geometry or editable native
154
159
  meshes. See [Directed scenes](docs/spatial-scenes.md).
155
160
 
156
- Slopcamera v3.3.1 includes the cinematic planning loop: a `slopcamera.spatial-direction`
161
+ Slopcamera v3.3.1 introduced the cinematic planning loop: a `slopcamera.spatial-direction`
157
162
  document describes beats, actions, camera coverage, and look intents, compiles
158
163
  into proposed performance/camera/cinema/material-lighting/shot documents, and
159
164
  fans out into bounded galleries per axis. Declared effects bind into renders,
@@ -308,7 +313,7 @@ declarative graphs, approvals, and resuming work.
308
313
  workflows run with the current user's access. Hashes and receipts identify
309
314
  observed inputs and outputs; they do not make arbitrary code hermetic.
310
315
  - **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
316
+ scenes, and run six bounded operation codes. It does not
312
317
  expose every local CLI operation and never mutates project state.
313
318
 
314
319
  ## Design and trust
@@ -333,6 +338,14 @@ source identity alone does not promise identical pixels on another machine.
333
338
  See [Architecture](docs/architecture.md), [`SECURITY.md`](SECURITY.md),
334
339
  [`PRIVACY.md`](PRIVACY.md), and [`NOTICE.md`](NOTICE.md) for the detailed boundaries.
335
340
 
341
+ Your agent renders images, diagrams, scenes, animation, and video from source
342
+ files you keep, and important operations record their inputs and outputs in
343
+ receipts, so a rendered result arrives with the record of how it was made: the
344
+ design every Hraness project shares. [The thread through
345
+ hraness](https://hraness.com/writing/the-thread-through-hraness) follows that
346
+ design across the projects, and the [ALGAL
347
+ vision](https://algal.computer/docs/vision/) states the bet behind it.
348
+
336
349
  ## Documentation
337
350
 
338
351
  - **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.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] | install|uninstall|status");
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.4.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
@@ -739,6 +740,13 @@ function humanTime(microseconds: number): string {
739
740
 
740
741
  function requireCapability(capabilities: readonly Capability[], name: Capability["name"]): string {
741
742
  const capability = capabilityByName(capabilities, name);
743
+ if ((!capability.available || capability.command === undefined) && name === "face-analyzer") {
744
+ throw new CliError(
745
+ "unavailable",
746
+ "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.",
747
+ { capability: name, ...(capability.reason === undefined ? {} : { reason: capability.reason }) },
748
+ );
749
+ }
742
750
  if (!capability.available || capability.command === undefined) {
743
751
  throw new CliError(
744
752
  "unavailable",
@@ -5541,6 +5549,7 @@ async function handleCreditsStatus(
5541
5549
  } catch (error) {
5542
5550
  throw creditsErrorToCli(error);
5543
5551
  }
5552
+ recordCredits(context.stateRoot, { usd: balance.balance.usd, low: balance.lowBalance, checkedAt: context.clock() });
5544
5553
  const view = {
5545
5554
  configured: true,
5546
5555
  tokenSource: loaded.source,
@@ -8412,6 +8421,7 @@ async function resolveMutationTarget(
8412
8421
  export async function runCli(argv: readonly string[], dependencies: CliDependencies = {}): Promise<number> {
8413
8422
  const io = dependencies.io ?? processIo;
8414
8423
  let jsonRequested = argv.includes("--json") || argv.includes("--jsonl");
8424
+ let activity: { readonly stateRoot: string; readonly label: string; readonly startedAt: number } | undefined;
8415
8425
  try {
8416
8426
  const command = parseCliArgs(argv);
8417
8427
  jsonRequested = "json" in command ? command.json : command.kind === "events" && command.format !== "human";
@@ -8506,6 +8516,11 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
8506
8516
  ? {}
8507
8517
  : { renderGalleryScene: dependencies.renderGalleryScene }),
8508
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
+ }
8509
8524
  const mutationTarget = await resolveMutationTarget(paths, stateRoot, command);
8510
8525
  await withCommandHostResources(context, command, async admittedContext => {
8511
8526
  if (mutationTarget === undefined) {
@@ -8528,9 +8543,15 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
8528
8543
  || command.kind === "project-render" && command.action === "run" && !command.dryRun) {
8529
8544
  reportUsefulResult(dependencies.onUsefulResult);
8530
8545
  }
8546
+ if (activity !== undefined) {
8547
+ recordActivity(activity.stateRoot, { state: "done", label: activity.label, startedAt: activity.startedAt, finishedAt: io.now().getTime() });
8548
+ }
8531
8549
  return 0;
8532
8550
  } catch (error) {
8533
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
+ }
8534
8555
  const payload = {
8535
8556
  error: {
8536
8557
  code: failure.code,
@@ -1,15 +1,17 @@
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 — creative scenes and non-destructive media editing for coding agents
8
+ const globalHelp = () => `slopcamera: a media studio for coding agents
7
9
 
8
10
  Usage: slopcamera <command> [options]
9
11
 
10
12
  Commands:
11
- capabilities Discover the exact static capability modules and qualification status
12
- operations list|show Discover host-owned typed operations and policies
13
+ capabilities List what this build can do and how well each part is tested
14
+ operations list|show List the operations agents can run and their limits
13
15
  diagram init|check|render Create, validate, or render portable diagram sources
14
16
  diagram sheets init|check|render
15
17
  Create and render black-and-white drawing sheets
@@ -35,10 +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 [--background] Run the prebuilt menu-bar companion
39
- menubar install|uninstall|status Manage its per-user LaunchAgent
40
- support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
41
- Optional support; no feature requires payment
42
40
  outputs Print the agent outputs directory
43
41
  recordings list List recording bundles
44
42
  projects list|create List projects or create one from a recording
@@ -55,13 +53,39 @@ Commands:
55
53
  render plan|run <recording> Resolve or execute a render plan
56
54
  assets emoji search|resolve Find checked local emoji overlays
57
55
 
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.
56
+ Examples:
57
+ slopcamera doctor Check what this Mac can render
58
+ slopcamera html scaffold plain --output intro.html
59
+ Start an HTML scene
60
+ slopcamera render run REC --output out.mp4 Render a recording
61
61
 
62
- Run slopcamera help <command> for command-specific help.`;
62
+ Run slopcamera help <command> for command-specific help.
63
+ ${slopcameraSupportHelpLine()}`;
63
64
 
64
65
  const HELP: Readonly<Record<string, string>> = {
66
+ advanced: `${slopcameraSupportAdvancedHelp()}`,
67
+ menubar: `Usage:
68
+ slopcamera menubar [--foreground|--background] [--json]
69
+ slopcamera menubar install|uninstall|status|start [--json]
70
+
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).
74
+
75
+ menubar Open it now; it closes when this command ends
76
+ menubar start Open it now and return (same as --background)
77
+ menubar install Open it every time you log in
78
+ menubar uninstall Stop opening it at login and remove the installed copy
79
+ menubar status Check whether it opens at login and is running
80
+
81
+ If it's already in your menu bar, starting it again says so and succeeds.
82
+ macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
83
+ time in System Settings › General › Login Items & Extensions.
84
+
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.`,
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.`,
@@ -677,8 +701,8 @@ Long inactivity is analyzed and removed by default; use --keep-inactivity to opt
677
701
  };
678
702
 
679
703
  export function commandHelp(topic: readonly string[]): string {
680
- if (topic.length === 0) return GLOBAL_HELP;
681
- return HELP[topic[0]!] ?? GLOBAL_HELP;
704
+ if (topic.length === 0) return globalHelp();
705
+ return HELP[topic[0]!] ?? globalHelp();
682
706
  }
683
707
 
684
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
+ }