@aefree/pi-unity 0.14.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.15.1 - 2026-09-20
11
+
12
+ ### Changed
13
+
14
+ - Update direct Pi development dependencies to 0.86.1 and require that exact local coding-agent version for the active guidance eval. Peer compatibility remains host-managed.
15
+
16
+ ## 0.15.0 - 2026-09-15
17
+
18
+ ### Fixed
19
+
20
+ - Correct Node directory-entry and child-process types, restore contextual typing for frozen optional integration contracts, and include the required details field in Pi progress updates.
21
+ - Add a pinned strict production-source type check that runs before the test suite.
22
+ - Preserve exact JSON values and raw evidence in expanded results, and prioritize failed/error test diagnostics before skipped records.
23
+
24
+ ### Changed
25
+
26
+ - Use compact Unity action headers and consistent result summaries inside Pi's existing tool boxes. Expanded results include project identity, highlighted eval code, formatted JSON, evidence, and artifact paths.
27
+ - Show unified test counts, duration, route, failures, and uncertainty while collapsed; distinguish artifact inspection success from test outcomes and correct run-script and test labels.
28
+ - Add an offline tool presentation preview and renderer checks for narrow terminals, redaction, and failure visibility.
29
+
10
30
  ## 0.14.0 - 2026-09-14
11
31
 
12
32
  ### Fixed
package/README.md CHANGED
@@ -25,6 +25,16 @@ pi install -l <path-to-pi-unity> # project-local
25
25
 
26
26
  Pi discovers the extension from `index.ts` and packaged skills from `skills/`. In UI sessions, pi-unity warns once per Pi runtime when Unity CLI is unavailable or `UNITY_CLI_PATH` is invalid. The warning never displays configured paths; install Unity CLI, then restart or reload Pi.
27
27
 
28
+ ## Development checks
29
+
30
+ Run `npm ci`, then `npm test` from the package repository. Tests first run the strict production-source type check (`index.ts` and `src/`) using the pinned TypeScript compiler and Node 22 types. Run `npm run typecheck` for that check alone. Tests and eval harnesses execute separately through `tsx`; they are outside this type-check scope.
31
+
32
+ ## Tool presentation
33
+
34
+ In Pi's terminal UI, Unity tools use compact action headers and project names. Collapsed results show test counts, timing and route when available, plus failures and uncertainty. Expand using Pi's configured tool-details shortcut to see the full project path, highlighted eval code, formatted JSON results, bounded evidence, and artifact paths. Inspection success and test outcomes are displayed separately.
35
+
36
+ For a local, offline text preview, run `npm run preview:tools` from this package. The preview exercises the renderers without launching Unity; Pi supplies the surrounding tool box and configured keybinding hints in a live session.
37
+
28
38
  ## Included tools
29
39
 
30
40
  ### Connected Pipeline
