@hraness/slopcamera 3.2.7 → 3.3.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.
Files changed (188) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +65 -20
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +6 -9
  6. package/apps/desktop/application/default-registry.ts +26 -10
  7. package/apps/desktop/application/operation.ts +14 -5
  8. package/apps/desktop/application/operations/index.ts +4 -1
  9. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  10. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  11. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  12. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  13. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  14. package/apps/desktop/application/registry.ts +2 -2
  15. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  16. package/apps/desktop/application/spatial-assets.ts +160 -31
  17. package/apps/desktop/application/spatial-render.ts +217 -20
  18. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  19. package/apps/desktop/application/spatial-review.ts +361 -0
  20. package/apps/desktop/application/spatial-spz.ts +48 -3
  21. package/apps/desktop/application/spatial-world-import.ts +26 -5
  22. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  23. package/apps/desktop/cli/args.ts +528 -97
  24. package/apps/desktop/cli/capability-manifest.ts +374 -0
  25. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  26. package/apps/desktop/cli/command-host-resources.ts +31 -2
  27. package/apps/desktop/cli/commands.ts +1215 -70
  28. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  29. package/apps/desktop/cli/help.ts +183 -23
  30. package/apps/desktop/cli/html-scene.ts +139 -28
  31. package/apps/desktop/cli/io.ts +2 -1
  32. package/apps/desktop/cli/main.ts +26 -50
  33. package/apps/desktop/cli/menubar.ts +244 -0
  34. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  35. package/apps/desktop/cli/portable-surface.ts +18 -5
  36. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  37. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  38. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  39. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  40. package/apps/desktop/cli/workflow-code.ts +1 -1
  41. package/apps/desktop/code/application-node-planner.ts +0 -47
  42. package/apps/desktop/code/host-source-layout.ts +46 -0
  43. package/apps/desktop/code/public.ts +0 -1
  44. package/apps/desktop/code/runtime-identity.ts +50 -8
  45. package/apps/desktop/code/semantic-builder.ts +24 -43
  46. package/apps/desktop/code/source-bundle.ts +23 -6
  47. package/apps/desktop/code/source-typecheck.ts +131 -43
  48. package/apps/desktop/code/worker-client.ts +14 -2
  49. package/apps/desktop/contracts/cinema.ts +736 -0
  50. package/apps/desktop/contracts/index.ts +1 -0
  51. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  52. package/apps/desktop/contracts/spatial-world.ts +26 -3
  53. package/apps/desktop/core/cinema-plan.ts +1320 -0
  54. package/apps/desktop/core/index.ts +1 -0
  55. package/apps/desktop/core/music-analysis.ts +124 -1
  56. package/apps/desktop/core/storage.ts +75 -0
  57. package/apps/desktop/dist/cli/main.js +737 -291
  58. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  59. package/apps/desktop/html-overlay/index.ts +9 -0
  60. package/apps/desktop/html-overlay/runtime.ts +2 -0
  61. package/apps/desktop/html-overlay/scene.ts +29 -0
  62. package/apps/desktop/html-overlay/spatial.ts +619 -57
  63. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  64. package/apps/desktop/workflows/index.ts +6 -0
  65. package/dist/cli.js +32 -434
  66. package/dist/code/advanced.js +1 -269
  67. package/dist/code/index.js +16 -2239
  68. package/dist/generate.js +1 -23
  69. package/dist/host-resources.js +1 -25
  70. package/dist/index-0bbesn4m.js +35 -0
  71. package/dist/index-296qhpf2.js +3 -0
  72. package/dist/index-3cxf2c58.js +3 -0
  73. package/dist/index-77fjfg9f.js +3 -0
  74. package/dist/index-80a36bc0.js +3 -0
  75. package/dist/index-a3vvs3rf.js +3 -0
  76. package/dist/index-b3geqczn.js +5 -0
  77. package/dist/index-cgpkyjqa.js +3 -0
  78. package/dist/index-f0ptv8dm.js +7 -0
  79. package/dist/index-grk47tgs.js +3 -0
  80. package/dist/index-qry58nj2.js +10 -0
  81. package/dist/index.js +1 -240
  82. package/dist/operations.js +1 -28
  83. package/dist/skill-install-gx7afxsd.js +2 -0
  84. package/dist/vectorize/worker.js +1 -141
  85. package/dist/workflow.js +1 -15
  86. package/docs/README.md +5 -1
  87. package/docs/studio.md +1 -1
  88. package/package.json +11 -24
  89. package/skills/slopcamera/SKILL.md +14 -1
  90. package/skills/slopcamera/references/diagrams.md +5 -14
  91. package/skills/slopcamera/references/directed-scenes.md +22 -2
  92. package/skills/slopcamera/references/gateway-media.md +15 -1
  93. package/skills/slopcamera/references/image-galleries.md +166 -0
  94. package/skills/slopcamera/references/install.md +2 -2
  95. package/skills/slopcamera/references/music-video.md +23 -3
  96. package/skills/slopcamera/references/parametric-design.md +14 -0
  97. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  98. package/skills/slopcamera/references/scene-building.md +32 -0
  99. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  100. package/skills/slopcamera/references/support.md +25 -0
  101. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  102. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  103. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  104. package/src/capability-manifest.ts +361 -0
  105. package/src/cli.ts +250 -102
  106. package/src/code/canonical-json.ts +9 -0
  107. package/src/generate.ts +2 -2
  108. package/src/icon.ts +1111 -0
  109. package/src/image-gallery.ts +1049 -0
  110. package/src/index.ts +11 -23
  111. package/src/mcp/boundary.ts +11 -11
  112. package/src/mcp/index.ts +3 -0
  113. package/src/mcp/server.ts +2 -1
  114. package/src/mcp/tools.ts +1642 -130
  115. package/src/mcp/types.ts +19 -0
  116. package/src/operations.ts +412 -0
  117. package/src/portable-capability-manifest.ts +67 -0
  118. package/src/scene-gallery.ts +184 -0
  119. package/src/spatial-scene/asset-admission.ts +183 -0
  120. package/src/spatial-scene/audit-rendered.ts +692 -0
  121. package/src/spatial-scene/audit.ts +617 -0
  122. package/src/spatial-scene/behavior-audit.ts +252 -0
  123. package/src/spatial-scene/behavior-bake.ts +233 -0
  124. package/src/spatial-scene/behavior-fns.ts +585 -0
  125. package/src/spatial-scene/behavior-gallery.ts +109 -0
  126. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  127. package/src/spatial-scene/behavior-trace.ts +325 -0
  128. package/src/spatial-scene/behavior.ts +618 -0
  129. package/src/spatial-scene/build.ts +632 -0
  130. package/src/spatial-scene/camera-rig.ts +125 -0
  131. package/src/spatial-scene/camera-track.ts +4 -2
  132. package/src/spatial-scene/character.ts +124 -0
  133. package/src/spatial-scene/contracts.ts +75 -7
  134. package/src/spatial-scene/design-templates.ts +351 -0
  135. package/src/spatial-scene/design.ts +327 -0
  136. package/src/spatial-scene/direction-compile.ts +584 -0
  137. package/src/spatial-scene/direction.ts +122 -0
  138. package/src/spatial-scene/effects.ts +149 -0
  139. package/src/spatial-scene/evaluate.ts +109 -29
  140. package/src/spatial-scene/gallery.ts +277 -0
  141. package/src/spatial-scene/generate.ts +239 -0
  142. package/src/spatial-scene/geometry-native.ts +165 -0
  143. package/src/spatial-scene/geometry.ts +1216 -0
  144. package/src/spatial-scene/gltf.ts +600 -77
  145. package/src/spatial-scene/identity.ts +55 -2
  146. package/src/spatial-scene/index.ts +36 -0
  147. package/src/spatial-scene/inspect.ts +35 -6
  148. package/src/spatial-scene/material-lighting.ts +609 -0
  149. package/src/spatial-scene/math.ts +21 -4
  150. package/src/spatial-scene/motion-evidence.ts +88 -0
  151. package/src/spatial-scene/parametric.ts +765 -0
  152. package/src/spatial-scene/particle-preparation.ts +183 -0
  153. package/src/spatial-scene/particle.ts +195 -0
  154. package/src/spatial-scene/patch.ts +109 -15
  155. package/src/spatial-scene/performance.ts +1613 -0
  156. package/src/spatial-scene/probe.ts +137 -0
  157. package/src/spatial-scene/recipe-pack.ts +86 -0
  158. package/src/spatial-scene/render-effects.ts +240 -0
  159. package/src/spatial-scene/review.ts +639 -0
  160. package/src/spatial-scene/simulation-bake.ts +206 -0
  161. package/src/spatial-scene/simulation.ts +187 -0
  162. package/src/spatial-scene/solve.ts +366 -0
  163. package/src/spatial-scene/temporal-audit.ts +354 -0
  164. package/src/support-completion.ts +16 -0
  165. package/src/support.ts +42 -0
  166. package/src/version.ts +1 -1
  167. package/apps/desktop/application/operations/recording/index.ts +0 -5
  168. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  169. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  170. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  171. package/apps/desktop/application/operations/recording/start.ts +0 -169
  172. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  173. package/apps/desktop/capture/protocol.ts +0 -689
  174. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  175. package/apps/desktop/cli/recording-controller.ts +0 -1567
  176. package/apps/desktop/cli/recording-daemon.ts +0 -931
  177. package/dist/index-42zsesc1.js +0 -2257
  178. package/dist/index-7308egqr.js +0 -48
  179. package/dist/index-8txs6fkn.js +0 -712
  180. package/dist/index-mcy8z0br.js +0 -192
  181. package/dist/index-p63wavx0.js +0 -2029
  182. package/dist/index-r7gdhmsp.js +0 -424
  183. package/dist/index-rvghzt7b.js +0 -2013
  184. package/dist/index-sh6xbav6.js +0 -1219
  185. package/dist/index-z1w83f81.js +0 -4
  186. package/dist/index-z7239b4h.js +0 -2363
  187. package/dist/skill-install-dh0ntqba.js +0 -10
  188. package/src/desktop.ts +0 -253
