@aefree/pi-unity 0.9.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.
@@ -0,0 +1,635 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { applyDefaultUnityBatchmodeArgs, buildUnityBatchmodeArgs, projectPathsMatch } from "./unity-core";
5
+ import type { RunningUnityProcess } from "./unity-processes";
6
+
7
+ export const DEFAULT_UNITY_CLI_COMMAND = "unity";
8
+ export const UNITY_PIPELINE_EVAL_MAX_CHARS = 4_000;
9
+
10
+ export type UnityCliCommand = {
11
+ command: string;
12
+ args: string[];
13
+ };
14
+
15
+ export type UnityCliLaunchOptions = {
16
+ editorVersion?: string;
17
+ editorPath?: string;
18
+ timeoutSeconds?: number;
19
+ cliCommand?: string;
20
+ useGraphics?: boolean;
21
+ };
22
+
23
+ export type UnityCliPipelineInstance = {
24
+ projectPath: string;
25
+ pid: number | null;
26
+ port?: number;
27
+ unityVersion?: string;
28
+ pipelineVersion?: string;
29
+ state?: string;
30
+ reachable?: boolean;
31
+ };
32
+
33
+ export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
34
+
35
+ export type UnityCliProjectCapabilities = {
36
+ cliAvailable: boolean;
37
+ cliVersion?: string;
38
+ projectSupportsPipeline: boolean;
39
+ pipelinePackageDeclared: boolean;
40
+ pipelinePackageVersion?: string;
41
+ matchingInstances: UnityCliPipelineInstance[];
42
+ advertisedCommands: string[];
43
+ advertisedCommandCount: number;
44
+ advertisedCommandsTruncated: boolean;
45
+ commandDiscoveryAttempted: boolean;
46
+ commandDiscoverySucceeded: boolean;
47
+ latestPipelineVersion?: string;
48
+ /** A timeout/startup error is uncertainty, never proof that Pipeline is absent. */
49
+ pipelineDiscovery: UnityCliDiscoveryState;
50
+ commandDiscovery: UnityCliDiscoveryState;
51
+ warnings: string[];
52
+ };
53
+
54
+ export type UnityCliExecResult = {
55
+ stdout: string;
56
+ stderr: string;
57
+ error?: Error & { code?: string | number; signal?: string | null };
58
+ };
59
+
60
+ /** Injectable seam for deterministic capability and planning-dispatch tests. */
61
+ export type UnityCliExecutor = (command: string, args: string[], options: { timeout?: number; signal?: AbortSignal }) => Promise<UnityCliExecResult>;
62
+
63
+ export function resolveUnityCliCommand(options?: { cliCommand?: string; env?: NodeJS.ProcessEnv }): string {
64
+ return options?.cliCommand?.trim() || options?.env?.UNITY_CLI_PATH?.trim() || process.env.UNITY_CLI_PATH?.trim() || DEFAULT_UNITY_CLI_COMMAND;
65
+ }
66
+
67
+ function unityCliBaseArgs(): string[] {
68
+ return ["--no-banner", "--non-interactive"];
69
+ }
70
+
71
+ function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOptions): void {
72
+ if (options.editorVersion?.trim()) {
73
+ args.push("--editor-version", options.editorVersion.trim());
74
+ }
75
+ if (options.editorPath?.trim()) {
76
+ args.push("--editor-path", options.editorPath.trim());
77
+ }
78
+ }
79
+
80
+ export function createUnityCliOpenCommand(projectRoot: string, options: UnityCliLaunchOptions = {}): UnityCliCommand {
81
+ const args = [...unityCliBaseArgs(), "open", projectRoot];
82
+ appendUnityCliEditorOptions(args, options);
83
+ return {
84
+ command: resolveUnityCliCommand(options),
85
+ args,
86
+ };
87
+ }
88
+
89
+ const UNITY_CLI_MANAGED_EDITOR_FLAGS_WITH_VALUES = new Set(["-projectpath"]);
90
+ const UNITY_CLI_MANAGED_EDITOR_FLAGS = new Set(["-batchmode", "-quit"]);
91
+
92
+ export function normalizeUnityCliForwardedArgs(extraEditorArgs: string[] = []): string[] {
93
+ const normalized: string[] = [];
94
+ for (let index = 0; index < extraEditorArgs.length; index += 1) {
95
+ const arg = extraEditorArgs[index];
96
+ const lower = arg.toLowerCase();
97
+ const equalsIndex = lower.indexOf("=");
98
+ const flagName = equalsIndex >= 0 ? lower.slice(0, equalsIndex) : lower;
99
+
100
+ if (UNITY_CLI_MANAGED_EDITOR_FLAGS.has(flagName)) {
101
+ continue;
102
+ }
103
+
104
+ if (UNITY_CLI_MANAGED_EDITOR_FLAGS_WITH_VALUES.has(flagName)) {
105
+ if (equalsIndex < 0) {
106
+ index += 1;
107
+ }
108
+ continue;
109
+ }
110
+
111
+ normalized.push(arg);
112
+ }
113
+ return normalized;
114
+ }
115
+
116
+ export function createUnityCliRunCommand(projectRoot: string, extraEditorArgs: string[] = [], options: UnityCliLaunchOptions = {}): UnityCliCommand {
117
+ const args = [...unityCliBaseArgs(), "run", projectRoot];
118
+ const forwardedArgs = normalizeUnityCliForwardedArgs(applyDefaultUnityBatchmodeArgs(extraEditorArgs, { useGraphics: options.useGraphics }));
119
+ appendUnityCliEditorOptions(args, options);
120
+ if (options.timeoutSeconds !== undefined) {
121
+ args.push("--timeout", String(options.timeoutSeconds));
122
+ }
123
+ if (forwardedArgs.length > 0) {
124
+ args.push("--", ...forwardedArgs);
125
+ }
126
+ return {
127
+ command: resolveUnityCliCommand(options),
128
+ args,
129
+ };
130
+ }
131
+
132
+ export function createUnityCliBatchmodeReportArgs(projectRoot: string, extraEditorArgs: string[] = [], options: { useGraphics?: boolean } = {}): string[] {
133
+ return buildUnityBatchmodeArgs(projectRoot, extraEditorArgs, options);
134
+ }
135
+
136
+ export function createUnityCliEditorExitCommand(
137
+ projectRoot: string,
138
+ options: { cliCommand?: string; timeoutSeconds?: number } = {},
139
+ ): UnityCliCommand {
140
+ return {
141
+ command: resolveUnityCliCommand(options),
142
+ args: [
143
+ ...unityCliBaseArgs(),
144
+ "command",
145
+ "--project-path",
146
+ projectRoot,
147
+ "--timeout",
148
+ String(options.timeoutSeconds ?? 5),
149
+ "eval",
150
+ "UnityEditor.EditorApplication.Exit(0); return true;",
151
+ ],
152
+ };
153
+ }
154
+
155
+ export const UNITY_CLI_VERSION_TIMEOUT_MS = 5_000;
156
+ /** Pipeline startup/discovery may legitimately finish near five seconds; remain bounded but do not classify it as absent. */
157
+ export const UNITY_CLI_DISCOVERY_TIMEOUT_MS = 12_000;
158
+
159
+ const execFileCollect: UnityCliExecutor = (command, args, options = {}) => {
160
+ return new Promise((resolve) => {
161
+ execFile(command, args, { timeout: options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS, signal: options.signal, windowsHide: true }, (error, stdout, stderr) => {
162
+ resolve({
163
+ stdout: typeof stdout === "string" ? stdout : stdout.toString(),
164
+ stderr: typeof stderr === "string" ? stderr : stderr.toString(),
165
+ error: error as UnityCliExecResult["error"],
166
+ });
167
+ });
168
+ });
169
+ };
170
+
171
+ function parseJsonObject(text: string): Record<string, unknown> | null {
172
+ const trimmed = text.trim();
173
+ if (!trimmed) return null;
174
+ try {
175
+ const parsed = JSON.parse(trimmed) as unknown;
176
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function getRecord(value: unknown): Record<string, unknown> | null {
183
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
184
+ }
185
+
186
+ function getNumber(value: unknown): number | null {
187
+ if (typeof value === "number" && Number.isFinite(value)) return value;
188
+ if (typeof value === "string") {
189
+ const parsed = Number.parseInt(value, 10);
190
+ return Number.isFinite(parsed) ? parsed : null;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function instancePid(instance: Record<string, unknown>): number | null {
196
+ return getNumber(instance.pid) ?? getNumber(instance.PID) ?? getNumber(instance.processId) ?? getNumber(instance.processID);
197
+ }
198
+
199
+ function instanceProjectPaths(instance: Record<string, unknown>): string[] {
200
+ return [
201
+ instance.projectPath,
202
+ instance.project,
203
+ instance.path,
204
+ instance.projectRoot,
205
+ instance.projectDirectory,
206
+ ].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
207
+ }
208
+
209
+ export function parseUnityCliStatusOutput(output: string, projectRoot: string): RunningUnityProcess[] {
210
+ const payload = parseJsonObject(output);
211
+ const data = getRecord(payload?.data);
212
+ const rawInstances = Array.isArray(data?.instances) ? data.instances : [];
213
+
214
+ return rawInstances
215
+ .map((entry) => {
216
+ const instance = getRecord(entry);
217
+ if (!instance) return null;
218
+ const projectPath = instanceProjectPaths(instance).find((candidate) => projectPathsMatch(candidate, projectRoot));
219
+ if (!projectPath) return null;
220
+ const pid = instancePid(instance);
221
+ const port = instance.port ?? instance.editorPort ?? instance.hostPort;
222
+ return {
223
+ pid,
224
+ commandLine: `Unity CLI status${port !== undefined ? ` port=${String(port)}` : ""}: ${projectPath}`,
225
+ } satisfies RunningUnityProcess;
226
+ })
227
+ .filter((entry): entry is RunningUnityProcess => entry !== null);
228
+ }
229
+
230
+ export async function listRunningUnityCliEditorsForProject(
231
+ projectRoot: string,
232
+ options: { cliCommand?: string; timeout?: number } = {},
233
+ ): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
234
+ const command = resolveUnityCliCommand(options);
235
+ const result = await execFileCollect(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
236
+ timeout: options.timeout ?? 5000,
237
+ });
238
+
239
+ if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") {
240
+ return { processes: [] };
241
+ }
242
+
243
+ const processes = parseUnityCliStatusOutput(result.stdout, projectRoot);
244
+ if (processes.length > 0) {
245
+ return { processes };
246
+ }
247
+
248
+ const payload = parseJsonObject(result.stdout);
249
+ const errors = Array.isArray(payload?.errors) ? payload.errors : [];
250
+ const onlyNoInstances = errors.some((entry) => getRecord(entry)?.code === "STATUS_NO_INSTANCES");
251
+ if (result.error && !onlyNoInstances) {
252
+ const message = result.stderr.trim() || result.error.message;
253
+ return { processes: [], warning: `Unity CLI status check failed; falling back to process scan: ${message}` };
254
+ }
255
+
256
+ return { processes: [] };
257
+ }
258
+
259
+ function optionalString(...values: unknown[]): string | undefined {
260
+ return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
261
+ }
262
+
263
+ function optionalBoolean(...values: unknown[]): boolean | undefined {
264
+ return values.find((value): value is boolean => typeof value === "boolean");
265
+ }
266
+
267
+ export function summarizeUnityCliText(value: string, maxChars = 1000, maxLines = 10): string {
268
+ const lines = value.trim().split(/\r?\n/).slice(0, maxLines);
269
+ const text = lines.join("\n");
270
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
271
+ }
272
+
273
+ export function parseUnityCliPipelineListOutput(output: string, projectRoot: string): { instances: UnityCliPipelineInstance[]; latestVersion?: string } {
274
+ const payload = parseJsonObject(output);
275
+ const data = getRecord(payload?.data);
276
+ const rawInstances = Array.isArray(data?.instances) ? data.instances : [];
277
+ const instances = rawInstances.flatMap((entry): UnityCliPipelineInstance[] => {
278
+ const instance = getRecord(entry);
279
+ if (!instance) return [];
280
+ const projectPath = instanceProjectPaths(instance).find((candidate) => projectPathsMatch(candidate, projectRoot));
281
+ if (!projectPath) return [];
282
+ const pipelineServer = getRecord(instance.pipelineServer);
283
+ let port = getNumber(instance.port ?? instance.editorPort ?? instance.hostPort) ?? undefined;
284
+ const apiUrl = optionalString(pipelineServer?.apiUrl);
285
+ if (port === undefined && apiUrl) {
286
+ try {
287
+ const parsedPort = Number.parseInt(new URL(apiUrl).port, 10);
288
+ if (Number.isFinite(parsedPort)) port = parsedPort;
289
+ } catch {
290
+ // Keep port unknown when the CLI reports a malformed endpoint.
291
+ }
292
+ }
293
+ const isRunning = optionalBoolean(instance.isRunning);
294
+ return [{
295
+ projectPath,
296
+ pid: instancePid(instance),
297
+ port,
298
+ unityVersion: optionalString(instance.unityVersion, instance.editorVersion, instance.version),
299
+ pipelineVersion: optionalString(instance.pipelineVersion, instance.packageVersion, instance.pipelinePackageVersion),
300
+ state: optionalString(instance.state, instance.status) ?? (isRunning === undefined ? undefined : isRunning ? "running" : "stopped"),
301
+ reachable: optionalBoolean(instance.reachable, instance.serverReachable, instance.isReachable, pipelineServer?.isReachable),
302
+ }];
303
+ });
304
+ return { instances, latestVersion: optionalString(data?.latestVersion) };
305
+ }
306
+
307
+ type UnityCliCommandCatalog = {
308
+ valid: boolean;
309
+ commands: string[];
310
+ total: number;
311
+ truncated: boolean;
312
+ };
313
+
314
+ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
315
+ const payload = parseJsonObject(output);
316
+ const data = getRecord(payload?.data);
317
+ const candidates: unknown[] = [];
318
+ let valid = false;
319
+ if (Array.isArray(payload?.data)) {
320
+ valid = true;
321
+ candidates.push(...payload.data);
322
+ }
323
+ for (const key of ["commands", "tools", "items"]) {
324
+ const value = data?.[key];
325
+ if (Array.isArray(value)) {
326
+ valid = true;
327
+ candidates.push(...value);
328
+ }
329
+ }
330
+ const names = candidates.flatMap((entry): string[] => {
331
+ const rawName = typeof entry === "string"
332
+ ? entry
333
+ : optionalString(getRecord(entry)?.name, getRecord(entry)?.command, getRecord(entry)?.id);
334
+ if (!rawName) return [];
335
+ const name = rawName.trim();
336
+ if (!name || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) return [];
337
+ return [name];
338
+ });
339
+ const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
340
+ return {
341
+ valid: Boolean(payload?.success === true && valid),
342
+ commands: unique.slice(0, 256),
343
+ total: unique.length,
344
+ truncated: unique.length > 256 || names.length < candidates.length,
345
+ };
346
+ }
347
+
348
+ export function parseUnityCliCommandListOutput(output: string): string[] {
349
+ const catalog = parseUnityCliCommandCatalog(output);
350
+ return catalog.valid ? catalog.commands : [];
351
+ }
352
+
353
+ export function haveSameKnownProcessIds(
354
+ initial: Array<{ pid?: number | null }>,
355
+ refreshed: Array<{ pid?: number | null }>,
356
+ ): boolean {
357
+ const initialPids = initial.flatMap((item) => item.pid ?? []).sort((left, right) => left - right);
358
+ const refreshedPids = refreshed.flatMap((item) => item.pid ?? []).sort((left, right) => left - right);
359
+ return initialPids.length === initial.length
360
+ && refreshedPids.length === refreshed.length
361
+ && initialPids.length > 0
362
+ && initialPids.join(",") === refreshedPids.join(",");
363
+ }
364
+
365
+ function parseUnityMajorVersion(unityVersion: string): number | null {
366
+ const match = unityVersion.trim().match(/^(\d+)/);
367
+ if (!match) return null;
368
+ const major = Number.parseInt(match[1], 10);
369
+ return Number.isFinite(major) ? major : null;
370
+ }
371
+
372
+ async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
373
+ try {
374
+ return getRecord(JSON.parse(await readFile(filePath, "utf8")));
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Promise<string | undefined> {
381
+ const lock = await readJsonFile(join(projectRoot, "Packages", "packages-lock.json"));
382
+ const lockDependencies = getRecord(lock?.dependencies);
383
+ const lockedPipeline = getRecord(lockDependencies?.["com.unity.pipeline"]);
384
+ const lockedVersion = optionalString(lockedPipeline?.version);
385
+ if (lockedVersion) return lockedVersion;
386
+
387
+ const manifest = await readJsonFile(join(projectRoot, "Packages", "manifest.json"));
388
+ const manifestDependencies = getRecord(manifest?.dependencies);
389
+ return optionalString(manifestDependencies?.["com.unity.pipeline"]);
390
+ }
391
+
392
+ export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
393
+ const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
394
+ return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
395
+ }
396
+
397
+ function cliFailureMessage(result: UnityCliExecResult): string | undefined {
398
+ const payload = parseJsonObject(result.stdout);
399
+ const errors = Array.isArray(payload?.errors) ? payload.errors : [];
400
+ const messages = errors.flatMap((entry): string[] => {
401
+ const message = optionalString(getRecord(entry)?.message);
402
+ return message ? [message] : [];
403
+ });
404
+ const message = messages[0] ?? (result.stderr.trim() || result.error?.message);
405
+ return message ? summarizeUnityCliText(message) : undefined;
406
+ }
407
+
408
+ export async function inspectUnityCliProjectCapabilities(
409
+ projectRoot: string,
410
+ unityVersion: string,
411
+ options: { cliCommand?: string; timeout?: number; signal?: AbortSignal; execute?: UnityCliExecutor } = {},
412
+ ): Promise<UnityCliProjectCapabilities> {
413
+ const execute = options.execute ?? execFileCollect;
414
+ const pipelinePackageVersion = await readDeclaredUnityPipelineVersion(projectRoot);
415
+ const projectMajor = parseUnityMajorVersion(unityVersion);
416
+ const result: UnityCliProjectCapabilities = {
417
+ cliAvailable: false,
418
+ projectSupportsPipeline: projectMajor !== null && projectMajor >= 6000,
419
+ pipelinePackageDeclared: Boolean(pipelinePackageVersion),
420
+ pipelinePackageVersion,
421
+ matchingInstances: [],
422
+ advertisedCommands: [],
423
+ advertisedCommandCount: 0,
424
+ advertisedCommandsTruncated: false,
425
+ commandDiscoveryAttempted: false,
426
+ commandDiscoverySucceeded: false,
427
+ pipelineDiscovery: "not_attempted",
428
+ commandDiscovery: "not_attempted",
429
+ warnings: [],
430
+ };
431
+ const command = resolveUnityCliCommand(options);
432
+ const versionTimeout = options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS;
433
+ const discoveryTimeout = options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS;
434
+ const versionResult = await execute(command, ["--version"], { timeout: versionTimeout, signal: options.signal });
435
+ if (versionResult.error && (versionResult.error as NodeJS.ErrnoException).code === "ENOENT") return result;
436
+ if (versionResult.error) {
437
+ result.warnings.push(`Unity CLI version probe ${isUnityCliTimeout(versionResult) ? "timed out" : "failed"}: ${cliFailureMessage(versionResult) ?? "unknown error"}`);
438
+ return result;
439
+ }
440
+ result.cliAvailable = true;
441
+ result.cliVersion = summarizeUnityCliText(versionResult.stdout, 200, 1) || undefined;
442
+
443
+ const pipelineResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "pipeline", "list"], { timeout: discoveryTimeout, signal: options.signal });
444
+ const pipelinePayload = parseJsonObject(pipelineResult.stdout);
445
+ const pipelineData = getRecord(pipelinePayload?.data);
446
+ if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances)) {
447
+ result.pipelineDiscovery = isUnityCliTimeout(pipelineResult) ? "timeout" : "unavailable";
448
+ result.warnings.push(`Unity Pipeline instance discovery ${result.pipelineDiscovery === "timeout" ? "timed out; Pipeline startup state is uncertain" : "failed"}: ${cliFailureMessage(pipelineResult) ?? "malformed or unsupported JSON response"}`);
449
+ return result;
450
+ }
451
+ result.pipelineDiscovery = "available";
452
+ const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
453
+ result.matchingInstances = pipeline.instances;
454
+ result.latestPipelineVersion = pipeline.latestVersion;
455
+ if (pipeline.instances.length === 0) {
456
+ result.pipelineDiscovery = "absent";
457
+ return result;
458
+ }
459
+ if (pipeline.instances.every((instance) => instance.reachable === false)) {
460
+ result.warnings.push("The exact project copy has Pipeline metadata, but every matching instance is explicitly unreachable.");
461
+ return result;
462
+ }
463
+
464
+ result.commandDiscoveryAttempted = true;
465
+ const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "list", "--project-path", projectRoot], { timeout: discoveryTimeout, signal: options.signal });
466
+ const catalog = parseUnityCliCommandCatalog(listResult.stdout);
467
+ if (listResult.error || !catalog.valid) {
468
+ result.commandDiscovery = isUnityCliTimeout(listResult) ? "timeout" : "unavailable";
469
+ result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "failed"}: ${cliFailureMessage(listResult) ?? "malformed or unsupported JSON response"}`);
470
+ return result;
471
+ }
472
+ result.commandDiscovery = "available";
473
+ result.advertisedCommands = catalog.commands;
474
+ result.advertisedCommandCount = catalog.total;
475
+ result.advertisedCommandsTruncated = catalog.truncated;
476
+ result.commandDiscoverySucceeded = true;
477
+ return result;
478
+ }
479
+
480
+ /**
481
+ * Purpose-built connected inspection commands intentionally supported by pi-unity.
482
+ * This list is package-owned: callers cannot promote an arbitrary Pipeline command
483
+ * to a planning read by supplying their own allow-list.
484
+ */
485
+ export const UNITY_PLANNING_READ_COMMANDS = Object.freeze([
486
+ "get_authoring_root",
487
+ "get_build_settings",
488
+ "get_player_settings",
489
+ "get_scene_hierarchy",
490
+ "editor_status",
491
+ "list_open_scenes",
492
+ "list_build_targets",
493
+ ] as const);
494
+
495
+ export type UnityPlanningInspectionRequest = {
496
+ projectRoot: string;
497
+ unityVersion: string;
498
+ /** Command must be advertised by the exact reachable Pipeline copy. */
499
+ command: string;
500
+ args?: string[];
501
+ /** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
502
+ evalSnippet?: string;
503
+ };
504
+
505
+ export type UnityPlanningInspectionResult =
506
+ | { outcome: "dispatched"; command: string; output: string; truncated: boolean }
507
+ | { outcome: "rejected"; code: string; message: string };
508
+
509
+ function planningInspectionReadiness(capabilities: UnityCliProjectCapabilities): string | undefined {
510
+ if (!capabilities.cliAvailable) return "unity_cli_unavailable";
511
+ if (capabilities.pipelineDiscovery !== "available") return `pipeline_${capabilities.pipelineDiscovery}`;
512
+ if (!capabilities.commandDiscoverySucceeded || capabilities.commandDiscovery !== "available") return `commands_${capabilities.commandDiscovery}`;
513
+ if (!capabilities.matchingInstances.some((instance) => instance.reachable === true)) return "pipeline_not_reachable";
514
+ if (capabilities.matchingInstances.some((instance) => !Number.isInteger(instance.pid) || (instance.pid ?? 0) <= 0)) return "pipeline_identity_unknown";
515
+ return undefined;
516
+ }
517
+
518
+ function caseInsensitiveField(record: Record<string, unknown>, name: string): unknown {
519
+ const entry = Object.entries(record).find(([key]) => key.toLowerCase() === name.toLowerCase());
520
+ return entry?.[1];
521
+ }
522
+
523
+ function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
524
+ const envelope = parseJsonObject(output);
525
+ if (!envelope) return "malformed";
526
+ if (caseInsensitiveField(envelope, "success") !== true) return "failure";
527
+ const data = getRecord(caseInsensitiveField(envelope, "data"));
528
+ if (!data) return "malformed";
529
+ if (caseInsensitiveField(data, "success") === false) return "failure";
530
+ if (!isEval) return undefined;
531
+
532
+ let response: unknown = caseInsensitiveField(data, "result") ?? data;
533
+ if (typeof response === "string") {
534
+ try { response = JSON.parse(response); } catch { return "malformed"; }
535
+ }
536
+ const evalResponse = getRecord(response);
537
+ if (!evalResponse) return "malformed";
538
+ if (caseInsensitiveField(evalResponse, "success") !== true) return "failure";
539
+ const diagnostics = caseInsensitiveField(evalResponse, "diagnostics");
540
+ if (Array.isArray(diagnostics) && diagnostics.some(item => {
541
+ const diagnostic = getRecord(item);
542
+ return String(caseInsensitiveField(diagnostic ?? {}, "severity") ?? "").toLowerCase() === "error";
543
+ })) return "failure";
544
+ return undefined;
545
+ }
546
+
547
+ /**
548
+ * The sole connected planning/eval dispatch seam. It re-discovers the exact canonical copy
549
+ * immediately before execution and accepts advertised package-owned reads or advertised eval.
550
+ * Eval is arbitrary bounded C#, so caller task intent and guidance—not syntax classification—
551
+ * govern mutations. Callers must provide an executor; discovery never dispatches work.
552
+ */
553
+ export async function dispatchUnityPlanningInspection(
554
+ request: UnityPlanningInspectionRequest,
555
+ options: {
556
+ cliCommand?: string;
557
+ timeout?: number;
558
+ signal?: AbortSignal;
559
+ execute: UnityCliExecutor;
560
+ inspect?: (projectRoot: string, unityVersion: string) => Promise<UnityCliProjectCapabilities>;
561
+ },
562
+ ): Promise<UnityPlanningInspectionResult> {
563
+ let projectRoot: string;
564
+ try {
565
+ projectRoot = await realpath(request.projectRoot);
566
+ } catch {
567
+ return { outcome: "rejected", code: "unity_project_identity_unavailable", message: "The Unity project root could not be canonicalized." };
568
+ }
569
+ const inspect = options.inspect ?? ((root, version) => inspectUnityCliProjectCapabilities(root, version, {
570
+ cliCommand: options.cliCommand,
571
+ timeout: options.timeout,
572
+ signal: options.signal,
573
+ execute: options.execute,
574
+ }));
575
+ const initial = await inspect(projectRoot, request.unityVersion);
576
+ const initialFailure = planningInspectionReadiness(initial);
577
+ if (initialFailure) return { outcome: "rejected", code: initialFailure, message: "Exact-copy Pipeline planning inspection is not established." };
578
+
579
+ const isEval = request.command === "eval";
580
+ const hasBoundedArgs = (request.args?.length ?? 0) <= 12
581
+ && (request.args ?? []).every((arg) => typeof arg === "string" && arg.length <= 500 && !/[\u0000-\u001f\u007f]/.test(arg));
582
+ if (!hasBoundedArgs) {
583
+ return { outcome: "rejected", code: "planning_command_args_invalid", message: "Connected inspection command arguments exceed the bounded request limits." };
584
+ }
585
+ if (isEval) {
586
+ const snippet = request.evalSnippet?.trim() ?? "";
587
+ if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
588
+ return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
589
+ }
590
+ } else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
591
+ return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
592
+ }
593
+ if (!initial.advertisedCommands.includes(request.command)) {
594
+ return { outcome: "rejected", code: "planning_command_unadvertised", message: "The exact Pipeline copy did not advertise the requested command." };
595
+ }
596
+
597
+ const refreshed = await inspect(projectRoot, request.unityVersion);
598
+ const refreshedFailure = planningInspectionReadiness(refreshed);
599
+ if (refreshedFailure || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances)) {
600
+ return { outcome: "rejected", code: "unity_project_identity_changed", message: "Pipeline identity changed or disconnected immediately before planning dispatch." };
601
+ }
602
+ if (!refreshed.advertisedCommands.includes(request.command)) {
603
+ return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
604
+ }
605
+
606
+ const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
607
+ const args = [
608
+ "--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
609
+ "--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
610
+ request.command,
611
+ ...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
612
+ ];
613
+ const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
614
+ if (execution.error) {
615
+ return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message: "Connected command did not complete successfully; its effect may be uncertain." };
616
+ }
617
+ const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
618
+ const output = redactUnityPlanningOutput(summarizeUnityCliText(raw, 4_000, 40));
619
+ const reportedFailure = connectedCommandFailure(execution.stdout, isEval);
620
+ if (reportedFailure) {
621
+ return {
622
+ outcome: "rejected",
623
+ code: reportedFailure === "malformed" ? "planning_command_malformed" : "planning_command_reported_failure",
624
+ message: `${reportedFailure === "malformed" ? "Connected command returned malformed JSON evidence" : "Connected command reported failure"}.${output ? ` ${output}` : ""}`,
625
+ };
626
+ }
627
+ return { outcome: "dispatched", command: request.command, output, truncated: output.length < raw.trim().length };
628
+ }
629
+
630
+ /** Keep connected inspection output useful without returning common credential forms verbatim. */
631
+ export function redactUnityPlanningOutput(value: string): string {
632
+ return value
633
+ .replace(/\b((?:bearer|token|api[_ -]?key|password|secret)\s*[:=])\s*[^\s,;]+/gi, "$1 [redacted]")
634
+ .replace(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{12,}\b/gi, "[redacted]");
635
+ }