@aefree/pi-unity 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows semantic versioning for public package releases.
7
7
 
8
+ ## [0.10.0] - 2026-08-14
9
+
10
+ ### Changed
11
+
12
+ - Added a configurable 1–86,400-second timeout to `unity_pipeline_eval`, forwarding it to both Unity CLI and the host process.
13
+ - Handle Pipeline 0.5's explicitly rejected initial-settling `Server Busy` envelopes with bounded retries for connected recompiles and tests, while preserving no-retry handling for ambiguous responses.
14
+
15
+ ## [0.9.3] - 2026-08-10
16
+
17
+ ### Added
18
+
19
+ - Added an optional `automated` parameter to `unity_open_editor`, forwarding Unity Editor's `-automated` flag through both Unity CLI and direct Editor launch paths.
20
+
8
21
  ## [0.9.2] - 2026-08-08
9
22
 
10
23
  ### Changed
package/README.md CHANGED
@@ -34,16 +34,16 @@ Use these tools with an already-open exact Unity project copy that has a reachab
34
34
  - `unity_project_status` — inspect lockfiles, matching Unity processes, Pipeline reachability, package version, and advertised commands without launching Unity.
35
35
  - `unity_pipeline_recompile` — recompile through Pipeline with exact-copy preflight, bounded polling, and compact compiler evidence.
36
36
  - `unity_pipeline_run_tests` — run one focused EditMode or PlayMode selection with bounded polling and aggregate results.
37
- - `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL.
37
+ - `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. It accepts `timeoutSeconds` from 1–86,400 seconds; a timeout is uncertain and does not cancel or retry Editor work.
38
38
  - `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
39
39
 
40
40
  Connected recompilation follows Unity's Script Changes While Playing policy and never preemptively sends `editor_stop`. Connected tests may exit Play Mode through advertised `editor_stop` when necessary, then verify Edit Mode before dispatch. Play Mode exit is allowed by default; `/unity-playmode-exit allow|disallow|status` controls the current session.
41
41
 
42
- A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode.
42
+ A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. The sole exception is Pipeline 0.5's explicit initial-settling `Server Busy` rejection for `unity_pipeline_recompile` and `unity_pipeline_run_tests`, which is known not to have dispatched a main-thread command and is retried only within the configured deadline.
43
43
 
44
44
  ### Editor and batchmode
45
45
 
46
- - `unity_open_editor` — open the Unity Editor GUI.
46
+ - `unity_open_editor` — open the Unity Editor GUI. Pass `automated: true` to add the Unity Editor `-automated` flag; this is distinct from the Unity CLI's own `--non-interactive` option.
47
47
  - `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
48
48
  - `unity_run_test_batch` — run one isolated or report-producing Unity Test Framework platform with generated XML and log paths.
49
49
  - `unity_inspect_artifacts` — summarize existing Unity Test Framework XML and Unity logs without launching Unity.
package/index.ts CHANGED
@@ -103,6 +103,7 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
103
103
  const OPEN_EDITOR_PARAMS = Type.Object({
104
104
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
105
105
  unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
106
+ automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
106
107
  launcher: LAUNCHER_SCHEMA,
107
108
  });
108
109
 
