@hraness/slopcamera 3.7.0 → 3.8.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
@@ -103,15 +103,16 @@ Native engines install separately. The [source-install guide](docs/how-to/use-cu
103
103
  explains workspace placement and durable-run identity; the
104
104
  [capability reference](docs/reference/capabilities.md) names runtime requirements.
105
105
 
106
- SlopCamera is a CLI. On macOS, an optional unbundled menu-bar companion shows
107
- what SlopCamera is rendering, how the last job ended, your last known credits
108
- balance and the newest files in the agent outputs directory; `slopcamera menubar` runs a prebuilt
109
- companion directly and does not install or launch an application bundle. The
110
- companion is a thin product binary over the shared
111
- [desktop-foundation](https://github.com/hraness/desktop-foundation) crate, pinned
112
- by immutable tag. Build it explicitly with
113
- `cargo build --release --manifest-path desktop/menubar/Cargo.toml`
114
- when working from source.
106
+ SlopCamera is a CLI with no menu-bar app or background process.
107
+ `slopcamera status` shows what is running, how the last job ended, your last
108
+ known credits balance, the newest outputs and whether the retired menu-bar
109
+ companion still opens at login. `slopcamera tui` keeps
110
+ that screen open and refreshes it; `slopcamera tui --snapshot` prints it once
111
+ and `--json` returns the data. `slopcamera outputs list|open|reveal` lists the
112
+ newest outputs or opens one on this Mac, `slopcamera commands --json` lists
113
+ every command with what it may change, and `slopcamera legacy retire` stops
114
+ the retired menu-bar companion opening at login by moving its LaunchAgent aside; it
115
+ deletes nothing, and `slopcamera doctor` prints the command that restores it.
115
116
 
116
117
  <details>
117
118
  <summary>Historical Atet release evidence</summary>
@@ -1,6 +1,6 @@
1
1
  # SlopCamera local host and CLI
2
2
 
3
- Slopcamera reads finished recording bundles from repository-local directories, combines independent camera and audio takes on one typed project clock, then applies non-destructive edits through the `slopcamera` CLI. The CLI is the product surface for agents. An optional unbundled `slopcamera menubar` companion surfaces the agent outputs directory in the macOS menu bar; it carries no capture or editing authority.
3
+ Slopcamera reads finished recording bundles from repository-local directories, combines independent camera and audio takes on one typed project clock, then applies non-destructive edits through the `slopcamera` CLI. The CLI is the product surface for agents. `slopcamera status` and `slopcamera tui` show what is running, credits and the newest agent outputs in the terminal.
4
4
 
5
5
  ## Recording bundles
6
6
 
@@ -819,13 +819,6 @@ bun run test:html-overlay
819
819
  bun run benchmark:code-concurrency
820
820
  ```
821
821
 
822
- The optional Rust menu-bar companion lives under `desktop/` at the repository root and builds with Cargo:
823
-
824
- ```sh
825
- cargo build --manifest-path desktop/menubar/Cargo.toml
826
- slopcamera menubar
827
- ```
828
-
829
822
  `test:html-overlay` is portable and leaves the real-browser tests registered as
830
823
  skipped. `test:html-overlay:browser:macos` runs the real Chrome frame suite,
831
824
  including a declared PNG. `test:html-overlay:libraries:macos` additionally
@@ -847,4 +840,4 @@ The compiled smoke copies the executable away from the checkout and proves
847
840
  diagram initialization/rendering plus PNG-to-SVG vectorization without
848
841
  `node_modules`.
849
842
 
850
- Screen recording, microphone, camera, Input Monitoring, and Accessibility are separate macOS permissions. A missing optional source becomes a typed diagnostic; strict-input mode fails before recording begins. Slopcamera ships as a CLI with an unbundled menu-bar companion; it does not produce, install, sign, or notarize a desktop application.
843
+ Screen recording, microphone, camera, Input Monitoring, and Accessibility are separate macOS permissions. A missing optional source becomes a typed diagnostic; strict-input mode fails before recording begins. Slopcamera ships as a CLI; it does not produce, install, sign, or notarize a desktop application.
@@ -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.7.0" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.8.0" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -459,7 +459,7 @@ export async function withPreparedSpatialAssets<Result>(
459
459
  }
460
460
  const decoded = await raster(bytes, interpretation.alpha === "opaque");
461
461
  if (decoded.width !== interpretation.width || decoded.height !== interpretation.height) throw new RangeError("Image dimensions differ from its interpretation.");
462
- profiles.add("sdr-png-jpeg-sharp-0.35.3");
462
+ profiles.add("sdr-png-jpeg-sharp-0.35.4");
463
463
  return publishRaster(decoded);
464
464
  };
465
465
  const native = async (argv: readonly [string, ...string[]], maxOutputBytes = 16_384) => {
@@ -5,18 +5,20 @@ import { join } from "node:path";
5
5
  import type { CliCommand } from "./args";
6
6
 
7
7
  /**
8
- * The small status file the menu bar reads: what Slopcamera is doing now,
8
+ * The small status file `slopcamera status` and `slopcamera tui` read: what Slopcamera is doing now,
9
9
  * how the last render ended, and the last credits balance a command saw.
10
10
  * It holds plain labels, times and fixed error codes only — never paths,
11
11
  * prompts, tokens or account identifiers. Writes are best effort: a status
12
12
  * that can't be saved never fails the command.
13
13
  */
14
- export const MENUBAR_STATUS_FILE = "menubar-status.json";
14
+ // The on-disk name predates the menu bar's retirement and stays frozen, so a
15
+ // rolled-back release and existing state keep reading the same file.
16
+ export const ACTIVITY_STATUS_FILE = "menubar-status.json";
15
17
  const MAX_STATUS_BYTES = 16 * 1024;
16
18
 
17
19
  export type ActivityState = "running" | "done" | "failed";
18
20
 
19
- export interface MenubarActivity {
21
+ export interface RecordedActivity {
20
22
  readonly state: ActivityState;
21
23
  readonly label: string;
22
24
  readonly startedAt: number;
@@ -24,16 +26,16 @@ export interface MenubarActivity {
24
26
  readonly error?: string;
25
27
  }
26
28
 
27
- export interface MenubarCredits {
29
+ export interface RecordedCredits {
28
30
  readonly usd: string;
29
31
  readonly low: boolean;
30
32
  readonly checkedAt: number;
31
33
  }
32
34
 
33
- export interface MenubarStatus {
35
+ export interface ActivityStatus {
34
36
  readonly schemaVersion: 1;
35
- readonly activity?: MenubarActivity;
36
- readonly credits?: MenubarCredits;
37
+ readonly activity?: RecordedActivity;
38
+ readonly credits?: RecordedCredits;
37
39
  }
38
40
 
39
41
  /** Plain labels for the commands that make media. Everything else leaves the status alone. */
@@ -58,13 +60,13 @@ export function activityLabel(command: CliCommand): string | undefined {
58
60
  }
59
61
  }
60
62
 
61
- function parse(text: string): MenubarStatus {
63
+ function parse(text: string): ActivityStatus {
62
64
  try {
63
65
  const value: unknown = JSON.parse(text);
64
66
  if (typeof value !== "object" || value === null || (value as { schemaVersion?: unknown }).schemaVersion !== 1) return { schemaVersion: 1 };
65
67
  const record = value as Record<string, unknown>;
66
- const activity = record.activity as MenubarActivity | undefined;
67
- const credits = record.credits as MenubarCredits | undefined;
68
+ const activity = record.activity as RecordedActivity | undefined;
69
+ const credits = record.credits as RecordedCredits | undefined;
68
70
  return {
69
71
  schemaVersion: 1,
70
72
  ...(activity !== undefined && typeof activity === "object" && typeof activity.label === "string" ? { activity } : {}),
@@ -75,8 +77,8 @@ function parse(text: string): MenubarStatus {
75
77
  }
76
78
  }
77
79
 
78
- export function readMenubarStatus(stateRoot: string): MenubarStatus {
79
- const path = join(stateRoot, MENUBAR_STATUS_FILE);
80
+ export function readActivityStatus(stateRoot: string): ActivityStatus {
81
+ const path = join(stateRoot, ACTIVITY_STATUS_FILE);
80
82
  try {
81
83
  const info = lstatSync(path);
82
84
  if (!info.isFile() || info.size > MAX_STATUS_BYTES) return { schemaVersion: 1 };
@@ -86,7 +88,7 @@ export function readMenubarStatus(stateRoot: string): MenubarStatus {
86
88
  }
87
89
  }
88
90
 
89
- const LOCK_FILE = `.${MENUBAR_STATUS_FILE}.lock`;
91
+ const LOCK_FILE = `.${ACTIVITY_STATUS_FILE}.lock`;
90
92
  /** A held lock blocks a write for this long before it is skipped. */
91
93
  const LOCK_BUDGET_MS = 250;
92
94
  /** A lock older than this is a crashed writer's and is reclaimed. */
@@ -128,14 +130,14 @@ function withStatusLock<T>(stateRoot: string, update: () => T): T | undefined {
128
130
  }
129
131
 
130
132
  /** Replaces the status file atomically with an owner-only file. Never throws. */
131
- export function writeMenubarStatus(stateRoot: string, update: (current: MenubarStatus) => MenubarStatus): void {
133
+ export function writeActivityStatus(stateRoot: string, update: (current: ActivityStatus) => ActivityStatus): void {
132
134
  withStatusLock(stateRoot, () => {
133
- const path = join(stateRoot, MENUBAR_STATUS_FILE);
134
- const staged = join(stateRoot, `.${MENUBAR_STATUS_FILE}.${randomUUID()}.tmp`);
135
+ const path = join(stateRoot, ACTIVITY_STATUS_FILE);
136
+ const staged = join(stateRoot, `.${ACTIVITY_STATUS_FILE}.${randomUUID()}.tmp`);
135
137
  try {
136
138
  const root = lstatSync(stateRoot);
137
139
  if (!root.isDirectory()) return;
138
- const text = `${JSON.stringify(update(readMenubarStatus(stateRoot)))}\n`;
140
+ const text = `${JSON.stringify(update(readActivityStatus(stateRoot)))}\n`;
139
141
  if (Buffer.byteLength(text) > MAX_STATUS_BYTES) return;
140
142
  const fd = openSync(staged, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
141
143
  try { writeSync(fd, text); } finally { closeSync(fd); }
@@ -147,10 +149,10 @@ export function writeMenubarStatus(stateRoot: string, update: (current: MenubarS
147
149
  });
148
150
  }
149
151
 
150
- export function recordActivity(stateRoot: string, activity: MenubarActivity): void {
151
- writeMenubarStatus(stateRoot, (current) => ({ ...current, activity }));
152
+ export function recordActivity(stateRoot: string, activity: RecordedActivity): void {
153
+ writeActivityStatus(stateRoot, (current) => ({ ...current, activity }));
152
154
  }
153
155
 
154
- export function recordCredits(stateRoot: string, credits: MenubarCredits): void {
155
- writeMenubarStatus(stateRoot, (current) => ({ ...current, credits }));
156
+ export function recordCredits(stateRoot: string, credits: RecordedCredits): void {
157
+ writeActivityStatus(stateRoot, (current) => ({ ...current, credits }));
156
158
  }
@@ -335,7 +335,6 @@ export type CliCommand =
335
335
  readonly tint: number | undefined;
336
336
  readonly videoStreamIndex: number;
337
337
  } & JsonOption)
338
- | ({ readonly kind: "menubar"; readonly action: "run" | "install" | "uninstall" | "status"; readonly mode: "foreground" | "background" } & JsonOption)
339
338
  | ({ readonly kind: "outputs" } & JsonOption)
340
339
  | ({ readonly kind: "credits-status" } & JsonOption)
341
340
  | ({
@@ -3716,15 +3715,8 @@ export function parseCliArgs(argv: readonly string[]): CliCommand {
3716
3715
  case "project": return parseProject(argv.slice(1));
3717
3716
  case "inspect": return parseInspect(argv.slice(1));
3718
3717
  case "events": return parseEvents(argv.slice(1));
3719
- case "menubar": {
3720
- const parsed = parseOptions(argv.slice(1), { ...JSON_SPEC, "--foreground": "flag", "--background": "flag" });
3721
- const positionals = parsed.positionals;
3722
- 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.");
3723
- 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.");
3724
- // `start` opens it now and returns, like --background; the login item's hints use it.
3725
- if (positionals[0] === "start") return { kind: "menubar", action: "run", mode: "background", json: optionFlag(parsed, "--json") };
3726
- return { kind: "menubar", action: (positionals[0] as "install" | "uninstall" | "status" | undefined) ?? "run", mode: optionFlag(parsed, "--background") ? "background" : "foreground", json: optionFlag(parsed, "--json") };
3727
- }
3718
+ // Retired with the menu-bar companion; the old command name explains where it went.
3719
+ case "menubar": throw new CliError("usage", "The menu bar was retired. Use slopcamera status or slopcamera tui to see what Slopcamera is doing, and slopcamera legacy retire to stop an old copy opening at login.");
3728
3720
  case "outputs": {
3729
3721
  const parsed = parseOptions(argv.slice(1), JSON_SPEC);
3730
3722
  exactPositionals(parsed, 0, "slopcamera outputs [--json]");
@@ -266,7 +266,6 @@ export function commandHostResourceClaims(
266
266
  case "help":
267
267
  case "capabilities":
268
268
  case "inspect":
269
- case "menubar":
270
269
  case "operations-list":
271
270
  case "operations-show":
272
271
  case "outputs":
@@ -1,4 +1,4 @@
1
- import { activityLabel, recordActivity, recordCredits } from "./menubar-status";
1
+ import { activityLabel, recordActivity, recordCredits } from "./activity-status";
2
2
  import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
3
3
  import { SlopcameraCloudError } from "../../../src/cloud-errors";
4
4
  import {
@@ -190,7 +190,8 @@ import { PlaywrightHtmlOverlayRenderer } from "./html-overlay-renderer";
190
190
  import { executeHtmlSceneCommand } from "./html-scene";
191
191
  import { executeHtmlFilmCommand, formatHtmlFilmResult } from "./html-film";
192
192
  import { BunProcessRunner, processIo, writeJson, writeLine, type CliIo, type ProcessRunner } from "./io";
193
- import { launchMenubar, manageMenubar, reportOutputsRoot } from "./menubar";
193
+ import { reportOutputsRoot } from "./outputs";
194
+ import { inspectLegacyLogin, retiredLegacyLogins } from "./legacy-login";
194
195
  import {
195
196
  codePreparationHostResourceClaims,
196
197
  combineHostResourceClaims,
@@ -364,7 +365,7 @@ import {
364
365
  workflowRunStore,
365
366
  } from "./workflow-runs";
366
367
 
367
- export const SLOPCAMERA_VERSION = "3.7.0";
368
+ export const SLOPCAMERA_VERSION = "3.8.0";
368
369
 
369
370
  // Legacy direct renders predate per-target output contracts. Keep them
370
371
  // bounded generously enough for long-form production while preventing one
@@ -7984,6 +7985,10 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
7984
7985
  platform: { architecture: process.arch, macOSVersion: macVersion.stdout.trim(), name: context.io.platform },
7985
7986
  repositoryRoot: context.paths.repositoryRoot,
7986
7987
  tools: Object.fromEntries(capabilities.map((capability) => [capability.name, capability])),
7988
+ legacyLoginItem: {
7989
+ ...inspectLegacyLogin(context.io.env.HOME),
7990
+ retired: retiredLegacyLogins(context.io.env.HOME),
7991
+ },
7987
7992
  version: context.version,
7988
7993
  };
7989
7994
  writeValue(context.io, command.json, output, () => [
@@ -7992,6 +7997,12 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
7992
7997
  `platform ${output.platform.name} ${output.platform.macOSVersion}`,
7993
7998
  ...capabilities.map((item) => `${item.name} ${item.available ? item.version ?? item.command : "unavailable"}`),
7994
7999
  `emoji ${emoji.provenance} ${emoji.installedCount}/${emoji.catalogCount}; generate: ${emoji.generationCommand}`,
8000
+ output.legacyLoginItem.state === "found"
8001
+ ? "login the old menu bar still opens at login; run: slopcamera legacy retire"
8002
+ : output.legacyLoginItem.state === "not-ours"
8003
+ ? `login ${output.legacyLoginItem.label ?? "an item"} was changed outside Slopcamera and is left alone`
8004
+ : "login nothing starts at login",
8005
+ ...output.legacyLoginItem.retired.map((item) => `retired ${item.path}; restore: ${item.restore}`),
7995
8006
  ].join("\n"));
7996
8007
  return;
7997
8008
  }
@@ -8032,11 +8043,6 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
8032
8043
  case "credits-forget": await handleCreditsForget(context, command); return;
8033
8044
  case "media-audio": await handleMediaAudio(context, command); return;
8034
8045
  case "media-color": await handleMediaColor(context, command); return;
8035
- case "menubar": {
8036
- if (command.action === "run") await launchMenubar(context.io, context.paths.repositoryRoot, command.json, command.mode);
8037
- else await manageMenubar(context.io, context.paths.repositoryRoot, command.action, command.json);
8038
- return;
8039
- }
8040
8046
  case "outputs": {
8041
8047
  await reportOutputsRoot(context.io, context.stateRoot, command.json);
8042
8048
  return;
@@ -8365,7 +8371,6 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
8365
8371
  case "fillers-list":
8366
8372
  case "emoji-search":
8367
8373
  case "emoji-resolve":
8368
- case "menubar":
8369
8374
  case "outputs":
8370
8375
  case "complete": return undefined;
8371
8376
  // Fresh projects write outside an existing mutable bundle and publish by
@@ -0,0 +1,271 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+
4
+ import { okEnvelope, type Envelope, type NextStep } from "@hraness/desktop-foundation/registry";
5
+ import { box, clean, table, type View } from "@hraness/desktop-foundation/tui";
6
+
7
+ import type { LegacyLoginReport } from "./legacy-login";
8
+ import type { ActivityStatus } from "./activity-status";
9
+
10
+ /**
11
+ * `slopcamera status` and `slopcamera tui`: what Slopcamera is doing, how
12
+ * the last job ended, the last credits balance a command saw, the newest
13
+ * outputs, and whether the retired menu bar still starts at login. Pure
14
+ * except `listOutputs`, so every state has a golden.
15
+ */
16
+ export const STATUS_SCHEMA = "slopcamera.status/1";
17
+ export const OUTPUTS_LIMIT = 3;
18
+ /** A job still marked running after this long crashed or was killed. */
19
+ export const STALE_RUNNING_MS = 6 * 60 * 60 * 1000;
20
+ const MAX_LABEL_CHARS = 40;
21
+
22
+ export type ActivityState = "idle" | "running" | "done" | "failed" | "stale";
23
+
24
+ export interface StatusActivity {
25
+ readonly state: ActivityState;
26
+ readonly headline: string;
27
+ readonly detail: string;
28
+ readonly label?: string;
29
+ readonly startedAt?: string;
30
+ readonly finishedAt?: string;
31
+ readonly error?: string;
32
+ }
33
+
34
+ export interface StatusCredits {
35
+ readonly usd: string;
36
+ readonly low: boolean;
37
+ readonly checkedAt: string;
38
+ }
39
+
40
+ export interface StatusOutput {
41
+ readonly name: string;
42
+ readonly kind: string;
43
+ readonly bytes: number;
44
+ readonly modifiedAt: string;
45
+ }
46
+
47
+ export interface StatusOutputs {
48
+ readonly root: string;
49
+ readonly total: number;
50
+ readonly latest: readonly StatusOutput[];
51
+ }
52
+
53
+ export interface StatusData {
54
+ readonly product: "slopcamera";
55
+ readonly version: string;
56
+ readonly attention: boolean;
57
+ readonly activity: StatusActivity;
58
+ readonly credits: StatusCredits | null;
59
+ readonly outputs: StatusOutputs;
60
+ readonly legacyLoginItem: LegacyLoginReport;
61
+ }
62
+
63
+ export interface StatusInput {
64
+ readonly version: string;
65
+ readonly status: ActivityStatus;
66
+ readonly outputs: StatusOutputs;
67
+ readonly legacyLogin: LegacyLoginReport;
68
+ readonly now: Date;
69
+ }
70
+
71
+ export function ago(thenMs: number, nowMs: number): string {
72
+ const seconds = Math.max(0, Math.floor((nowMs - thenMs) / 1000));
73
+ if (seconds < 60) return "just now";
74
+ if (seconds < 3_600) return `${Math.floor(seconds / 60)} min ago`;
75
+ if (seconds < 7_200) return "1 hour ago";
76
+ if (seconds < 86_400) return `${Math.floor(seconds / 3_600)} hours ago`;
77
+ if (seconds < 172_800) return "yesterday";
78
+ return `${Math.floor(seconds / 86_400)} days ago`;
79
+ }
80
+
81
+ export function dollars(usd: string): string {
82
+ const bare = usd.replace(/^\$+/u, "");
83
+ return bare !== "" && /^[0-9.,]+$/u.test(bare) ? `$${bare}` : usd;
84
+ }
85
+
86
+ export function explain(code: string): string {
87
+ switch (code) {
88
+ case "cancelled": return "It was stopped before it finished";
89
+ case "not-found": return "A file it needed wasn't found";
90
+ case "unavailable": return "A tool it needed isn't available on this computer";
91
+ case "authorization-required": return "It needs more credits first";
92
+ case "subprocess": return "A render tool stopped with an error";
93
+ case "invalid-data":
94
+ case "incompatible": return "Its input wasn't in a form Slopcamera can use";
95
+ case "unsafe-path": return "It was asked to use a folder Slopcamera won't write to";
96
+ case "usage": return "The command had a typo or a missing option";
97
+ case "conflict": return "Something else was changing the same project";
98
+ default: return "Something went wrong. Run the command again to see why";
99
+ }
100
+ }
101
+
102
+ function plain(text: string, max: number): boolean {
103
+ return text !== "" && [...text].length <= max && /^[\p{L}\p{N} .,'$-]+$/u.test(text);
104
+ }
105
+
106
+ function iso(ms: number): string | undefined {
107
+ return Number.isFinite(ms) && ms >= 0 && ms <= 8.64e15 ? new Date(ms).toISOString() : undefined;
108
+ }
109
+
110
+ function activityOf(status: ActivityStatus, now: number): StatusActivity {
111
+ const activity = status.activity;
112
+ if (activity === undefined || !plain(activity.label, MAX_LABEL_CHARS) || !Number.isFinite(activity.startedAt)) {
113
+ return { state: "idle", headline: "Ready", detail: "Nothing rendered yet" };
114
+ }
115
+ const startedAt = iso(activity.startedAt);
116
+ const finishedMs = activity.finishedAt ?? activity.startedAt;
117
+ const finishedAt = activity.finishedAt === undefined ? undefined : iso(activity.finishedAt);
118
+ const times = { ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };
119
+ if (activity.state === "running") {
120
+ if (now - activity.startedAt < STALE_RUNNING_MS) {
121
+ return { state: "running", headline: activity.label, detail: `Started ${ago(activity.startedAt, now)}`, label: activity.label, ...times };
122
+ }
123
+ return { state: "stale", headline: "Last job stopped early", detail: `${activity.label} · started ${ago(activity.startedAt, now)}`, label: activity.label, ...times };
124
+ }
125
+ if (activity.state === "failed") {
126
+ const error = typeof activity.error === "string" && /^[a-z][a-z-]{0,40}$/u.test(activity.error) ? activity.error : "internal";
127
+ return { state: "failed", headline: "Last job didn't finish", detail: `${activity.label} · ${explain(error)}`, label: activity.label, error, ...times };
128
+ }
129
+ return { state: "done", headline: "Ready", detail: `Last job finished ${ago(finishedMs, now)}`, label: activity.label, ...times };
130
+ }
131
+
132
+ function creditsOf(status: ActivityStatus): StatusCredits | null {
133
+ const credits = status.credits;
134
+ if (credits === undefined || !plain(credits.usd, 16) || typeof credits.low !== "boolean") return null;
135
+ const checkedAt = iso(credits.checkedAt);
136
+ return checkedAt === undefined ? null : { usd: credits.usd, low: credits.low, checkedAt };
137
+ }
138
+
139
+ export function statusData(input: StatusInput): StatusData {
140
+ const now = input.now.getTime();
141
+ const activity = activityOf(input.status, now);
142
+ const credits = creditsOf(input.status);
143
+ return {
144
+ product: "slopcamera",
145
+ version: input.version,
146
+ attention: activity.state === "failed" || credits?.low === true || input.legacyLogin.state === "found",
147
+ activity,
148
+ credits,
149
+ outputs: input.outputs,
150
+ legacyLoginItem: input.legacyLogin,
151
+ };
152
+ }
153
+
154
+ export function statusNext(data: StatusData): NextStep[] {
155
+ const next: NextStep[] = [];
156
+ if (data.activity.state === "failed" || data.activity.state === "stale") {
157
+ next.push({ command: "slopcamera runs list --json", why: "See which run stopped and whether it can resume.", audience: "agent" });
158
+ }
159
+ if (data.credits?.low === true) {
160
+ next.push({ command: "slopcamera credits topup", why: "Credits are low; adding more opens a checkout for a person.", audience: "human" });
161
+ }
162
+ if (data.legacyLoginItem.state === "found") {
163
+ next.push({ command: "slopcamera legacy retire --json", why: "The retired menu bar still opens at login; this moves its login item aside.", audience: "agent" });
164
+ }
165
+ return next;
166
+ }
167
+
168
+ export function statusEnvelope(input: StatusInput): Envelope<StatusData> {
169
+ const data = statusData(input);
170
+ const next = statusNext(data);
171
+ return okEnvelope(STATUS_SCHEMA, data, next.length === 0 ? undefined : next, input.now);
172
+ }
173
+
174
+ function size(bytes: number): string {
175
+ if (bytes < 1024) return `${bytes} B`;
176
+ const units = ["KB", "MB", "GB", "TB"];
177
+ let value = bytes / 1024;
178
+ let unit = 0;
179
+ while (value >= 1024 && unit < units.length - 1) {
180
+ value /= 1024;
181
+ unit += 1;
182
+ }
183
+ return `${value < 10 ? Math.round(value * 10) / 10 : Math.round(value)} ${units[unit]}`;
184
+ }
185
+
186
+ function day(modifiedAt: string, now: number): string {
187
+ const then = Date.parse(modifiedAt);
188
+ const days = Math.floor((now - then) / 86_400_000);
189
+ if (days <= 0) return "today";
190
+ if (days === 1) return "yesterday";
191
+ return `${days} days ago`;
192
+ }
193
+
194
+ function mark(state: ActivityState): string {
195
+ switch (state) {
196
+ case "idle": return "○";
197
+ case "running": return "↻";
198
+ case "done": return "✓";
199
+ default: return "⚠";
200
+ }
201
+ }
202
+
203
+ function loginLine(report: LegacyLoginReport): string {
204
+ switch (report.state) {
205
+ case "found": return "⚠ The old menu bar still opens at login · run slopcamera legacy retire";
206
+ case "not-ours": return "– A menu bar login item changed outside Slopcamera is left alone";
207
+ default: return "✓ Nothing starts at login";
208
+ }
209
+ }
210
+
211
+ /** The one Status view, rendered by both `tui` and `tui --snapshot`. */
212
+ export function statusViews(now: () => Date): View<StatusData>[] {
213
+ return [{
214
+ id: "status",
215
+ title: "Slopcamera",
216
+ render(data, width) {
217
+ const nowMs = now().getTime();
218
+ const lines = [`${mark(data.activity.state)} ${clean(data.activity.headline)} · ${clean(data.activity.detail)}`];
219
+ if (data.credits !== null) {
220
+ const checked = ago(Date.parse(data.credits.checkedAt), nowMs);
221
+ lines.push(data.credits.low
222
+ ? `⚠ Credits are low · ${dollars(data.credits.usd)} left · checked ${checked}`
223
+ : `✓ ${dollars(data.credits.usd)} in credits · Checked ${checked}`);
224
+ }
225
+ const outputs = data.outputs.latest.length === 0
226
+ ? ["No outputs yet · Agents save finished files here"]
227
+ : table(["Output", "Size", "Saved", "Type"], data.outputs.latest.map(item => [
228
+ clean(item.name), size(item.bytes), day(item.modifiedAt, nowMs), item.kind,
229
+ ]), width - 2);
230
+ const more = data.outputs.total > data.outputs.latest.length
231
+ ? [`${data.outputs.total} outputs · slopcamera outputs list --json`]
232
+ : ["slopcamera outputs open"];
233
+ return [
234
+ ...box("Now", lines, width),
235
+ ...box("Outputs", [...outputs, ...more], width),
236
+ ...box("Login", [loginLine(data.legacyLoginItem)], width),
237
+ ];
238
+ },
239
+ }];
240
+ }
241
+
242
+ function kindOf(name: string): string {
243
+ const extension = extname(name).slice(1);
244
+ return extension === "" || extension.length > 8 ? "File" : extension.toUpperCase();
245
+ }
246
+
247
+ /**
248
+ * The newest regular files at the top of the outputs folder. Hidden files,
249
+ * links and folders are skipped; a missing folder is an empty list.
250
+ */
251
+ export function listOutputs(root: string, limit: number): StatusOutputs {
252
+ let names: string[];
253
+ try {
254
+ names = readdirSync(root);
255
+ } catch {
256
+ return { root, total: 0, latest: [] };
257
+ }
258
+ const files: StatusOutput[] = [];
259
+ for (const name of names) {
260
+ if (name.startsWith(".")) continue;
261
+ try {
262
+ const info = lstatSync(join(root, name));
263
+ if (!info.isFile()) continue;
264
+ files.push({ name, kind: kindOf(name), bytes: info.size, modifiedAt: new Date(info.mtimeMs).toISOString() });
265
+ } catch {
266
+ // Removed while listing.
267
+ }
268
+ }
269
+ files.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.name.localeCompare(right.name));
270
+ return { root, total: files.length, latest: files.slice(0, limit) };
271
+ }
@@ -39,6 +39,11 @@ Commands:
39
39
  Buy and inspect hosted generation credits
40
40
  media audio|color Apply local non-destructive audio and video effects
41
41
  outputs Print the agent outputs directory
42
+ outputs list|open|reveal List the newest outputs or open one on this Mac
43
+ status What Slopcamera is doing, credits and newest outputs
44
+ tui [--snapshot] The status screen; --snapshot prints it once
45
+ commands [--json] List every command with what it may change
46
+ legacy retire Stop the old menu bar opening at login
42
47
  recordings list List recording bundles
43
48
  projects list|create List projects or create one from a recording
44
49
  project inspect|add|edit|render
@@ -65,28 +70,6 @@ ${slopcameraSupportHelpLine()}`;
65
70
 
66
71
  const HELP: Readonly<Record<string, string>> = {
67
72
  advanced: `${slopcameraSupportAdvancedHelp()}`,
68
- menubar: `Usage:
69
- slopcamera menubar [--foreground|--background] [--json]
70
- slopcamera menubar install|uninstall|status|start [--json]
71
-
72
- Shows what Slopcamera is doing in the menu bar: a render in progress, how the
73
- last job ended and your last known credits balance, plus your newest outputs.
74
- It only reads that status and the outputs folder (slopcamera outputs).
75
-
76
- menubar Open it now; it closes when this command ends
77
- menubar start Open it now and return (same as --background)
78
- menubar install Open it 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, so it isn't listed in
87
- slopcamera help. In a Slopcamera checkout, build it with
88
- cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
89
- it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
90
73
  capabilities: `Usage: slopcamera capabilities [--json]
91
74
 
92
75
  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.`,
@@ -729,7 +712,7 @@ export function commandHelp(topic: readonly string[]): string {
729
712
 
730
713
  export function completions(words: readonly string[]): readonly string[] {
731
714
  const topLevel = [
732
- "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
715
+ "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "support", "outputs", "status", "tui", "commands", "legacy", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
733
716
  ];
734
717
  if (words.length <= 1) return topLevel;
735
718
  const command = words[0];
@@ -755,7 +738,8 @@ export function completions(words: readonly string[]): readonly string[] {
755
738
  if (words[1] === "direction") return ["check", "plan", "gallery"];
756
739
  if (words[1] === "effects") return ["check", "plan", "bake"];
757
740
  if (words[1] === "behavior") return ["check", "bake", "gallery", "audit"];
758
- return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "temporal-audit"];
741
+ if (words[1] === "design") return ["catalog", "init", "inspect", "set", "compile", "gallery"];
742
+ return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "design", "temporal-audit"];
759
743
  }
760
744
  if (command === "edit") return ["init", "show", "trim", "cut", "speed", "zoom", "overlay", "cursor", "clicks", "keystrokes", "typed-text"];
761
745
  if (command === "analyze") return ["faces", "inactivity", "zooms", "music", "scenes", "speech"];
@@ -767,5 +751,7 @@ export function completions(words: readonly string[]): readonly string[] {
767
751
  if (command === "media") return ["audio", "color"];
768
752
  if (command === "render") return ["plan", "run"];
769
753
  if (command === "assets") return ["emoji"];
754
+ if (command === "outputs") return ["list", "open", "reveal"];
755
+ if (command === "legacy") return ["retire"];
770
756
  return [];
771
757
  }