package/index.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { renderUnityGuidanceResult, renderUnityToolCall, renderUnityPipelineCall, renderUnityToolResult, renderUnityPipelineResult } from "./src/unity-renderers";
2
+ import { type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { StringEnum } from "@earendil-works/pi-ai";
3
4
  import { Type } from "typebox";
5
+ import type { Dirent } from "node:fs";
4
6
  import { mkdir, readFile, readdir, realpath, stat, unlink } from "node:fs/promises";
5
7
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
8
  import { setTimeout as delay } from "node:timers/promises";
7
- import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { getKeybindings, truncateToWidth } from "@earendil-works/pi-tui";
8
10
  import {
9
11
  buildUnityBatchmodeAgentText,
10
12
  deriveUnityBatchmodeStatus,
@@ -69,7 +71,7 @@ async function loadFileDiscoveryFilterIntegrationV1(pi: Pick<ExtensionAPI, "getA
69
71
  const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same as batchmode/headless Unity.";
70
72
  const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
71
73
 
72
- type UnityToolDetails = {
74
+ export type UnityToolDetails = {
73
75
  mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline_run_script" | "pipeline" | "tests";
74
76
  projectRoot: string;
75
77
  unityVersion: string;
@@ -97,6 +99,7 @@ type UnityToolDetails = {
97
99
  removedLockfile?: string;
98
100
  piUnitySettings?: PiUnitySettings;
99
101
  sessionSettings?: { allowAutonomousPlayModeExit: boolean };
102
+ projectState?: { nativeLockfileExists: boolean; runningProcessCount: number; processVerificationIncomplete: boolean; staleLockSuspected: boolean };
100
103
  testBatch?: UnityTestBatchPlan;
101
104
  cliCapabilities?: UnityCliProjectCapabilities;
102
105
  pipelineInspection?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
@@ -681,13 +684,14 @@ async function buildProjectStatusReport(
681
684
  status: "passed",
682
685
  piUnitySettings,
683
686
  sessionSettings: { allowAutonomousPlayModeExit },
687
+ projectState: { nativeLockfileExists: lockState.nativeLockfileExists, runningProcessCount: runningProcesses.length, processVerificationIncomplete: Boolean(warning), staleLockSuspected },
684
688
  cliCapabilities,
685
689
  },
686
690
  };
687
691
  }
688
692
 
689
693
  async function findNewestFile(root: string, suffixes: string[]): Promise<string | undefined> {
690
- let entries: Awaited<ReturnType<typeof readdir>>;
694
+ let entries: Dirent[];
691
695
  try {
692
696
  entries = await readdir(root, { withFileTypes: true });
693
697
  } catch (error) {
@@ -932,92 +936,6 @@ async function buildBatchmodeReport(
932
936
  };
933
937
  }
934
938
 
935
- function compactUnityRendererValue(value: unknown, limit = 160): string {
936
- const redacted = String(value ?? "").replace(
937
- /\b(token|secret|password|api[_-]?key)\s*([:=])\s*((?:\$@?|@\$?)?"(?:""|\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;)}\]]+)/gi,
938
- "$1$2[redacted]",
939
- );
940
- const normalized = redacted.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
941
- return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
942
- }
943
-
944
- function reuseRendererText(context: { lastComponent?: unknown } | undefined, text: string): Text {
945
- const component = context?.lastComponent;
946
- if (component instanceof Text) {
947
- component.setText(text);
948
- return component;
949
- }
950
- return new Text(text, 0, 0);
951
- }
952
-
953
- function renderUnityToolCall(
954
- name: string,
955
- args: { path?: string; args?: string[] },
956
- theme: any,
957
- modeLabel: string,
958
- emphasis: string,
959
- context?: { lastComponent?: unknown },
960
- ): Text {
961
- const pathLabel = compactUnityRendererValue(args.path?.trim() || "auto-resolve", 120);
962
- const extraArgs = Array.isArray(args.args) && args.args.length > 0
963
- ? args.args.slice(0, 4).join(" ") + (args.args.length > 4 ? ` ... +${args.args.length - 4}` : "")
964
- : undefined;
965
- let text =
966
- theme.fg("toolTitle", theme.bold(`${name} `)) +
967
- theme.fg("accent", modeLabel) +
968
- theme.fg("muted", ` (${emphasis})`);
969
- text += `\n ${theme.fg("accent", pathLabel)}`;
970
- if (extraArgs) {
971
- text += `\n ${theme.fg("muted", extraArgs)}`;
972
- }
973
- return reuseRendererText(context, text);
974
- }
975
-
976
- function renderUnityPipelineCall(
977
- name: string,
978
- args: { path?: string; testPlatform?: string; testFilter?: string; command?: string; code?: string },
979
- theme: any,
980
- context: { lastComponent?: unknown },
981
- ): Text {
982
- const detail = name === "unity_pipeline_run_tests"
983
- ? `${args.testPlatform ?? "tests"}${args.testFilter ? ` • ${compactUnityRendererValue(args.testFilter, 100)}` : ""}`
984
- : name === "unity_pipeline_inspect"
985
- ? `command=${compactUnityRendererValue(args.command ?? "(missing)", 100)}`
986
- : name === "unity_pipeline_eval"
987
- ? `C# ${compactUnityRendererValue(args.code ?? "(missing)", 140)}`
988
- : "connected bounded recompile";
989
- return renderUnityToolCall(name, args, theme, "pipeline", detail, context);
990
- }
991
-
992
- function getToolTextContent(result: any): string {
993
- return Array.isArray(result.content)
994
- ? result.content.filter((entry: any) => entry?.type === "text").map((entry: any) => String(entry.text ?? "")).join("\n")
995
- : "";
996
- }
997
-
998
- function buildBatchmodeStatusLine(details: UnityToolDetails, theme: any): string {
999
- const status = details.status ?? "passed";
1000
- let line = `\n ${theme.fg("accent", `status=${status}`)}${theme.fg("muted", ` exit=${details.exitCode ?? 0}`)}`;
1001
- if (details.invocation?.testPlatform) {
1002
- line += ` ${theme.fg("muted", `platform=${details.invocation.testPlatform}`)}`;
1003
- }
1004
- return line;
1005
- }
1006
-
1007
- function buildBatchmodeResultsLine(details: UnityToolDetails, theme: any): string {
1008
- if (!details.parsedTestResults) {
1009
- return "";
1010
- }
1011
-
1012
- const parts = [
1013
- details.parsedTestResults.total !== undefined ? `total ${details.parsedTestResults.total}` : undefined,
1014
- details.parsedTestResults.passed !== undefined ? `passed ${details.parsedTestResults.passed}` : undefined,
1015
- details.parsedTestResults.failed !== undefined ? `failed ${details.parsedTestResults.failed}` : undefined,
1016
- ].filter(Boolean);
1017
-
1018
- return parts.length > 0 ? `\n ${theme.fg("muted", parts.join(" • "))}` : "";
1019
- }
1020
-
1021
939
  function throwIfAborted(signal?: AbortSignal): void {
1022
940
  if (signal?.aborted) {
1023
941
  throw new Error("Unity tool execution aborted.");
@@ -1231,7 +1149,7 @@ async function runUnifiedUnityTests(
1231
1149
  discoveryWarning: string | undefined,
1232
1150
  raw: UnityRunTestsRequest,
1233
1151
  signal: AbortSignal | undefined,
1234
- onUpdate?: (update: { content: Array<{ type: "text"; text: string }> }) => void,
1152
+ onUpdate?: AgentToolUpdateCallback<unknown>,
1235
1153
  allowAutonomousExitPlayMode = true,
1236
1154
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
1237
1155
  const request = normalizeUnityRunTestsRequest(raw);
@@ -1259,7 +1177,7 @@ async function runUnifiedUnityTests(
1259
1177
  if (route === "connected") {
1260
1178
  let result;
1261
1179
  try {
1262
- result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1180
+ result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }], details: undefined }) });
1263
1181
  } catch (error) {
1264
1182
  if (!(error instanceof UnityPipelineTerminalTestEvidenceError)) throw error;
1265
1183
  const outcome = error.evidence.outcome;
@@ -1332,94 +1250,6 @@ async function runUnifiedUnityTests(
1332
1250
  });
1333
1251
  }
1334
1252
 
1335
- function renderUnityPipelineResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: { lastComponent?: unknown }): Text {
1336
- const details = result.details as UnityToolDetails | undefined;
1337
- const primaryText = getToolTextContent(result);
1338
- if (options.isPartial) {
1339
- return reuseRendererText(context, `${theme.fg("warning", "…")} ${theme.fg("toolTitle", theme.bold("Unity Pipeline working"))}\n ${theme.fg("muted", compactUnityRendererValue(primaryText || "Waiting for Pipeline…", 180))}`);
1340
- }
1341
- if (!details) return reuseRendererText(context, primaryText || "(no output)");
1342
-
1343
- const pipeline = details.pipeline;
1344
- const icon = details.status === "passed" ? theme.fg("success", "✓") : theme.fg("error", "✗");
1345
- let text: string;
1346
- if (pipeline?.operation === "recompile") {
1347
- text = `${icon} ${theme.fg("toolTitle", theme.bold("Unity recompile"))} ${theme.fg("accent", pipeline.terminalState)}${theme.fg("muted", ` • ${pipeline.elapsedSeconds.toFixed(1)}s`)}`;
1348
- } else if (pipeline?.operation === "tests") {
1349
- const counts = pipeline.counts;
1350
- const passed = counts?.passed === undefined || counts?.total === undefined ? "tests completed" : `${counts.passed}/${counts.total} passed`;
1351
- text = `${icon} ${theme.fg("toolTitle", theme.bold(`Unity ${pipeline.testPlatform ?? ""} tests`.trim()))} ${theme.fg("accent", passed)}${theme.fg("muted", ` • ${pipeline.elapsedSeconds.toFixed(1)}s`)}`;
1352
- } else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection" || details.mode === "pipeline_run_script") {
1353
- const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.mode === "pipeline_run_script" ? details.pipelineRunScript : details.pipelineInspection;
1354
- const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : details.mode === "pipeline_run_script" ? "Unity Pipeline Run Script" : "Unity Pipeline Inspection";
1355
- const summary = output?.outcome === "dispatched" ? output.output || "(no bounded output returned)" : output?.message || primaryText;
1356
- text = `${icon} ${theme.fg("toolTitle", theme.bold(label))}\n ${theme.fg("toolOutput", compactUnityRendererValue(summary, 240))}`;
1357
- } else {
1358
- return renderUnityToolResult(result, options.expanded, theme);
1359
- }
1360
-
1361
- if (pipeline?.playModeHandling && pipeline.playModeHandling !== "not_playing") {
1362
- const handling = pipeline.playModeHandling === "agent_exited" ? "Play Mode exited by pi-unity" : `Play Mode: ${pipeline.playModeHandling.replace(/_/g, " ")}`;
1363
- text += `\n ${theme.fg("warning", handling)}`;
1364
- }
1365
- if (options.expanded && primaryText) text += `\n\n${theme.fg("toolOutput", primaryText)}`;
1366
- else if (!options.expanded) text += ` ${theme.fg("dim", `(${keyHint("app.tools.expand", "details")})`)}`;
1367
- return reuseRendererText(context, text);
1368
- }
1369
-
1370
- function renderUnityToolResult(result: any, expanded: boolean, theme: any): Text {
1371
- const details = result.details as UnityToolDetails | undefined;
1372
- const primaryText = getToolTextContent(result);
1373
-
1374
- if (!details) {
1375
- return new Text(primaryText || "(no output)", 0, 0);
1376
- }
1377
-
1378
- const icon = details.mode === "gui"
1379
- ? theme.fg("success", "◉")
1380
- : details.status === "passed"
1381
- ? theme.fg("success", "✓")
1382
- : details.status === "killed"
1383
- ? theme.fg("warning", "! ")
1384
- : theme.fg("error", "✗");
1385
- const title = details.mode === "gui"
1386
- ? "Unity Editor"
1387
- : details.mode === "status"
1388
- ? "Unity Project Status"
1389
- : details.mode === "artifacts"
1390
- ? "Unity Artifacts"
1391
- : details.mode === "pipeline_inspection"
1392
- ? "Unity Pipeline Inspection"
1393
- : details.mode === "pipeline_eval"
1394
- ? "Unity Pipeline Eval"
1395
- : details.mode === "pipeline_run_script"
1396
- ? "Unity Pipeline Run Script"
1397
- : details.mode === "pipeline"
1398
- ? "Unity Pipeline"
1399
- : getBatchmodeVariantLabel(details.args);
1400
- const projectLabel = details.projectRoot ?? "(unknown project)";
1401
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("muted", projectLabel)}`;
1402
- if (details.mode === "batchmode") {
1403
- text += buildBatchmodeStatusLine(details, theme);
1404
- text += buildBatchmodeResultsLine(details, theme);
1405
- } else if (details.mode === "status") {
1406
- text += `\n ${theme.fg("accent", `status=${details.status ?? "passed"}`)}`;
1407
- } else if (details.mode === "pipeline" && details.pipeline) {
1408
- text += `\n ${theme.fg("accent", `${details.pipeline.operation}=${details.pipeline.terminalState}`)}${theme.fg("muted", ` ${details.pipeline.elapsedSeconds.toFixed(1)}s`)}`;
1409
- }
1410
-
1411
- if (expanded && primaryText) {
1412
- text += `\n\n${theme.fg("toolOutput", primaryText)}`;
1413
- } else if (!expanded && details.mode === "batchmode") {
1414
- const snippet = summarizeTextForAgent(details.stderr) ?? summarizeTextForAgent(details.stdout);
1415
- if (snippet) {
1416
- text += `\n ${theme.fg("muted", snippet.split(/\r?\n/)[0])}`;
1417
- }
1418
- }
1419
-
1420
- return new Text(text, 0, 0);
1421
- }
1422
-
1423
1253
  function formatUnityGuidanceAudit(result: UnityGuidanceAuditResult): string {
1424
1254
  const lines = [
1425
1255
  `Unity guidance audit scanned ${result.summary.filesScanned} file(s): ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ${result.summary.infos} info finding(s).`,
@@ -1618,16 +1448,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1618
1448
  renderCall(args, theme) {
1619
1449
  return renderUnityToolCall("unity_guidance_audit", args, theme, "guidance", "read-only instruction audit");
1620
1450
  },
1621
- renderResult(result, { expanded }, theme) {
1622
- const details = result.details as UnityGuidanceAuditResult | undefined;
1623
- const primaryText = getToolTextContent(result);
1624
- if (!details) return new Text(primaryText || "(no output)", 0, 0);
1625
- const count = details.summary.errors + details.summary.warnings + details.summary.infos;
1626
- const ancestorCount = details.ancestorCandidates.length;
1627
- let text = `${count > 0 || ancestorCount > 0 ? theme.fg("warning", "!") : theme.fg("success", "✓")} ${theme.fg("toolTitle", theme.bold("Unity Guidance Audit"))}`;
1628
- text += `\n ${theme.fg("muted", `${details.summary.filesScanned} files • ${count} findings${ancestorCount > 0 ? ` • ${ancestorCount} ancestor files excluded` : ""}`)}`;
1629
- if (expanded && primaryText) text += `\n\n${theme.fg("toolOutput", primaryText)}`;
1630
- return new Text(text, 0, 0);
1451
+ renderResult(result, options, theme, context) {
1452
+ return renderUnityGuidanceResult(result, options, theme, context);
1631
1453
  },
1632
1454
  });
1633
1455
 
@@ -1656,8 +1478,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1656
1478
  renderCall(args, theme) {
1657
1479
  return renderUnityToolCall("unity_project_status", args, theme, "status", "inspects project lock");
1658
1480
  },
1659
- renderResult(result, { expanded }, theme) {
1660
- return renderUnityToolResult(result, expanded, theme);
1481
+ renderResult(result, { expanded, isPartial }, theme, context) {
1482
+ return renderUnityToolResult(result, expanded, theme, context, isPartial);
1661
1483
  },
1662
1484
  });
1663
1485
 
@@ -1679,8 +1501,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1679
1501
  const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
1680
1502
  return await runUnifiedUnityTests(pi, ctx, candidate, discoveryWarning, params as UnityRunTestsRequest, signal, onUpdate, sessionAllowsAutonomousPlayModeExit(ctx));
1681
1503
  },
1682
- renderCall(args, theme, context) { return renderUnityToolCall("unity_run_tests", args, theme, "tests", `${args.testPlatform ?? "Unity"} • ${compactUnityRendererValue(args.testFilters?.[0] ?? args.testFilter ?? args.execution ?? "auto", 100)}`, context); },
1683
- renderResult(result, { expanded }, theme) { return renderUnityToolResult(result, expanded, theme); },
1504
+ renderCall(args, theme, context) { return renderUnityToolCall("unity_run_tests", args, theme, "tests", undefined, context); },
1505
+ renderResult(result, { expanded, isPartial }, theme, context) { return renderUnityToolResult(result, expanded, theme, context, isPartial); },
1684
1506
  });
1685
1507
 
1686
1508
  pi.registerTool({
@@ -1699,7 +1521,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1699
1521
  const { candidate } = await resolveProjectCandidate(ctx, params.path);
1700
1522
  const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1701
1523
  signal,
1702
- onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1524
+ onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }], details: undefined }),
1703
1525
  });
1704
1526
  return {
1705
1527
  content: [{ type: "text", text: result.text }],
@@ -1869,8 +1691,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1869
1691
  renderCall(args, theme) {
1870
1692
  return renderUnityToolCall("unity_inspect_artifacts", args, theme, "artifacts", "reads logs/results");
1871
1693
  },
1872
- renderResult(result, { expanded }, theme) {
1873
- return renderUnityToolResult(result, expanded, theme);
1694
+ renderResult(result, { expanded, isPartial }, theme, context) {
1695
+ return renderUnityToolResult(result, expanded, theme, context, isPartial);
1874
1696
  },
1875
1697
  });
1876
1698
 
@@ -1928,8 +1750,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1928
1750
  renderCall(args, theme) {
1929
1751
  return renderUnityToolCall("unity_open_editor", args, theme, "gui", "opens editor window");
1930
1752
  },
1931
- renderResult(result, { expanded }, theme) {
1932
- return renderUnityToolResult(result, expanded, theme);
1753
+ renderResult(result, { expanded, isPartial }, theme, context) {
1754
+ return renderUnityToolResult(result, expanded, theme, context, isPartial);
1933
1755
  },
1934
1756
  });
1935
1757
 
@@ -1967,8 +1789,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1967
1789
  const displayArgs = args.useGraphics ? args.args : ["-nographics", ...(args.args ?? [])];
1968
1790
  return renderUnityToolCall("unity_launch_batchmode", args, theme, "batchmode", getBatchmodeVariantLabel(displayArgs));
1969
1791
  },
1970
- renderResult(result, { expanded }, theme) {
1971
- return renderUnityToolResult(result, expanded, theme);
1792
+ renderResult(result, { expanded, isPartial }, theme, context) {
1793
+ return renderUnityToolResult(result, expanded, theme, context, isPartial);
1972
1794
  },
1973
1795
  });
1974
1796
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -18,7 +18,10 @@
18
18
  ]
19
19
  },
20
20
  "scripts": {
21
- "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/unity-package-validation.test.ts",
21
+ "typecheck": "tsc -p tsconfig.json",
22
+ "pretest": "npm run typecheck",
23
+ "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-renderers.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/unity-package-validation.test.ts",
24
+ "preview:tools": "tsx tests/unity-renderers.test.ts --preview",
22
25
  "eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
23
26
  },
24
27
  "dependencies": {
@@ -50,10 +53,12 @@
50
53
  "devDependencies": {
51
54
  "@aefree/pi-file-discovery": "^0.1.0",
52
55
  "@aefree/pi-project-artifacts": "^0.1.0",
53
- "@earendil-works/pi-ai": "0.85.1",
54
- "@earendil-works/pi-coding-agent": "0.85.1",
55
- "@earendil-works/pi-tui": "0.85.1",
56
- "tsx": "^4.23.5"
56
+ "@earendil-works/pi-ai": "0.86.1",
57
+ "@earendil-works/pi-coding-agent": "0.86.1",
58
+ "@earendil-works/pi-tui": "0.86.1",
59
+ "@types/node": "22.20.2",
60
+ "tsx": "^4.23.5",
61
+ "typescript": "7.0.2"
57
62
  },
58
63
  "peerDependenciesMeta": {
59
64
  "@earendil-works/pi-ai": {
@@ -24,7 +24,7 @@ const OWNER = Object.freeze({
24
24
  * metadata remains project-owned and schema-open.
25
25
  */
26
26
  export function createUnityArtifactProfileV1(): ArtifactProfileV1 {
27
- return Object.freeze({
27
+ return Object.freeze<ArtifactProfileV1>({
28
28
  contractVersion: 1,
29
29
  id: UNITY_ARTIFACT_PROFILE_ID_V1,
30
30
  kind: "artifact-profile",
package/src/unity-cli.ts CHANGED
@@ -81,7 +81,7 @@ export type UnityCliProjectCapabilities = {
81
81
  export type UnityCliExecResult = {
82
82
  stdout: string;
83
83
  stderr: string;
84
- error?: Error & { code?: string | number; signal?: string | null };
84
+ error?: Error & { code?: string | number | null; signal?: string | null; killed?: boolean };
85
85
  };
86
86
 
87
87
  /** Injectable seam for deterministic capability and planning-dispatch tests. */
@@ -209,9 +209,9 @@ const execFileCollect: UnityCliExecutor = (command, args, options = {}) => {
209
209
  return new Promise((resolve) => {
210
210
  execFile(command, args, { timeout: options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS, signal: options.signal, windowsHide: true }, (error, stdout, stderr) => {
211
211
  resolve({
212
- stdout: typeof stdout === "string" ? stdout : stdout.toString(),
213
- stderr: typeof stderr === "string" ? stderr : stderr.toString(),
214
- error: error as UnityCliExecResult["error"],
212
+ stdout,
213
+ stderr,
214
+ error: error ?? undefined,
215
215
  });
216
216
  });
217
217
  });
@@ -488,7 +488,7 @@ async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<
488
488
  }
489
489
 
490
490
  export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
491
- const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
491
+ const error = result.error;
492
492
  return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
493
493
  }
494
494
 
@@ -14,7 +14,7 @@ export const UNITY_EXACT_GENERATED_ROOT_BYPASSED_CODE = "unity_exact_generated_r
14
14
  export const UNITY_GENERATED_DIRECTORIES = Object.freeze(["Library", "Temp", "Logs", "obj", "Build", "Builds", "UserSettings", ".vs"] as const);
15
15
 
16
16
  export function createUnityFileDiscoveryFilterV1(): FileDiscoveryFilterV1 {
17
- return Object.freeze({
17
+ return Object.freeze<FileDiscoveryFilterV1>({
18
18
  contractVersion: 1,
19
19
  id: UNITY_FILE_DISCOVERY_FILTER_ID_V1,
20
20
  kind: "file-discovery-filter",
@@ -1,3 +1,4 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
@@ -135,7 +136,7 @@ export async function discoverUnityProjects(
135
136
  continue;
136
137
  }
137
138
 
138
- let entries: fs.Dirent[] = [];
139
+ let entries: Dirent[] = [];
139
140
  try {
140
141
  entries = await fs.readdir(next.dir, { withFileTypes: true });
141
142
  } catch {
@@ -0,0 +1,174 @@
1
+ import { highlightCode, keyHint, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { UnityToolDetails } from "../index";
4
+ import type { UnityGuidanceAuditResult } from "./unity-guidance-audit";
5
+
6
+ type Args = { path?: string; args?: unknown[]; code?: string; command?: string; file?: string; entry?: string; dryRun?: boolean; testPlatform?: string; testFilter?: string; testFilters?: string[]; testCategories?: string[]; execution?: string };
7
+ type Context = { lastComponent?: unknown; args?: Args; isError?: boolean; expanded?: boolean };
8
+ type Result = { content?: Array<{ type: string; text?: string }>; details?: unknown };
9
+ type Options = { expanded: boolean; isPartial?: boolean };
10
+
11
+ function redact(value: string): string {
12
+ return value.replace(/\b(token|secret|password|api[_-]?key)\s*([:=])\s*((?:\$@?|@\$?)?"(?:""|\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;)}\]]+)/gi, "$1$2[redacted]")
13
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
14
+ }
15
+ export function compactUnityRendererValue(value: unknown, limit = 160): string {
16
+ const text = redact(String(value ?? "")).replace(/\s+/g, " ").trim();
17
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
18
+ }
19
+ function reuse(context: Context | undefined, value: string): Text {
20
+ const text = context?.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
21
+ text.setText(value);
22
+ return text;
23
+ }
24
+ function projectName(path?: string): string {
25
+ return compactUnityRendererValue(path?.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "auto-resolve", 70);
26
+ }
27
+ const titles: Record<string, string> = {
28
+ unity_project_status: "Project status", unity_run_tests: "Tests", unity_pipeline_run_tests: "Tests",
29
+ unity_pipeline_recompile: "Recompile", unity_pipeline_eval: "Eval", unity_pipeline_inspect: "Inspect",
30
+ unity_pipeline_run_script: "Run script", unity_inspect_artifacts: "Artifacts", unity_open_editor: "Open Editor",
31
+ unity_launch_batchmode: "Batchmode", unity_guidance_audit: "Guidance audit",
32
+ };
33
+ export function renderUnityToolCall(name: string, args: Args, theme: Pick<Theme, "fg" | "bold">, _mode?: string, _emphasis?: string, context?: Context): Text {
34
+ const title = args.testPlatform ? `${args.testPlatform} tests` : titles[name] ?? name;
35
+ let text = theme.fg("toolTitle", theme.bold(`Unity · ${title}`)) + theme.fg("dim", ` ${projectName(args.path)}`);
36
+ let subtitle = "";
37
+ if (args.code !== undefined) subtitle = compactUnityRendererValue(args.code, 140);
38
+ else if (args.file) subtitle = `${compactUnityRendererValue(args.file, 120)}${args.entry ? ` · ${compactUnityRendererValue(args.entry, 60)}` : ""}${args.dryRun ? " · compile only" : ""}`;
39
+ else if (args.command) subtitle = compactUnityRendererValue(args.command.replace(/_/g, " "), 100);
40
+ else if (args.testPlatform) {
41
+ subtitle = [...(args.testFilters ?? (args.testFilter ? [args.testFilter] : [])), ...(args.testCategories ?? []).map(value => `category: ${value}`)].map(value => compactUnityRendererValue(value, 100)).join(" · ") || "All tests";
42
+ subtitle = compactUnityRendererValue(subtitle, 180);
43
+ } else if (name === "unity_launch_batchmode") subtitle = _emphasis ?? "";
44
+ if (subtitle && !(args.code && context?.expanded)) text += `\n${theme.fg("muted", subtitle)}`;
45
+ return reuse(context, text);
46
+ }
47
+ export function renderUnityPipelineCall(name: string, args: Args, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
48
+ return renderUnityToolCall(name, args, theme, undefined, undefined, context);
49
+ }
50
+ function content(result: Result): string {
51
+ return redact((result.content ?? []).filter(entry => entry.type === "text").map(entry => entry.text ?? "").join("\n"));
52
+ }
53
+ function counts(summary?: { passed?: number; failed?: number; skipped?: number }): string {
54
+ return summary ? (["passed", "failed", "skipped"] as const).filter(key => summary[key] !== undefined).map(key => `${summary[key]} ${key}`).join(" · ") : "";
55
+ }
56
+ function prettyOutput(output: string): string {
57
+ const safe = redact(output);
58
+ try { JSON.parse(safe); }
59
+ catch (error) { if (error instanceof SyntaxError) return safe; throw error; }
60
+
61
+ // Validate with JSON.parse, but format tokens directly so JSON number and string lexemes stay exact.
62
+ let formatted = "";
63
+ let depth = 0;
64
+ const indent = () => " ".repeat(depth);
65
+ for (let index = 0; index < safe.length; index++) {
66
+ const character = safe[index];
67
+ if (/\s/.test(character)) continue;
68
+ if (character === '"') {
69
+ const start = index++;
70
+ while (index < safe.length) {
71
+ if (safe[index] === "\\") index++;
72
+ else if (safe[index] === '"') break;
73
+ index++;
74
+ }
75
+ formatted += safe.slice(start, index + 1);
76
+ } else if (character === "{" || character === "[") {
77
+ let next = index + 1;
78
+ while (/\s/.test(safe[next] ?? "")) next++;
79
+ if (safe[next] === (character === "{" ? "}" : "]")) {
80
+ formatted += character + safe[next];
81
+ index = next;
82
+ } else {
83
+ formatted += `${character}\n`;
84
+ depth++;
85
+ formatted += indent();
86
+ }
87
+ } else if (character === "}" || character === "]") {
88
+ depth--;
89
+ formatted += `\n${indent()}${character}`;
90
+ } else if (character === ",") formatted += `,\n${indent()}`;
91
+ else if (character === ":") formatted += ": ";
92
+ else formatted += character;
93
+ }
94
+ return formatted;
95
+ }
96
+ export function renderUnityPipelineResult(result: Result, options: Options, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
97
+ return renderUnityToolResult(result, options.expanded, theme, context, options.isPartial);
98
+ }
99
+ export function renderUnityToolResult(result: Result, expanded: boolean, theme: Pick<Theme, "fg" | "bold">, context?: Context, isPartial = false): Text {
100
+ const primary = content(result);
101
+ const details = result.details as UnityToolDetails | undefined;
102
+ if (isPartial) return reuse(context, theme.fg("warning", `… ${compactUnityRendererValue(primary || "Waiting for Unity…", 200)}`));
103
+ if (!details?.mode) {
104
+ const text = expanded ? primary : compactUnityRendererValue(primary || "No output", 280);
105
+ return reuse(context, theme.fg(context?.isError ? "error" : "toolOutput", text) + (!expanded && primary ? `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}` : ""));
106
+ }
107
+ const pipeline = details.pipeline;
108
+ const tests = details.testResult;
109
+ const output = details.pipelineEval ?? details.pipelineInspection ?? details.pipelineRunScript;
110
+ const outcome = tests?.outcome ?? details.testOutcome;
111
+ const uncertain = outcome && ["uncertain", "empty_selection", "timed_out", "cancelled", "passed_with_flakes"].includes(outcome);
112
+ const failed = context?.isError || details.status === "failed" || outcome === "tests_failed" || outcome === "run_error" || output?.outcome === "rejected";
113
+ const tone = uncertain || details.status === "killed" || details.projectState?.processVerificationIncomplete || details.projectState?.staleLockSuspected ? "warning" : failed ? "error" : details.status === "passed" || details.mode === "gui" ? "success" : "warning";
114
+ let summary: string = details.status ?? "Completed";
115
+ if (details.mode === "status") {
116
+ const capabilities = details.cliCapabilities;
117
+ const reachable = capabilities?.matchingInstances.some(instance => instance.reachable === true);
118
+ const state = details.projectState;
119
+ summary = reachable ? "Editor open · Pipeline reachable"
120
+ : capabilities?.matchingInstances.length || state?.runningProcessCount ? "Editor detected · Pipeline reachability unconfirmed"
121
+ : state?.processVerificationIncomplete ? "Process state uncertain"
122
+ : state?.staleLockSuspected ? "No Editor detected · lock may be stale"
123
+ : state ? `No Editor detected · lock ${state.nativeLockfileExists ? "present" : "absent"}`
124
+ : "Project inspected · expand for process and lock state";
125
+ if (details.unityVersion) summary += ` · Unity ${details.unityVersion}`;
126
+ } else if (tests || details.mode === "artifacts") {
127
+ summary = `${details.mode === "artifacts" ? `Inspection ${details.status ?? "unknown"} · Tests: ` : ""}${(outcome ?? "not established").replace(/_/g, " ")}`;
128
+ const testCounts = counts(tests?.summary ?? details.normalizedResult?.summary ?? details.parsedTestResults ?? undefined);
129
+ if (testCounts) summary = details.mode === "tests" && outcome === "passed" ? testCounts : `${summary} · ${testCounts}`;
130
+ if (tests?.durationSeconds !== undefined) summary += ` · ${tests.durationSeconds.toFixed(1)}s`;
131
+ if (details.route) summary += ` · ${details.route}`;
132
+ } else if (pipeline) {
133
+ summary = `${pipeline.operation === "recompile" ? "Recompile" : "Tests"} ${pipeline.terminalState} · ${pipeline.elapsedSeconds.toFixed(1)}s`;
134
+ const testCounts = counts(pipeline.counts);
135
+ if (testCounts) summary += ` · ${testCounts}`;
136
+ } else if (output) {
137
+ summary = output.outcome === "rejected" ? compactUnityRendererValue(output.message, 240) : expanded ? "Completed" : compactUnityRendererValue(output.output || "No output returned", 240);
138
+ if (output.outcome === "dispatched" && output.truncated && !expanded) summary += " · output truncated";
139
+ }
140
+ else if (details.mode === "gui") summary = `Editor launched${details.pid ? ` · PID ${details.pid}` : ""}`;
141
+ else if (details.mode === "batchmode") summary = `${details.status ?? "unknown"} · exit ${details.exitCode ?? "unknown"}${counts(details.parsedTestResults ?? undefined) ? ` · ${counts(details.parsedTestResults ?? undefined)}` : ""}`;
142
+ let text = theme.fg(tone, `${tone === "success" ? "✓" : tone === "error" ? "✗" : "!"} ${summary}`);
143
+ const notices = [details.warning, ...(pipeline?.warnings ?? []), ...(details.evidenceWarnings ?? []), ...(tests?.diagnostics ?? [])].filter((value): value is string => Boolean(value));
144
+ if (pipeline?.playModeHandling && pipeline.playModeHandling !== "not_playing") notices.push(pipeline.playModeHandling === "agent_exited" ? "Play Mode exited by pi-unity" : `Play Mode: ${pipeline.playModeHandling.replace(/_/g, " ")}`);
145
+ if (notices.length) text += `\n${theme.fg("warning", compactUnityRendererValue(notices.join(" · "), expanded ? 1000 : 240))}`;
146
+ const nonPassing = tests?.tests?.filter(test => !["passed", "success"].includes(test.status.toLowerCase())) ?? [];
147
+ const failures = nonPassing.filter(test => ["failed", "error"].includes(test.status.toLowerCase()));
148
+ const diagnostics = nonPassing.filter(test => !["failed", "error"].includes(test.status.toLowerCase()));
149
+ for (const test of [...failures, ...diagnostics].slice(0, expanded ? 8 : 1)) text += `\n${theme.fg("warning", compactUnityRendererValue(`${test.name}: ${test.message || test.status}`, expanded ? 1000 : 200))}`;
150
+ if (expanded) {
151
+ const section = (title: string, body: string) => { if (body) text += `\n\n${theme.fg("toolTitle", theme.bold(title))}\n${body}`; };
152
+ section("Project", theme.fg("muted", redact(details.projectRoot || context?.args?.path || "Unknown project")));
153
+ if (context?.args?.code) section("C#", highlightCode(redact(context.args.code), "csharp").join("\n"));
154
+ if (context?.args?.file) section("Script", theme.fg("toolOutput", redact(`${context.args.file}${context.args.entry ? `\nEntry: ${context.args.entry}` : ""}${context.args.dryRun ? "\nCompile only" : ""}`)));
155
+ if (output?.outcome === "dispatched") section(`Result${output.truncated ? " (truncated)" : ""}`, theme.fg("toolOutput", prettyOutput(output.output)));
156
+ section("Evidence", theme.fg("toolOutput", primary));
157
+ const paths = [...new Set([details.artifactPath, details.normalizedResultPath, details.artifacts?.testResultsPath, details.artifacts?.logFilePath, ...Object.values(tests?.backendArtifacts ?? {})].filter((value): value is string => Boolean(value)))];
158
+ section("Artifacts", theme.fg("muted", redact(paths.join("\n"))));
159
+ } else text += `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}`;
160
+ return reuse(context, text);
161
+ }
162
+
163
+ export function renderUnityGuidanceResult(result: Result, options: Options, theme: Pick<Theme, "fg" | "bold">, context: Context): Text {
164
+ const details = result.details as UnityGuidanceAuditResult | undefined;
165
+ if (!details?.summary) return renderUnityToolResult(result, options.expanded, theme, context, options.isPartial);
166
+ const { filesScanned, errors, warnings, infos } = details.summary;
167
+ const ancestors = details.ancestorCandidates.length;
168
+ const tone = errors ? "error" : warnings || ancestors ? "warning" : "success";
169
+ let text = theme.fg(tone, `${tone === "success" ? "✓" : "!"} ${filesScanned} files · ${errors} errors · ${warnings} warnings · ${infos} info`);
170
+ if (ancestors) text += `\n${theme.fg("warning", `${ancestors} ancestor files excluded`)}`;
171
+ if (options.expanded) text += `\n\n${theme.fg("toolTitle", theme.bold("Findings"))}\n${theme.fg("toolOutput", content(result))}`;
172
+ else text += `\n${theme.fg("dim", keyHint("app.tools.expand", "details"))}`;
173
+ return reuse(context, text);
174
+ }