@@ -147,6 +148,7 @@ const PIPELINE_TEST_PARAMS = Type.Object({
147
148
  const PIPELINE_EVAL_PARAMS = Type.Object({
148
149
  path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
149
150
  code: Type.String({ minLength: 1, maxLength: 4000, description: "Bounded C# source for advertised Pipeline eval. Roslyn compiles it on the connected Editor main thread; include an explicit return value when evidence is needed." }),
151
+ timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
150
152
  }, { additionalProperties: false });
151
153
 
152
154
  const PIPELINE_INSPECTION_PARAMS = Type.Object({
@@ -1469,7 +1471,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1469
1471
  }, {
1470
1472
  execute: createPlanningUnityCliExecutor(pi),
1471
1473
  signal,
1472
- timeout: 12_000,
1474
+ timeout: (params.timeoutSeconds ?? 12) * 1000,
1473
1475
  });
1474
1476
  throwIfAborted(signal);
1475
1477
  const text = result.outcome === "dispatched"
@@ -1578,7 +1580,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1578
1580
  pi.registerTool({
1579
1581
  name: "unity_open_editor",
1580
1582
  label: "Unity Open Editor",
1581
- description: "Open the Unity Editor GUI for a Unity project copy.",
1583
+ description: "Open the Unity Editor GUI for a Unity project copy, optionally passing Unity Editor's -automated flag.",
1582
1584
  promptSnippet: "Open the Unity Editor GUI for a resolved Unity project when the user explicitly asks for the editor to open.",
1583
1585
  promptGuidelines: [
1584
1586
  "Use this tool only when the user explicitly wants the Unity Editor GUI opened.",
@@ -1605,11 +1607,12 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1605
1607
  launch = launchUnityCliOpenDetached(candidate.projectRoot, {
1606
1608
  editorVersion: candidate.unityVersion,
1607
1609
  editorPath: params.unityEditorPath,
1610
+ automated: params.automated,
1608
1611
  });
1609
1612
  } else {
1610
1613
  await assertUnityProjectNotBusy(candidate.projectRoot);
1611
1614
  editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1612
- launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
1615
+ launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
1613
1616
  }
1614
1617
  const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
1615
1618
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -16,7 +16,7 @@ Use one typed tool call for each supported connected operation:
16
16
 
17
17
  These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
18
18
 
19
- A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision.
19
+ A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_pipeline_run_tests`, which confirms that a main-thread command was rejected before dispatch.
20
20
 
21
21
  ## Preconditions and boundaries
22
22
 
@@ -49,4 +49,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
49
49
 
50
50
  Use `unity_run_test_batch` for a closed project, intentional isolation/CI, category or multiple filters, or required NUnit XML/log evidence. State the reason for that route. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
51
51
 
52
- Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; it is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
52
+ Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
package/src/unity-cli.ts CHANGED
@@ -18,6 +18,8 @@ export type UnityCliLaunchOptions = {
18
18
  timeoutSeconds?: number;
19
19
  cliCommand?: string;
20
20
  useGraphics?: boolean;
21
+ /** Forward Unity Editor's -automated flag through `unity open --args`. */
22
+ automated?: boolean;
21
23
  };
22
24
 
23
25
  export type UnityCliPipelineInstance = {
@@ -80,6 +82,7 @@ function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOpti
80
82
  export function createUnityCliOpenCommand(projectRoot: string, options: UnityCliLaunchOptions = {}): UnityCliCommand {
81
83
  const args = [...unityCliBaseArgs(), "open", projectRoot];
82
84
  appendUnityCliEditorOptions(args, options);
85
+ if (options.automated) args.push("--args", "-automated");
83
86
  return {
84
87
  command: resolveUnityCliCommand(options),
85
88
  args,
package/src/unity-core.ts CHANGED
@@ -64,8 +64,13 @@ export function buildUnityEditorCandidates(
64
64
  ];
65
65
  }
66
66
 
67
- export function buildUnityOpenEditorArgs(projectRoot: string): string[] {
68
- return ["-projectPath", projectRoot];
67
+ export type UnityOpenEditorArgsOptions = {
68
+ /** Pass Unity Editor's -automated flag. */
69
+ automated?: boolean;
70
+ };
71
+
72
+ export function buildUnityOpenEditorArgs(projectRoot: string, options: UnityOpenEditorArgsOptions = {}): string[] {
73
+ return ["-projectPath", projectRoot, ...(options.automated ? ["-automated"] : [])];
69
74
  }
70
75
 
71
76
  export type UnityBatchmodeArgsOptions = {
@@ -7,6 +7,7 @@ import {
7
7
  buildUnityOpenEditorArgs,
8
8
  normalizeUnityEditorOverride,
9
9
  type SupportedPlatform,
10
+ type UnityOpenEditorArgsOptions,
10
11
  } from "./unity-core";
11
12
  import { createUnityCliOpenCommand, type UnityCliLaunchOptions } from "./unity-cli";
12
13
 
@@ -47,8 +48,12 @@ export async function resolveUnityEditorPath(
47
48
  );
48
49
  }
49
50
 
50
- export function launchUnityEditorDetached(editorPath: string, projectRoot: string): { pid: number | undefined; args: string[]; command: string } {
51
- const args = buildUnityOpenEditorArgs(projectRoot);
51
+ export function launchUnityEditorDetached(
52
+ editorPath: string,
53
+ projectRoot: string,
54
+ options: UnityOpenEditorArgsOptions = {},
55
+ ): { pid: number | undefined; args: string[]; command: string } {
56
+ const args = buildUnityOpenEditorArgs(projectRoot, options);
52
57
  const child = spawn(editorPath, args, {
53
58
  detached: true,
54
59
  stdio: "ignore",
@@ -145,6 +145,17 @@ function diagnostics(result: RecordValue): string[] {
145
145
  });
146
146
  return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
147
147
  }
148
+ /** True only for Pipeline 0.5's explicit rejected outer envelope, before a main-thread command is dispatched. */
149
+ export function isUnityPipelineInitialSettlingBusy(output: string): boolean {
150
+ let outer: RecordValue | undefined;
151
+ try { outer = record(JSON.parse(output)); } catch { return false; }
152
+ const data = record(outer?.data);
153
+ return outer?.success === false
154
+ && string(field(data ?? {}, "error"))?.toLowerCase() === "server busy"
155
+ && statusOf(data ?? {}) === "busy"
156
+ && field(data ?? {}, "retryable") === true;
157
+ }
158
+
148
159
  export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
149
160
  const parsed = parseUnityPipelineEnvelope(output);
150
161
  if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
@@ -316,6 +327,19 @@ async function executeCommand(deps: PipelineDependencies, projectRoot: string, c
316
327
  if (deadline !== undefined) ensureBeforeDeadline(deadline, now, command);
317
328
  return result;
318
329
  }
330
+ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot: string, command: "recompile" | "run_tests", args: string[], operation: string, signal: AbortSignal | undefined, deadline: number, now: () => number, sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>): Promise<UnityCliExecResult> {
331
+ for (let attempt = 0; ; attempt += 1) {
332
+ const response = await executeCommand(deps, projectRoot, command, args, signal, deadline, now);
333
+ if (!isUnityPipelineInitialSettlingBusy(response.stdout)) return response;
334
+ const remaining = deadline - now();
335
+ if (remaining <= 0) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
336
+ const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(attempt, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, remaining);
337
+ await sleep(delay, signal);
338
+ throwIfAborted(signal);
339
+ if (now() >= deadline) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
340
+ }
341
+ }
342
+
319
343
  async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = true): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
320
344
  const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
321
345
  const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
@@ -404,7 +428,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
404
428
  const lifecyclePrefix = playModeOutcomeText(preflight);
405
429
  ensureBeforeDeadline(deadline, now, "recompile before dispatch");
406
430
  throwIfAborted(signal);
407
- const dispatched = await executeCommand(deps, projectRoot, "recompile", [], signal, deadline, now);
431
+ const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
408
432
  if (dispatched.error) throw new Error("Unity Pipeline recompile dispatch failed; operation may not have started.");
409
433
  let state = normalizeUnityPipelineCompile(dispatched.stdout);
410
434
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
@@ -421,6 +445,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
421
445
  if (identityState === "temporary_disconnect") continue;
422
446
  const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
423
447
  if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
448
+ if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
424
449
  state = normalizeUnityPipelineCompile(response.stdout);
425
450
  if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
426
451
  if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...playModeDetails(preflight) } };
@@ -448,7 +473,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
448
473
  }
449
474
  const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...(request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"] : []), "--async_tests", "true"];
450
475
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
451
- const dispatched = await executeCommand(deps, projectRoot, "run_tests", args, signal, deadline, now);
476
+ const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
452
477
  if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
453
478
  let state = normalizeUnityPipelineTest(dispatched.stdout);
454
479
  if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
@@ -473,6 +498,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
473
498
  if (identityState === "temporary_disconnect") continue;
474
499
  const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
475
500
  if (response.error) continue;
501
+ if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
476
502
  state = normalizeUnityPipelineTest(response.stdout);
477
503
  if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
478
504
  if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);