@@ -1,931 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { createConnection, createServer, type Server, type Socket } from "node:net";
4
- import { join } from "node:path";
5
- import {
6
- CaptureInterruptionSchema,
7
- CaptureDeviceSelectionSchema,
8
- CaptureDisplaySelectionSchema,
9
- CapturePermissionsSchema,
10
- CaptureSourceInventorySchema,
11
- TypedTextFocusIdentitiesSchema,
12
- } from "../capture/protocol";
13
- import { CliError, asCliError } from "./errors";
14
- import { environmentWithoutGatewayCredentials } from "./io";
15
- import {
16
- CaptureHelperRecordingController,
17
- executeRecordingAction,
18
- parseRecordingSnapshot,
19
- readRepositoryRecordingState,
20
- type RecordingController,
21
- type RecordingSnapshot,
22
- type RecordingStartOptions,
23
- } from "./recording-controller";
24
- import { ensurePrivateDirectory } from "./paths";
25
-
26
- const CONTROL_PROTOCOL_VERSION = 1 as const;
27
- const MAX_CONTROL_LINE_BYTES = 64 * 1024;
28
- const MAX_COMPACT_RECOVERY_BYTES = 40 * 1024;
29
- const DEFAULT_TIMEOUT_MS = 10_000;
30
-
31
- interface ControlMetadata {
32
- readonly nonce: string;
33
- readonly pid: number;
34
- readonly protocolVersion: 1;
35
- readonly socketPath: string;
36
- readonly startedAt: string;
37
- readonly uid: number | null;
38
- }
39
-
40
- export interface ControlRequest {
41
- readonly action: "start" | "pause" | "resume" | "stop" | "status";
42
- readonly nonce: string;
43
- readonly options?: RecordingStartOptions;
44
- readonly protocolVersion: 1;
45
- readonly requestId: string;
46
- }
47
-
48
- export type ControlResponse =
49
- | {
50
- readonly ok: true;
51
- readonly protocolVersion: 1;
52
- readonly requestId: string;
53
- readonly snapshot: RecordingSnapshot;
54
- }
55
- | {
56
- readonly error: { readonly code: string; readonly details?: Readonly<Record<string, unknown>>; readonly message: string };
57
- readonly ok: false;
58
- readonly protocolVersion: 1;
59
- readonly requestId: string;
60
- };
61
-
62
- export interface SerializedRecordingDispatcher {
63
- (request: ControlRequest): Promise<ControlResponse>;
64
- drain(): Promise<void>;
65
- }
66
-
67
- interface ControlPaths {
68
- readonly lock: string;
69
- readonly metadata: string;
70
- readonly socket: string;
71
- }
72
-
73
- function controlPaths(artifactRoot: string): ControlPaths {
74
- return {
75
- lock: join(artifactRoot, ".recording-control.lock"),
76
- metadata: join(artifactRoot, ".recording-control.json"),
77
- socket: join(artifactRoot, ".recording-control.sock"),
78
- };
79
- }
80
-
81
- function currentUid(): number | null {
82
- return typeof process.getuid === "function" ? process.getuid() : null;
83
- }
84
-
85
- function parseJsonLine(line: string, label: string): unknown {
86
- if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES) {
87
- throw new CliError("invalid-data", `${label} exceeds ${MAX_CONTROL_LINE_BYTES} bytes.`);
88
- }
89
- try {
90
- return JSON.parse(line) as unknown;
91
- } catch {
92
- throw new CliError("invalid-data", `${label} is not valid JSON.`);
93
- }
94
- }
95
-
96
- function object(value: unknown, label: string): Readonly<Record<string, unknown>> {
97
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
98
- throw new CliError("invalid-data", `${label} must be an object.`);
99
- }
100
- return value as Readonly<Record<string, unknown>>;
101
- }
102
-
103
- function recoveryControllerReusable(
104
- details: Readonly<Record<string, unknown>> | undefined,
105
- ): boolean | null {
106
- const recovery = optionalRecord(recordProperty(details, "recovery"));
107
- if (recovery === null) {
108
- return null;
109
- }
110
- const reusable = recordProperty(recovery, "controllerReusable");
111
- return typeof reusable === "boolean" ? reusable : null;
112
- }
113
-
114
- function recordProperty(
115
- value: Readonly<Record<string, unknown>> | undefined,
116
- key: string,
117
- ): unknown {
118
- if (value === undefined) return undefined;
119
- try {
120
- return value[key];
121
- } catch {
122
- return undefined;
123
- }
124
- }
125
-
126
- function optionalRecord(
127
- value: unknown,
128
- ): Readonly<Record<string, unknown>> | null {
129
- try {
130
- return typeof value === "object" && value !== null && !Array.isArray(value)
131
- ? value as Readonly<Record<string, unknown>>
132
- : null;
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
- function boundedNullableString(value: unknown): string | null {
139
- return typeof value === "string" && Buffer.byteLength(value) <= 4_096
140
- ? value
141
- : null;
142
- }
143
-
144
- function compactRecoveryDetails(
145
- details: Readonly<Record<string, unknown>> | undefined,
146
- ): Readonly<Record<string, unknown>> | undefined {
147
- const source = optionalRecord(recordProperty(details, "recovery"));
148
- const controllerReusable = recordProperty(source ?? undefined, "controllerReusable");
149
- if (source === null || typeof controllerReusable !== "boolean") {
150
- return undefined;
151
- }
152
- const rawTerminalManifestState = recordProperty(source, "terminalManifestState");
153
- const terminalManifestState = rawTerminalManifestState === "failed"
154
- || rawTerminalManifestState === "stopped"
155
- || rawTerminalManifestState === "unsettled"
156
- ? rawTerminalManifestState
157
- : controllerReusable
158
- ? null
159
- : "unsettled";
160
- if (terminalManifestState === null) return undefined;
161
-
162
- const snapshotSource = optionalRecord(recordProperty(source, "snapshot"));
163
- const snapshot: Record<string, unknown> = {};
164
- if (snapshotSource !== null) {
165
- const lastInterruption = recordProperty(snapshotSource, "lastInterruption");
166
- if (lastInterruption === null) {
167
- snapshot.lastInterruption = null;
168
- } else {
169
- try {
170
- const interruption = CaptureInterruptionSchema.safeParse(
171
- lastInterruption,
172
- );
173
- if (interruption.success) snapshot.lastInterruption = interruption.data;
174
- } catch {
175
- // Hostile foreign diagnostics cannot prevent recovery disposition from
176
- // crossing the daemon boundary.
177
- }
178
- }
179
- const logicalTimeUs = recordProperty(snapshotSource, "logicalTimeUs");
180
- if (
181
- typeof logicalTimeUs === "number"
182
- && Number.isSafeInteger(logicalTimeUs)
183
- && logicalTimeUs >= 0
184
- ) {
185
- snapshot.logicalTimeUs = logicalTimeUs;
186
- }
187
- const rawPermissions = recordProperty(snapshotSource, "permissions");
188
- if (rawPermissions === null) {
189
- snapshot.permissions = null;
190
- } else {
191
- try {
192
- const permissions = CapturePermissionsSchema.safeParse(
193
- rawPermissions,
194
- );
195
- if (permissions.success) snapshot.permissions = permissions.data;
196
- } catch {
197
- // Ignore untrusted optional frontier evidence.
198
- }
199
- }
200
- for (const name of ["availableSources", "sources"] as const) {
201
- try {
202
- const sources = CaptureSourceInventorySchema.safeParse(
203
- recordProperty(snapshotSource, name),
204
- );
205
- if (sources.success) snapshot[name] = sources.data;
206
- } catch {
207
- // Ignore untrusted optional frontier evidence.
208
- }
209
- }
210
- }
211
-
212
- const recovery = {
213
- controllerReusable,
214
- recordingId: boundedNullableString(recordProperty(source, "recordingId")),
215
- recordingRoot: boundedNullableString(recordProperty(source, "recordingRoot")),
216
- ...(Object.keys(snapshot).length === 0 ? {} : { snapshot }),
217
- terminalManifestState,
218
- };
219
- const compact = { recovery };
220
- if (Buffer.byteLength(JSON.stringify(compact)) <= MAX_COMPACT_RECOVERY_BYTES) {
221
- return compact;
222
- }
223
- delete snapshot.availableSources;
224
- delete snapshot.sources;
225
- return {
226
- recovery: {
227
- ...recovery,
228
- ...(Object.keys(snapshot).length === 0 ? { snapshot: undefined } : { snapshot }),
229
- },
230
- };
231
- }
232
-
233
- function ownedControlErrorMessage(
234
- error: Extract<ControlResponse, { readonly ok: false }>["error"],
235
- ): string {
236
- if (recoveryControllerReusable(error.details) === false) {
237
- return "Capture recovery was incomplete.";
238
- }
239
- switch (error.code) {
240
- case "usage":
241
- return "The recording request is invalid.";
242
- case "not-found":
243
- return "The requested recording resource was not found.";
244
- case "conflict":
245
- return "The recording command cannot run in the current state.";
246
- case "unavailable":
247
- return "The recording runtime is unavailable.";
248
- case "unsafe-path":
249
- return "The recording runtime refused an unsafe path.";
250
- case "invalid-data":
251
- return "The recording runtime returned invalid data.";
252
- case "subprocess":
253
- return "The capture helper failed.";
254
- case "unsupported-plan":
255
- return "The recording request is unsupported.";
256
- default:
257
- return "The recording control operation failed.";
258
- }
259
- }
260
-
261
- function string(value: unknown, label: string): string {
262
- if (typeof value !== "string" || value === "" || Buffer.byteLength(value) > 4_096) {
263
- throw new CliError("invalid-data", `${label} must be a bounded non-empty string.`);
264
- }
265
- return value;
266
- }
267
-
268
- function parseStartOptions(value: unknown): RecordingStartOptions {
269
- const options = object(value, "recording start options");
270
- const camera = CaptureDeviceSelectionSchema.safeParse(options.camera);
271
- if (!camera.success) throw new CliError("invalid-data", "Camera selection is invalid.");
272
- const displays = CaptureDisplaySelectionSchema.safeParse(options.displays);
273
- if (!displays.success) throw new CliError("invalid-data", "Display selection is invalid.");
274
- const microphone = CaptureDeviceSelectionSchema.safeParse(options.microphone);
275
- if (!microphone.success) throw new CliError("invalid-data", "Microphone selection is invalid.");
276
- const bool = (name: string): boolean => {
277
- const item = options[name];
278
- if (typeof item !== "boolean") throw new CliError("invalid-data", `${name} must be boolean.`);
279
- return item;
280
- };
281
- const rawProcessIdentifier = options.interactionEventProcessIdentifier;
282
- const interactionEventProcessIdentifier = rawProcessIdentifier === undefined
283
- ? undefined
284
- : rawProcessIdentifier === null
285
- ? null
286
- : typeof rawProcessIdentifier === "number"
287
- && Number.isInteger(rawProcessIdentifier)
288
- && rawProcessIdentifier > 0
289
- && rawProcessIdentifier <= 2_147_483_647
290
- ? rawProcessIdentifier
291
- : (() => {
292
- throw new CliError(
293
- "invalid-data",
294
- "interactionEventProcessIdentifier must be a positive 32-bit process ID or null.",
295
- );
296
- })();
297
- const rawFocusIdentities = options.typedTextFocusIdentities;
298
- const parsedFocusIdentities = rawFocusIdentities === undefined
299
- ? undefined
300
- : TypedTextFocusIdentitiesSchema.nullable().safeParse(rawFocusIdentities);
301
- if (
302
- parsedFocusIdentities !== undefined
303
- && !parsedFocusIdentities.success
304
- ) {
305
- throw new CliError(
306
- "invalid-data",
307
- "typedTextFocusIdentities must contain unique, bounded focus identities or null.",
308
- );
309
- }
310
- const typedTextFocusIdentities = parsedFocusIdentities?.data;
311
- return {
312
- camera: camera.data,
313
- displays: displays.data,
314
- ...(interactionEventProcessIdentifier === undefined
315
- ? {}
316
- : { interactionEventProcessIdentifier }),
317
- microphone: microphone.data,
318
- strictInputs: bool("strictInputs"),
319
- systemAudio: bool("systemAudio"),
320
- typedText: bool("typedText"),
321
- ...(typedTextFocusIdentities === undefined
322
- ? {}
323
- : {
324
- typedTextFocusIdentities: typedTextFocusIdentities === null
325
- ? null
326
- : typedTextFocusIdentities.map(identity => ({ ...identity })),
327
- }),
328
- };
329
- }
330
-
331
- function parseRequest(line: string): ControlRequest {
332
- const request = object(parseJsonLine(line, "Recording control request"), "Recording control request");
333
- if (request.protocolVersion !== CONTROL_PROTOCOL_VERSION) {
334
- throw new CliError("invalid-data", `Recording control protocol must be ${CONTROL_PROTOCOL_VERSION}.`);
335
- }
336
- const action = request.action;
337
- if (action !== "start" && action !== "pause" && action !== "resume" && action !== "stop" && action !== "status") {
338
- throw new CliError("invalid-data", "Recording control action is invalid.");
339
- }
340
- const allowed = new Set(action === "start"
341
- ? ["action", "nonce", "options", "protocolVersion", "requestId"]
342
- : ["action", "nonce", "protocolVersion", "requestId"]);
343
- if (Object.keys(request).some((key) => !allowed.has(key))) {
344
- throw new CliError("invalid-data", "Recording control request has unknown fields.");
345
- }
346
- const base = {
347
- action,
348
- nonce: string(request.nonce, "nonce"),
349
- protocolVersion: CONTROL_PROTOCOL_VERSION,
350
- requestId: string(request.requestId, "requestId"),
351
- } as const;
352
- return action === "start"
353
- ? { ...base, action, options: parseStartOptions(request.options) }
354
- : { ...base, action };
355
- }
356
-
357
- function parseMetadata(value: unknown): ControlMetadata {
358
- const metadata = object(value, "Recording control metadata");
359
- if (
360
- metadata.protocolVersion !== CONTROL_PROTOCOL_VERSION
361
- || typeof metadata.pid !== "number"
362
- || !Number.isSafeInteger(metadata.pid)
363
- || metadata.pid <= 0
364
- || (metadata.uid !== null && (typeof metadata.uid !== "number" || !Number.isSafeInteger(metadata.uid)))
365
- ) {
366
- throw new CliError("invalid-data", "Recording control metadata has invalid process identity.");
367
- }
368
- return {
369
- nonce: string(metadata.nonce, "control nonce"),
370
- pid: metadata.pid,
371
- protocolVersion: CONTROL_PROTOCOL_VERSION,
372
- socketPath: string(metadata.socketPath, "control socket path"),
373
- startedAt: string(metadata.startedAt, "control start timestamp"),
374
- uid: metadata.uid,
375
- };
376
- }
377
-
378
- async function readMetadata(path: string): Promise<ControlMetadata | null> {
379
- let contents: string;
380
- try {
381
- contents = await readFile(path, "utf8");
382
- } catch (error) {
383
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
384
- throw error;
385
- }
386
- let value: unknown;
387
- try {
388
- value = JSON.parse(contents);
389
- } catch {
390
- throw new CliError("invalid-data", "Recording control metadata is not valid JSON.");
391
- }
392
- const metadata = parseMetadata(value);
393
- if (metadata.uid !== currentUid()) {
394
- throw new CliError("unsafe-path", "Recording control daemon belongs to another operating-system user.");
395
- }
396
- return metadata;
397
- }
398
-
399
- function processExists(pid: number): boolean {
400
- try {
401
- process.kill(pid, 0);
402
- return true;
403
- } catch (error) {
404
- return error instanceof Error && "code" in error && error.code === "EPERM";
405
- }
406
- }
407
-
408
- async function atomicWrite(path: string, value: unknown): Promise<void> {
409
- const temporary = `${path}.${randomUUID()}.tmp`;
410
- try {
411
- await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
412
- await rename(temporary, path);
413
- } finally {
414
- await rm(temporary, { force: true });
415
- }
416
- }
417
-
418
- function responseLine(response: ControlResponse): string {
419
- const safeResponse: ControlResponse = response.ok
420
- ? response
421
- : (() => {
422
- const compactDetails = compactRecoveryDetails(response.error.details);
423
- const mustProjectRecovery =
424
- recoveryControllerReusable(response.error.details) === false;
425
- return {
426
- ...response,
427
- error: {
428
- code: response.error.code,
429
- ...(mustProjectRecovery
430
- ? compactDetails === undefined
431
- ? {}
432
- : { details: compactDetails }
433
- : response.error.details === undefined
434
- ? {}
435
- : { details: response.error.details }),
436
- message: ownedControlErrorMessage(response.error),
437
- },
438
- };
439
- })();
440
- let line = `${JSON.stringify(safeResponse)}\n`;
441
- if (
442
- Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES
443
- && !safeResponse.ok
444
- ) {
445
- const compactDetails = compactRecoveryDetails(
446
- safeResponse.error.details,
447
- );
448
- line = `${JSON.stringify({
449
- ...safeResponse,
450
- error: {
451
- code: safeResponse.error.code,
452
- ...(compactDetails === undefined ? {} : { details: compactDetails }),
453
- message: safeResponse.error.message,
454
- },
455
- } satisfies ControlResponse)}\n`;
456
- }
457
- if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES) {
458
- throw new CliError("internal", "Recording control response exceeded its protocol bound.");
459
- }
460
- return line;
461
- }
462
-
463
- function requestLine(request: ControlRequest): string {
464
- const line = `${JSON.stringify(request)}\n`;
465
- if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES) {
466
- throw new CliError("internal", "Recording control request exceeded its protocol bound.");
467
- }
468
- return line;
469
- }
470
-
471
- function parseResponse(line: string, requestId: string): RecordingSnapshot {
472
- const response = object(parseJsonLine(line, "Recording control response"), "Recording control response");
473
- if (response.protocolVersion !== CONTROL_PROTOCOL_VERSION || response.requestId !== requestId) {
474
- throw new CliError("invalid-data", "Recording control response identity does not match the request.");
475
- }
476
- if (response.ok === true) return parseRecordingSnapshot(response.snapshot);
477
- if (response.ok !== false) throw new CliError("invalid-data", "Recording control response omits status.");
478
- const error = object(response.error, "Recording control error");
479
- const code = string(error.code, "Recording control error code");
480
- const knownCode = ([
481
- "usage", "not-found", "conflict", "unavailable", "unsafe-path", "invalid-data", "subprocess",
482
- "unsupported-plan", "internal",
483
- ] as const).find(candidate => candidate === code);
484
- throw new CliError(
485
- knownCode === undefined ? "subprocess" : knownCode,
486
- string(error.message, "Recording control error message"),
487
- typeof error.details === "object" && error.details !== null && !Array.isArray(error.details)
488
- ? error.details as Readonly<Record<string, unknown>>
489
- : undefined,
490
- );
491
- }
492
-
493
- function socketRequest(
494
- metadata: ControlMetadata,
495
- action: ControlRequest["action"],
496
- options: RecordingStartOptions | undefined,
497
- timeoutMs: number,
498
- ): Promise<RecordingSnapshot> {
499
- const requestId = randomUUID();
500
- const request: ControlRequest = action === "start"
501
- ? {
502
- action,
503
- nonce: metadata.nonce,
504
- options: options ?? (() => { throw new CliError("internal", "Start options are missing."); })(),
505
- protocolVersion: CONTROL_PROTOCOL_VERSION,
506
- requestId,
507
- }
508
- : { action, nonce: metadata.nonce, protocolVersion: CONTROL_PROTOCOL_VERSION, requestId };
509
- return new Promise((resolve, reject) => {
510
- const socket = createConnection(metadata.socketPath);
511
- let buffer = "";
512
- let settled = false;
513
- const finish = (error?: unknown, snapshot?: RecordingSnapshot): void => {
514
- if (settled) return;
515
- settled = true;
516
- socket.destroy();
517
- if (error !== undefined) reject(error instanceof Error ? error : new Error("Unknown recording control failure."));
518
- else if (snapshot !== undefined) resolve(snapshot);
519
- else reject(new CliError("internal", "Recording control request ended without a result."));
520
- };
521
- socket.setEncoding("utf8");
522
- socket.setTimeout(timeoutMs);
523
- socket.once("connect", () => {
524
- socket.write(requestLine(request));
525
- });
526
- socket.on("data", (chunk: string) => {
527
- buffer += chunk;
528
- if (Buffer.byteLength(buffer) > MAX_CONTROL_LINE_BYTES) {
529
- finish(new CliError("invalid-data", "Recording control response is oversized."));
530
- return;
531
- }
532
- const newline = buffer.indexOf("\n");
533
- if (newline === -1) return;
534
- try {
535
- finish(undefined, parseResponse(buffer.slice(0, newline), requestId));
536
- } catch (error) {
537
- finish(error);
538
- }
539
- });
540
- socket.once("timeout", () => finish(new CliError("subprocess", "Recording control daemon timed out.")));
541
- socket.once("error", (error) => finish(error));
542
- socket.once("end", () => finish(new CliError("subprocess", "Recording control daemon closed without a response.")));
543
- });
544
- }
545
-
546
- async function wait(milliseconds: number): Promise<void> {
547
- await new Promise<void>((resolve) => {
548
- setTimeout(resolve, milliseconds);
549
- });
550
- }
551
-
552
- export interface RecordingDaemonClientOptions {
553
- readonly artifactRoot: string;
554
- readonly daemonCommand: readonly [string, ...string[]];
555
- readonly helperExecutable: string;
556
- readonly now?: () => Date;
557
- readonly timeoutMs?: number;
558
- }
559
-
560
- export class RecordingDaemonClient implements RecordingController {
561
- readonly #artifactRoot: string;
562
- readonly #daemonCommand: readonly [string, ...string[]];
563
- readonly #helperExecutable: string;
564
- readonly #now: () => Date;
565
- readonly #timeoutMs: number;
566
-
567
- constructor(options: RecordingDaemonClientOptions) {
568
- this.#artifactRoot = options.artifactRoot;
569
- this.#daemonCommand = options.daemonCommand;
570
- this.#helperExecutable = options.helperExecutable;
571
- this.#now = options.now ?? (() => new Date());
572
- this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
573
- }
574
-
575
- async #recoverStale(metadata: ControlMetadata | null, force = false): Promise<void> {
576
- const paths = controlPaths(this.#artifactRoot);
577
- if (!force && metadata !== null && processExists(metadata.pid)) return;
578
- const active = await readRepositoryRecordingState(this.#artifactRoot, this.#now);
579
- if (active.state !== "idle") {
580
- const stalePath = join(
581
- this.#artifactRoot,
582
- `.stale-recording-${this.#now().toISOString().replaceAll(/[:.]/gu, "-")}.json`,
583
- );
584
- await atomicWrite(stalePath, { reason: "control-daemon-exited", snapshot: active });
585
- await rm(join(this.#artifactRoot, ".active-recording.json"), { force: true });
586
- }
587
- await Promise.all([
588
- rm(paths.metadata, { force: true }),
589
- rm(paths.socket, { force: true }),
590
- ]);
591
- }
592
-
593
- async #existingMetadata(): Promise<ControlMetadata | null> {
594
- const metadata = await readMetadata(controlPaths(this.#artifactRoot).metadata);
595
- if (metadata !== null && metadata.socketPath !== controlPaths(this.#artifactRoot).socket) {
596
- throw new CliError("unsafe-path", "Recording control metadata points outside its repository socket.");
597
- }
598
- if (metadata !== null && processExists(metadata.pid)) return metadata;
599
- await this.#recoverStale(metadata);
600
- return null;
601
- }
602
-
603
- async #ensureDaemon(): Promise<ControlMetadata> {
604
- await ensurePrivateDirectory(this.#artifactRoot);
605
- const existing = await this.#existingMetadata();
606
- if (existing !== null) return existing;
607
- const paths = controlPaths(this.#artifactRoot);
608
- let ownsLock = false;
609
- try {
610
- await mkdir(paths.lock, { mode: 0o700 });
611
- ownsLock = true;
612
- } catch (error) {
613
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
614
- try {
615
- const lock = await stat(paths.lock);
616
- if (Date.now() - lock.mtimeMs > 5_000 && await readMetadata(paths.metadata) === null) {
617
- await rm(paths.lock, { force: true, recursive: true });
618
- await mkdir(paths.lock, { mode: 0o700 });
619
- ownsLock = true;
620
- }
621
- } catch (lockError) {
622
- if (!(lockError instanceof Error && "code" in lockError && lockError.code === "ENOENT")) throw lockError;
623
- }
624
- }
625
- if (ownsLock) {
626
- const command = [
627
- ...this.#daemonCommand,
628
- "__record_daemon",
629
- "--artifact-root", this.#artifactRoot,
630
- "--helper", this.#helperExecutable,
631
- ];
632
- const child = Bun.spawn(command, {
633
- env: environmentWithoutGatewayCredentials(process.env),
634
- stdin: "ignore",
635
- stdout: "ignore",
636
- stderr: "ignore",
637
- });
638
- child.unref();
639
- }
640
- const deadline = Date.now() + this.#timeoutMs;
641
- while (Date.now() < deadline) {
642
- try {
643
- const metadata = await readMetadata(paths.metadata);
644
- if (metadata !== null && processExists(metadata.pid)) return metadata;
645
- } catch (error) {
646
- if (!(error instanceof CliError && error.code === "invalid-data")) throw error;
647
- }
648
- await wait(25);
649
- }
650
- if (ownsLock) await rm(paths.lock, { force: true, recursive: true });
651
- throw new CliError("unavailable", "Recording control daemon did not become ready.");
652
- }
653
-
654
- async #request(action: ControlRequest["action"], options?: RecordingStartOptions): Promise<RecordingSnapshot> {
655
- const metadata = action === "status"
656
- ? await this.#existingMetadata()
657
- : await this.#ensureDaemon();
658
- if (metadata === null) return await readRepositoryRecordingState(this.#artifactRoot, this.#now);
659
- try {
660
- return await socketRequest(metadata, action, options, action === "start" ? 300_000 : 30_000);
661
- } catch (error) {
662
- if (
663
- error instanceof CliError
664
- && recoveryControllerReusable(error.details) === false
665
- ) {
666
- const deadline = Date.now() + this.#timeoutMs;
667
- while (processExists(metadata.pid) && Date.now() < deadline) {
668
- await wait(25);
669
- }
670
- if (!processExists(metadata.pid)) {
671
- try {
672
- await this.#recoverStale(metadata, true);
673
- } catch {
674
- // The original recovery failure remains authoritative. A later
675
- // request will retry stale-daemon cleanup before starting.
676
- }
677
- }
678
- }
679
- if (error instanceof Error && "code" in error && ["ECONNREFUSED", "ENOENT"].includes(String(error.code))) {
680
- await this.#recoverStale(metadata, true);
681
- if (action === "status") return await readRepositoryRecordingState(this.#artifactRoot, this.#now);
682
- }
683
- throw error;
684
- }
685
- }
686
-
687
- async start(options: RecordingStartOptions): Promise<RecordingSnapshot> {
688
- return await this.#request("start", options);
689
- }
690
-
691
- async pause(): Promise<RecordingSnapshot> {
692
- return await this.#request("pause");
693
- }
694
-
695
- async resume(): Promise<RecordingSnapshot> {
696
- return await this.#request("resume");
697
- }
698
-
699
- async stop(): Promise<RecordingSnapshot> {
700
- return await this.#request("stop");
701
- }
702
-
703
- async status(): Promise<RecordingSnapshot> {
704
- return await this.#request("status");
705
- }
706
-
707
- async close(): Promise<void> {
708
- // The daemon owns the helper across callers. Closing a client is a no-op.
709
- }
710
- }
711
-
712
- export function handleRecordingControlSocket(
713
- socket: Socket,
714
- dispatch: (request: ControlRequest) => Promise<ControlResponse>,
715
- nonce: string,
716
- ): void {
717
- socket.setEncoding("utf8");
718
- socket.setTimeout(DEFAULT_TIMEOUT_MS);
719
- let buffer = "";
720
- let handled = false;
721
- let claimed = false;
722
- const respond = (response: ControlResponse): void => {
723
- if (handled) return;
724
- const line = responseLine(response);
725
- handled = true;
726
- socket.end(line);
727
- };
728
- socket.on("data", (chunk: string) => {
729
- if (handled || claimed) return;
730
- buffer += chunk;
731
- if (Buffer.byteLength(buffer) > MAX_CONTROL_LINE_BYTES) {
732
- respond({
733
- error: { code: "invalid-data", message: "Recording control request is oversized." },
734
- ok: false,
735
- protocolVersion: CONTROL_PROTOCOL_VERSION,
736
- requestId: "invalid",
737
- });
738
- return;
739
- }
740
- const newline = buffer.indexOf("\n");
741
- if (newline === -1) return;
742
- claimed = true;
743
- void (async () => {
744
- let requestId = "invalid";
745
- try {
746
- const request = parseRequest(buffer.slice(0, newline));
747
- requestId = request.requestId;
748
- if (request.nonce !== nonce) throw new CliError("conflict", "Recording control nonce does not match.");
749
- respond(await dispatch(request));
750
- } catch (error) {
751
- const failure = asCliError(error);
752
- respond({
753
- error: {
754
- code: failure.code,
755
- ...(failure.details === undefined ? {} : { details: failure.details }),
756
- message: failure.message,
757
- },
758
- ok: false,
759
- protocolVersion: CONTROL_PROTOCOL_VERSION,
760
- requestId,
761
- });
762
- }
763
- })();
764
- });
765
- socket.once("timeout", () => socket.destroy());
766
- socket.once("error", () => socket.destroy());
767
- }
768
-
769
- export function createSerializedRecordingDispatcher(
770
- controller: RecordingController,
771
- options: {
772
- readonly onControllerUnusable?: () => void;
773
- } = {},
774
- ): SerializedRecordingDispatcher {
775
- let tail = Promise.resolve();
776
- const completed = new Map<string, ControlResponse>();
777
- const inFlight = new Map<string, Promise<ControlResponse>>();
778
- let unusableFailure:
779
- | Extract<ControlResponse, { readonly ok: false }>["error"]
780
- | null = null;
781
- const remember = (
782
- requestId: string,
783
- response: ControlResponse,
784
- ): ControlResponse => {
785
- completed.set(requestId, response);
786
- if (completed.size > 1_024) {
787
- completed.delete(completed.keys().next().value!);
788
- }
789
- return response;
790
- };
791
- const unusableResponse = (
792
- requestId: string,
793
- ): Extract<ControlResponse, { readonly ok: false }> => {
794
- if (unusableFailure === null) {
795
- throw new CliError(
796
- "internal",
797
- "An unusable recording controller has no recovery failure.",
798
- );
799
- }
800
- return {
801
- error: unusableFailure,
802
- ok: false,
803
- protocolVersion: CONTROL_PROTOCOL_VERSION,
804
- requestId,
805
- };
806
- };
807
- const dispatch = (async (request: ControlRequest) => {
808
- const prior = completed.get(request.requestId);
809
- if (prior !== undefined) return prior;
810
- const running = inFlight.get(request.requestId);
811
- if (running !== undefined) return await running;
812
- if (unusableFailure !== null) {
813
- return remember(
814
- request.requestId,
815
- unusableResponse(request.requestId),
816
- );
817
- }
818
- const operation = tail.then(async () => {
819
- // A request can be admitted while the preceding operation is still
820
- // discovering that the controller is unusable. Recheck at execution
821
- // time so no distinct queued action reaches that controller.
822
- if (unusableFailure !== null) {
823
- return unusableResponse(request.requestId);
824
- }
825
- try {
826
- const snapshot = await executeRecordingAction(controller, request.action, request.options);
827
- return {
828
- ok: true,
829
- protocolVersion: CONTROL_PROTOCOL_VERSION,
830
- requestId: request.requestId,
831
- snapshot,
832
- } satisfies ControlResponse;
833
- } catch (error) {
834
- const failure = asCliError(error);
835
- const response = {
836
- error: {
837
- code: failure.code,
838
- ...(failure.details === undefined ? {} : { details: failure.details }),
839
- message: failure.message,
840
- },
841
- ok: false,
842
- protocolVersion: CONTROL_PROTOCOL_VERSION,
843
- requestId: request.requestId,
844
- } satisfies ControlResponse;
845
- if (
846
- recoveryControllerReusable(failure.details) === false
847
- && unusableFailure === null
848
- ) {
849
- unusableFailure = response.error;
850
- options.onControllerUnusable?.();
851
- }
852
- return response;
853
- }
854
- });
855
- inFlight.set(request.requestId, operation);
856
- tail = operation.then(() => undefined, () => undefined);
857
- const response = await operation;
858
- inFlight.delete(request.requestId);
859
- return remember(request.requestId, response);
860
- }) as SerializedRecordingDispatcher;
861
- dispatch.drain = async () => {
862
- while (true) {
863
- const admitted = tail;
864
- await admitted;
865
- if (admitted === tail) return;
866
- }
867
- };
868
- return dispatch;
869
- }
870
-
871
- export interface RecordingDaemonOptions {
872
- readonly artifactRoot: string;
873
- readonly controller?: RecordingController;
874
- readonly helperExecutable: string;
875
- readonly now?: () => Date;
876
- }
877
-
878
- export async function runRecordingDaemon(options: RecordingDaemonOptions): Promise<void> {
879
- const now = options.now ?? (() => new Date());
880
- const paths = controlPaths(options.artifactRoot);
881
- await ensurePrivateDirectory(options.artifactRoot);
882
- const nonce = randomUUID();
883
- const controller = options.controller ?? new CaptureHelperRecordingController({
884
- artifactRoot: options.artifactRoot,
885
- executable: options.helperExecutable,
886
- io: { now },
887
- });
888
- await rm(paths.socket, { force: true });
889
- let server: Server | undefined;
890
- let serverClosed: Promise<void> | undefined;
891
- let retirementRequested = false;
892
- const dispatch = createSerializedRecordingDispatcher(controller, {
893
- onControllerUnusable: () => {
894
- if (retirementRequested) return;
895
- retirementRequested = true;
896
- server?.close();
897
- },
898
- });
899
- try {
900
- server = createServer((socket) => handleRecordingControlSocket(socket, dispatch, nonce));
901
- await new Promise<void>((resolve, reject) => {
902
- server!.once("error", reject);
903
- server!.listen(paths.socket, resolve);
904
- });
905
- serverClosed = new Promise<void>((resolve, reject) => {
906
- server!.once("close", resolve);
907
- server!.once("error", reject);
908
- });
909
- await chmod(paths.socket, 0o600);
910
- const metadata: ControlMetadata = {
911
- nonce,
912
- pid: process.pid,
913
- protocolVersion: CONTROL_PROTOCOL_VERSION,
914
- socketPath: paths.socket,
915
- startedAt: now().toISOString(),
916
- uid: currentUid(),
917
- };
918
- await atomicWrite(paths.metadata, metadata);
919
- await rm(paths.lock, { force: true, recursive: true });
920
- await serverClosed;
921
- } finally {
922
- if (server !== undefined) server.close();
923
- await dispatch.drain();
924
- await controller.close();
925
- await Promise.all([
926
- rm(paths.lock, { force: true, recursive: true }),
927
- rm(paths.metadata, { force: true }),
928
- rm(paths.socket, { force: true }),
929
- ]);
930
- }
931
- }