@hraness/slopcamera 3.7.0 → 3.8.1

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.
@@ -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.
@@ -24,6 +24,9 @@ import {
24
24
  } from "./html-overlay-browser-runtime";
25
25
 
26
26
  const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
27
+ const ExecutionContractVersionSchema = z.union([z.literal(1), z.literal(2)]);
28
+ export type HtmlOverlayExecutionContractVersion = z.infer<typeof ExecutionContractVersionSchema>;
29
+
27
30
  const IntegrityLeafSchema = z.strictObject({
28
31
  key: z.string().min(1).max(512),
29
32
  sha256: Sha256Schema,
@@ -44,7 +47,7 @@ export const HtmlOverlayExecutionIntegritySchema = z.strictObject({
44
47
  }),
45
48
  rootSha256: Sha256Schema,
46
49
  runtimeSha256: Sha256Schema,
47
- schemaVersion: z.literal(1),
50
+ schemaVersion: ExecutionContractVersionSchema,
48
51
  });
49
52
  export type HtmlOverlayExecutionIntegrity = Readonly<
50
53
  z.infer<typeof HtmlOverlayExecutionIntegritySchema>
@@ -185,7 +188,7 @@ function legacyHtmlOverlayRendererContract(profileInput?: HtmlOverlayExecutionPr
185
188
  }
186
189
 
187
190
  /** Fetch opt-in grants only the exact declared private paths and changes execution identity. */
188
- export function htmlOverlayRendererContract(
191
+ function historicalHtmlOverlayRendererContract(
189
192
  profileInput?: HtmlOverlayExecutionProfile,
190
193
  resourcesInput: readonly HtmlOverlayDeclaredResource[] = [],
191
194
  ) {
@@ -204,6 +207,25 @@ export function htmlOverlayRendererContract(
204
207
  });
205
208
  }
206
209
 
210
+ /** Historical receipts select v1 explicitly; all new execution binds clone prevention. */
211
+ export function htmlOverlayRendererContract(
212
+ profileInput?: HtmlOverlayExecutionProfile,
213
+ resourcesInput: readonly HtmlOverlayDeclaredResource[] = [],
214
+ version: HtmlOverlayExecutionContractVersion = 2,
215
+ ) {
216
+ const contract = historicalHtmlOverlayRendererContract(profileInput, resourcesInput);
217
+ if (ExecutionContractVersionSchema.parse(version) === 1) return contract;
218
+ return Object.freeze({
219
+ ...contract,
220
+ launch: Object.freeze({
221
+ ...contract.launch,
222
+ args: Object.freeze(contract.launch.args.map(argument => argument.startsWith("--disable-features=")
223
+ ? `${argument},PaintHolding,MacAppCodeSignClone` : argument)),
224
+ }),
225
+ schemaVersion: 4,
226
+ });
227
+ }
228
+
207
229
  export function assertHtmlOverlayExecutionProfileLibraries(
208
230
  libraries: readonly string[], profile?: HtmlOverlayExecutionProfile,
209
231
  ): void {
@@ -258,6 +280,7 @@ export function createHtmlOverlayExecutionBundle(
258
280
  authoringInput: HtmlOverlayAuthoringInput,
259
281
  browserRuntimeInput: HtmlOverlayBrowserRuntimeBinding,
260
282
  executionProfile?: HtmlOverlayExecutionProfile,
283
+ contractVersion: HtmlOverlayExecutionContractVersion = 2,
261
284
  ): HtmlOverlayExecutionBundle {
262
285
  const authoring = HtmlOverlayAuthoringInputSchema.parse(authoringInput);
263
286
  assertHtmlOverlayExecutionProfileLibraries(authoring.libraries, executionProfile);
@@ -290,7 +313,7 @@ export function createHtmlOverlayExecutionBundle(
290
313
  },
291
314
  },
292
315
  { key: "browser-runtime", value: browserRuntime },
293
- { key: "renderer-contract", value: htmlOverlayRendererContract(executionProfile, authoring.resources) },
316
+ { key: "renderer-contract", value: htmlOverlayRendererContract(executionProfile, authoring.resources, contractVersion) },
294
317
  {
295
318
  key: "document",
296
319
  value: {
@@ -326,7 +349,7 @@ export function createHtmlOverlayExecutionBundle(
326
349
  leaves,
327
350
  rootSha256: merkleRoot(leaves),
328
351
  runtimeSha256,
329
- schemaVersion: 1,
352
+ schemaVersion: contractVersion,
330
353
  });
331
354
  return Object.freeze({
332
355
  importMap,
@@ -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.1" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.audit",
@@ -381,7 +381,7 @@ export async function recoverSpatialRenderOutput(application: ApplicationContext
381
381
  mode: plan.request.mode, frameRate: plan.request.selection.kind === "video" ? plan.request.selection.frameRate : { numerator: 1, denominator: 1 }, preparedAssets: batch.preparedAssets });
382
382
  equal(batch.metadata, expectedBatch.metadata, "Spatial batch metadata differs from the exact scene samples.");
383
383
  equal(batch.metadataSha256, expectedBatch.metadataSha256, "Spatial batch metadata digest is invalid.");
384
- const execution = createHtmlOverlayExecutionBundle(expectedBatch.authoring, input.browserRuntime, plan.request.executionProfile);
384
+ const execution = createHtmlOverlayExecutionBundle(expectedBatch.authoring, input.browserRuntime, plan.request.executionProfile, batch.executionIntegrity.schemaVersion);
385
385
  assertHtmlOverlayGpuEvidenceProfile(plan.request.executionProfile, batch.gpuEvidence);
386
386
  equal(batch.gpuEvidence, receipt.runtime.gpuEvidence, "Spatial batch hardware identity differs from its completion receipt.");
387
387
  equal(batch.executionIntegrity, execution.integrity, "Spatial batch execution integrity differs from its exact authored runtime.");
@@ -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.1";
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
+ }