Author SHA1 Message Date
sanspie 2f3b06af54 docs: exercise camera outputs and box collider Try 2026-11-07 13:05:26 +03:00
sanspie 788dae1bf7 docs: add camera and rigid body Try contracts 2026-11-07 12:22:07 +03:00
sanspie 3812145c74 test: run pinned native and browser art parity 2026-11-07 11:28:49 +03:00
sanspie bfc69d5598 docs: add executable VirtualCamera2D Try contract 2026-11-07 11:28:28 +03:00
sanspie a946785c7b docs: link component references to Studio lessons 2026-11-07 11:28:13 +03:00
sanspie 2104d1d54c test: preserve screenshot markers in native composer workflow 2026-11-07 11:27:52 +03:00
sanspie 6c1d19704e fix: satisfy release HTTP lint checks 2026-11-07 10:38:41 +03:00
sanspie 579751f54a Integrate pinned Asset Studio web release 2026-11-07 10:36:17 +03:00
sanspie 87e3181a2a fix: align art source frames and add workspace goldens 2026-11-07 09:25:36 +03:00
sanspie 199d39b347 docs: follow Go error message conventions 2026-11-07 08:26:54 +03:00
sanspie 43cbb6bb7f art: require consent before lossy web imports
Do not open an Aseprite document with omitted metadata until the browser user reviews and accepts diagnostics; exercise cancel and confirmation through the real UI.
2026-11-07 08:16:51 +03:00
sanspie 8cfb81d071 docs: verify PostProcess output Try
Exercise the real prepared Bloom scene, resolve its dependencies and check runtime and image Reset in native and Chromium.
2026-11-07 08:10:24 +03:00
sanspie 2687450c2d docs: publish Art Studio as a lazy site entry
Build the existing Go/WASM studio with version-bound browser resources and link it from English and Russian site navigation without loading it on docs pages.
2026-11-07 08:10:07 +03:00
sanspie 796fe5b52f scene: detach nested prefab override values
Clone nested lists, structs and curves in orphan properties and added components so repeated snapshot restores cannot alias live edits.
2026-11-07 08:09:44 +03:00
sanspie 4924bb12e8 docs: bind shared Try contracts only to components
Allow guide pages to reuse the same scene without claiming its behavioral sidecar; continue rejecting wrong or duplicate component contracts.
2026-11-07 08:03:41 +03:00
sanspie 54c1af7edf scene: clone frequent snapshot components directly
Detach mutable prefab, particle, occluder and edge data with typed copies; test repeated restore, snapshot reuse and mutable-field coverage.
2026-11-07 07:25:51 +03:00
sanspie 113892ab67 vm: skip slice traversal for string-only snapshots
Track live slice objects through allocation, collection and restore, while keeping independent heap snapshots and the existing slice-capacity semantics.
2026-11-07 07:22:55 +03:00
sanspie 5e55c2dbb9 docs: verify particle emission Try
Use a prewarmed emitter and observe the real live-particle count and Game View before and after Reset on native and Chromium.
2026-11-07 07:20:51 +03:00
sanspie c64c199e70 art: preserve transparent surface channel data
Keep authored normal, height and emission RGB when the composited pixel is transparent, without covering visible pixels. Check source round trips, partial updates, undo and prepared runner artifacts.
2026-11-07 07:06:12 +03:00
sanspie aa1da698f4 docs: verify MeshRenderer double-sided Try
Exercise back-face culling and Reset in native and Chromium without relaxing the full component inventory gate.
2026-11-07 07:02:04 +03:00
sanspie 25a731f6c4 docs: verify light and ambient Try contracts
Use deterministic light scenes and assert real visual changes and Reset across native and Chromium; overall component coverage remains incomplete.
2026-11-07 06:16:08 +03:00
sanspie 5a2745987f feat(art): import Aseprite through native editor
Create guarded editable sources with existing prepared frame and clip subassets; require confirmation for reported metadata loss and preserve the original file.
2026-11-07 06:07:41 +03:00
sanspie 8a364bee5e test(art): validate real Aseprite files against LibreSprite
Pin MIT-licensed upstream fixtures and independent reference frames; verify linked cels, undo, metadata round-trip, and explicit unsupported-data diagnostics.
2026-11-07 05:43:47 +03:00
sanspie 3d2cf44608 test: require actual null-DACL rejection on Windows
Protect the fixture from inherited ACEs and assert the rejection reason; fix the editor-tagged staticcheck error.
2026-11-07 05:34:15 +03:00
sanspie eb92b49531 test: create Windows null-DACL fixture without elevation
Keep the fail-closed assertion and fix Go vet's keyed color literals.
2026-11-07 05:30:38 +03:00
sanspie 09d10625c3 fix(examples): recognize Windows system volume owner
Trust only the exact Windows TrustedInstaller SID and add an ancestor DELETE_CHILD rejection test; native validation remains in progress.
2026-11-07 05:27:16 +03:00
sanspie 05aa019738 WIP: validate Windows remote store ACLs and reparse points
Fail closed for untrusted writes, null or unknown ACLs, and recheck managed trees. Native Windows validation is still required.
2026-11-07 05:23:49 +03:00
sanspie d965aeb886 fix: portable Stage paths and deduplicate Try sidecars
Allow only macOS system temp aliases while rejecting nested links, and preserve duplicate-contract detection across distinct Try sources.
2026-11-07 05:17:54 +03:00
sanspie 32f65c3ddc feat(examples): open checked remote copies in the launcher
Confirm the HTTPS origin, preserve entry scenes and offline edits, and disable external import tools in managed remote projects. Native Windows store support and full remote-example acceptance remain incomplete.
2026-11-07 05:06:50 +03:00
sanspie 8fd0483b42 art: add native ellipse and lasso selections 2026-11-07 04:36:18 +03:00
sanspie 029f6d7cb4 docs: add executable light occluder contract 2026-11-07 04:35:48 +03:00
sanspie 07a6fa1e55 art: add native eyedropper and preserve chosen color bytes 2026-11-07 04:23:45 +03:00
sanspie 6b52dd790b docs: run component contracts in native and browser runtimes
Verify real Game View changes and reset in both languages; add Camera2D and Camera3D demos. The strict gate remains intentionally failing until all public components are covered.
2026-11-07 04:22:46 +03:00
sanspie 3c015e9266 WIP: integrate Stage 12.5 art authoring and examples
Partial implementation; native studio, cross-platform examples, Try coverage, and acceptance remain incomplete.
2026-11-07 04:01:11 +03:00
sanspie 107b09e506 tests: fix golden references for the Design workspace
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m50s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m56s
ci / release (push) Successful in 7m22s
Keep the material inspector fixture full height and refresh the reviewed Russian palette reference for current icons and layout.
2026-11-05 22:53:42 +03:00
sanspie 1f47880e8a docs: update editor guidance for 0.12.1
Document the default Design layout, agent mentions and follow controls, curve preset editing, and Scene View lighting toggles in English and Russian.
2026-11-05 22:41:19 +03:00
sanspie 4920c2a11c release: prepare 0.12.1 and fix CI checks
Prepare the stable editor update and fix CI checks for module icons, diagnostics, updater retries and integration snapshots.
2026-11-05 22:28:27 +03:00
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00
sanspie a6822156cd Finish Stage 12 editor fixes and refreshed platformer
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
2026-11-05 17:36:42 +03:00
sanspie 5eb1832907 Complete Stage 12 for v0.12.0
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
2026-11-05 11:46:53 +03:00
sanspie ad23798da5 docs: the project description says what Aego is: a 2D game engine built in Go
ci / checks (push) Successful in 7m58s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m1s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m21s
2026-10-28 02:55:13 +03:00
sanspie 132ddeff70 Stage 12a1: shader ABI, shader import and material runtime (D367, D368, D380, D381, D393)
ci / checks (push) Successful in 8m39s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m54s
Shader ABI 1: ub0 frame (16 B), ub1 view (112 B), ub2 material, ub3 mesh
draw; tex0 main, tex1 reserved, tex2-tex7 user; @instance lanes in
vertex slot 1 at locations 4-7, 64 bytes a sprite. The engine sprite,
sprite3d, debug and mesh shaders move to it, and engine includes
sprite, color, noise and lighting ship under engine/res/shaders/include.

.aeshader files import into an AESH artifact with every variant in both
GLSL dialects and a line map; errors are SHD001-SHD009 mapped back to the
source file and line, also for driver compile logs. A failed reimport
keeps the last good shader. .aemat materials (MAT001-MAT004) reference a
shader and textures and are reimported when their shader changes. The
material cache in render/material binds a material with no allocations,
retires replaced shaders, moves materials to a reimported shader in
place (D393) and re-uploads every material after a WebGL context
restore. make ci requires glslangValidator (D367).

web-bench, baseline retaken on nodea in both GPU modes, the same numbers
in swiftshader and hardware, Chromium and Firefox:
- the frame UBO is uploaded once a frame: buffer uploads 2 -> 3 a frame;
- the view UBO is 112 bytes instead of 64: buffer KB 0.372 -> 0.434
  a frame (+16 B frame, +48 B view);
- fewer SetUniformBuffer calls a batch: the sprite pass no longer binds
  the view per batch, frame and view are bound once per view; the GL
  state cache already deduped that bind, so WebGL calls go 50.49 ->
  52.49 a frame with the two new frame-UBO calls, draws 3.31 unchanged;
- buffer KB from lanes is unchanged: the demo has no @instance material.
Frame time and Go bytes in the engine frame (357 -> 342) do not regress.

wasm size, gzip: playground 6.46 -> 6.50 MB, try 6.98 -> 7.01 MB, web
runtime 6.05 -> 6.07 MB (total 27.27 -> 27.46, 29.54 -> 29.67,
25.56 -> 25.65 MB).
2026-10-28 01:23:11 +03:00
sanspie c8d45b6ff5 docs: the Animator demo jumps and hits; the web demo caption names J to attack
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m59s
ci / release (push) Successful in 9m16s
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 8m18s
ci-platforms / darwin amd64 smoke (push) Skipped
The built-in platformer controller plays engine/anim/jump in Jump and engine/anim/hit in Hit; before, both states had no clip and nothing showed.
The Animator try scene jumps with Space (Grounded false in the air) instead of toggling the parameter.
2026-10-27 23:09:31 +03:00
sanspie 962c479933 web: the browser build drops the self-update core (D362 amended)
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 10m24s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m13s
ci / release (push) Successful in 8m57s
modules/update links aego/update (net/http, TLS, x509, archives) only off js; the web build gets a browser backend with the same section, settings and aego/update API, where Check and Apply fail and State stays idle. A test compares both builds' registrations.
depcheck: wasm-runtime and wasm-try variants, update in wasmDenied, wasmDeniedStd (net/http, crypto/tls, crypto/x509, archive/*) with the wasm-reaches-std rule.
wasmsize -prev: playground-size and try-size print the change since their last run.
Loaders show the compressed download size: wasmGzip in an export's files.json, aego-*-gzip and data-wasm-gzip on the site; docstool writes the .wasm.gz copies.
Demo game.wasm 33.9 -> 26.0 MB, gzip 8.07 -> 6.25 MB.
2026-10-27 20:06:16 +03:00
sanspie 72d9a21782 Stage 12w: web runtime performance (D365, D366, D390, D391)
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m38s
ci / release (push) Successful in 9m25s
make web-bench measures the exported platformer in Chromium and Firefox
in lockstep, with exact WebGL counters against a per-mode baseline, time
compared with Mann-Whitney and a profile pass that gates allocations in
the engine frame to zero, except listed first-decode and growth sites.

On the web the frame loop fills the audio output, the stream feeder
waits for ring space, per-event allocations are gone and Go allocations
per frame drop from 7.2 to 3.8 in Chromium. The pixel ratio is capped at
1.5 by default (display.maxPixelRatio) and Frame Limit works on the web.
The game loop stays on the main thread; a Worker is not built (D391).
2026-10-27 16:29:41 +03:00
sanspie cc2a428a7d engine: version 0.12.0-dev until the 0.12.0 release
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m13s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m21s
ci / release (push) Successful in 9m18s
2026-10-26 22:33:39 +03:00
1794 changed files with 142404 additions and 4346 deletions
+3
View File
@@ -0,0 +1,3 @@
# Claude configuration
This directory contains project-specific guidance and local configuration for Claude Code. The guidance records repository hygiene and verification expectations for agents working on Aego.
+3
View File
@@ -0,0 +1,3 @@
# CI workflows
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
+3 -2
View File
@@ -9,8 +9,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -41,7 +42,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
+5 -4
View File
@@ -10,8 +10,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -26,7 +27,7 @@ jobs:
with:
go-version-file: go.mod
- name: system packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
@@ -61,7 +62,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
@@ -137,7 +138,7 @@ jobs:
with:
go-version-file: go.mod
- name: linux packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
+1
View File
@@ -17,6 +17,7 @@ tools/genicons/genicons
/tests/golden/testdata/actual-*
tools/websmoke/node_modules/
/tools/websmoke/__pycache__/
tools/websmoke/golden/actual-*.png
# build output belongs in .cache/bin; these catch stray builds in the root
+108 -8
View File
File diff suppressed because it is too large Load Diff
+178 -19
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,8 +1,8 @@
# Aego
# Aego — A 2D game engine built in Go.
Make 2D games in Go. One editor, one project, desktop and web.
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
- Documentation and the playground: <https://aego.akarpov.ru>
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
+3
View File
@@ -0,0 +1,3 @@
# Animation
`anim` provides the shared animation kernel for clips, keyframe tracks, events, and controller graphs. It samples tracks, evaluates state transitions and blending, and encodes clips and controllers in text and binary forms for use by the engine and editor.
+3
View File
@@ -0,0 +1,3 @@
# app
The `app` packages assemble the engine, modules, asset pipeline, and scripting workspace into development hosts. They coordinate workflows such as project creation, editing, running, and exporting outside the engine core.
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"fmt"
"strconv"
"strings"
"aego/art"
)
// ArtHost is supplied only by the running editor. Offline commands must not
// reopen files and silently replace the user's dirty graphical documents.
// Like Host.ApplyScene, these calls run on the editor mutation thread.
type ArtHost interface {
ReadArt(file, section string, offset, limit int) (*Output, error)
CaptureArt(file string, frame art.ID, channel art.Channel) (*Output, error)
PrepareArt(file string, revision uint64, label string, ops []art.Operation) (*Output, error)
}
// ArtProposal is an opaque, prepared transaction carried by the existing agent
// proposal queue. Commit is invoked only after its normal approval decision.
// Keeping the prepared transaction also preserves IDs returned by its preview.
type ArtProposal interface {
Commit() (*Output, error)
RetainedBytes() int64
}
func artHost(s *Session) (ArtHost, error) {
if s != nil {
if h, ok := s.host.(ArtHost); ok {
return h, nil
}
}
return nil, fmt.Errorf("art: connect to the running Aego editor; offline writes to graphical documents are disabled")
}
func artCommands() []*Command {
fileArg := Arg{Name: "file", Type: "string", Doc: "Project-relative .aeart source, including unsaved editor content", Required: true, Pos: true}
return []*Command{
{Name: "art.read", Doc: "Read the current art document, not its last saved file. Lists are paginated; revision is a decimal string for art.apply.", Args: []Arg{
fileArg, {Name: "section", Type: "string", Doc: "summary, layers, frames, cels, palette, tags or slices"},
{Name: "offset", Type: "integer", Doc: "First item, default 0"}, {Name: "limit", Type: "integer", Doc: "1..1024 items; default 256"},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
offset, err := a.Int("offset", 0)
if err != nil {
return nil, err
}
limit, err := a.Int("limit", 256)
if err != nil {
return nil, err
}
if offset < 0 || limit < 1 || limit > 1024 {
return nil, fmt.Errorf("art: invalid page")
}
return h.ReadArt(a.Str("file"), a.Str("section"), offset, limit)
}},
{Name: "art.capture", Doc: "Capture unsaved CPU pixels with document/frame/channel identity and revision. MCP returns a PNG image; the CLI writes it only with --out to a new file.", Args: []Arg{
fileArg, {Name: "frame", Type: "string", Doc: "Stable frame ID; defaults to current frame"},
{Name: "channel", Type: "string", Doc: "albedo, normal, height or emission; defaults to current channel"},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
return h.CaptureArt(a.Str("file"), art.ID(a.Str("frame")), art.Channel(a.Str("channel")))
}},
{Name: "art.apply", Doc: "Propose one atomic graphics edit at the revision returned by art.read. Agent panel edits require approval; CLI and external agents follow the editor's confirmation setting. All use the document's common Undo. Name new entities with as and refer to them as $name later.", Write: true, Destroys: true, Args: []Arg{
fileArg, {Name: "revision", Type: "string", Doc: "Exact decimal revision returned by art.read", Required: true},
{Name: "label", Type: "string", Doc: "Human-readable operation label", Required: true},
{Name: "operations", Type: "array", Doc: "1..4096 typed art.Operation objects; no paths or shell commands", Required: true, Items: map[string]any{"type": "object"}},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
if !a.Bool("dry-run") {
return nil, fmt.Errorf("art: edits must pass through the editor proposal queue")
}
revision, err := strconv.ParseUint(a.Str("revision"), 10, 64)
if err != nil {
return nil, fmt.Errorf("art: revision must be an unsigned decimal integer")
}
label := strings.TrimSpace(a.Str("label"))
if label == "" || len(label) > 256 {
return nil, fmt.Errorf("art: label must have 1..256 bytes")
}
ops, err := art.DecodeOperations([]byte(a.Str("operations")))
if err != nil {
return nil, err
}
return h.PrepareArt(a.Str("file"), revision, label, ops)
}},
}
}
+32 -90
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"aego/project"
@@ -14,6 +15,7 @@ type cliFlags struct {
json bool
quiet bool
strict bool
artOut string
}
func Usage(w io.Writer) {
@@ -101,6 +103,22 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
i++
}
continue
case "out":
if c.Name != "art.capture" {
break
}
if !has {
if i+1 >= len(toks) {
return nil, f, fmt.Errorf("--out needs a value")
}
i++
val = toks[i]
}
if val == "" || f.artOut != "" {
return nil, f, fmt.Errorf("--out requires one non-empty path")
}
f.artOut = val
continue
}
x, ok := byName[name]
if !ok {
@@ -129,6 +147,9 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
}
func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
if err == nil && f.artOut != "" {
err = writeArtCapture(f.artOut, out)
}
switch {
case f.json:
var v any = out
@@ -151,3 +172,26 @@ func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
}
return 0
}
// An explicit output path never overwrites a user's existing file.
func writeArtCapture(path string, out *Output) error {
if out == nil || len(out.Images) != 1 || out.Images[0].Mime != "image/png" {
return errors.New("art: editor did not return a PNG capture")
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return fmt.Errorf("art: cannot create capture: %w", err)
}
n, err := f.Write(out.Images[0].Data)
if err == nil && n != len(out.Images[0].Data) {
err = io.ErrShortWrite
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("art: cannot write capture: %w", err)
}
return nil
}
+25 -3
View File
@@ -18,6 +18,7 @@ type Arg struct {
Doc string
Required bool
Pos bool
Items map[string]any
}
type Command struct {
@@ -45,7 +46,9 @@ type Output struct {
Value any `json:"value,omitempty"`
Text string `json:"-"`
// Images go to the model as image content, such as the screenshots of a run.
Images []Image `json:"-"`
Images []Image `json:"-"`
Drawing *DrawingEdit `json:"-"`
Art ArtProposal `json:"-"`
}
// Image is a picture an answer carries.
@@ -67,12 +70,24 @@ func catalog() []*Command {
out = append(out, ctrlCommands()...)
out = append(out, refsCommands()...)
out = append(out, assetCommands()...)
out = append(out, variantCommand())
out = append(out, materialCommands()...)
out = append(out, postEffectCommands()...)
out = append(out, textureCommands()...)
out = append(out, artCommands()...)
out = append(out, schemaCommands()...)
out = append(out, projectCommands()...)
out = append(out, scriptCommands()...)
ls := looks()
for _, c := range out {
c.Look = ls[c.Name]
if c.Name == "asset.import-variant" {
c.Look = ls["asset.pixels"]
}
if c.Look.Title == nil && (strings.HasPrefix(c.Name, "postfx.") || strings.HasPrefix(c.Name, "material.") || strings.HasPrefix(c.Name, "shader.") || strings.HasPrefix(c.Name, "texture.") || strings.HasPrefix(c.Name, "art.")) {
name := strings.ReplaceAll(c.Name, ".", " ")
c.Look = Look{Icon: "comp.sprite", Title: func(a Args) string { return name + " " + file(a) }, Summary: changes}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
@@ -130,6 +145,9 @@ func (c *Command) Usage() string {
if c.Write {
parts = append(parts, "[--dry-run]")
}
if c.Name == "art.capture" {
parts = append(parts, "[--out path]")
}
return strings.Join(parts, " ")
}
@@ -137,7 +155,11 @@ func (c *Command) Schema() map[string]any {
props := map[string]any{}
var req []string
for _, x := range c.AllArgs() {
props[x.Name] = map[string]any{"type": x.Type, "description": x.Doc}
prop := map[string]any{"type": x.Type, "description": x.Doc}
if x.Items != nil {
prop["items"] = x.Items
}
props[x.Name] = prop
if x.Required {
req = append(req, x.Name)
}
@@ -175,7 +197,7 @@ func Run(s *Session, name string, a Args) (*Output, error) {
}
out, err := c.Run(s, a)
if out != nil {
out.OK = err == nil
out.OK = err == nil && !out.Errors()
}
return out, err
}
+59
View File
@@ -0,0 +1,59 @@
package agent
import (
"fmt"
"testing"
"aego/art"
"aego/asset"
)
// Source-only scene validation must not treat a partial sprite sheet as a
// complete generic material texture when native runtime rejects that sampler.
func TestAgentSourceMaterialRejectsPartialArtSheet(t *testing.T) {
for _, multi := range []bool{false, true} {
t.Run(fmt.Sprintf("multi=%v", multi), func(t *testing.T) {
s := materialSession(t)
doc, err := art.New(4, 4, art.RGBA, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
defer doc.Close()
if multi {
edit, err := doc.Begin(doc.Revision(), "Add frame")
if err != nil {
t.Fatal(err)
}
if _, err := edit.AddFrame(doc.Info().Frames[0].ID, 100); err != nil {
t.Fatal(err)
}
change, err := edit.Prepare()
if err != nil {
t.Fatal(err)
}
if err := change.Apply(); err != nil {
t.Fatal(err)
}
}
data, err := doc.Encode()
if err != nil {
t.Fatal(err)
}
artID, err := s.create("assets/art.aeart", data)
if err != nil {
t.Fatal(err)
}
content := fmt.Sprintf("material version=1 shader=asset(%q) { params { Noise=asset(%q) } }", asset.BuiltinShaderDissolve.String(), artID.String())
materialID, err := s.create("assets/art.aemat", []byte(content))
if err != nil {
t.Fatal(err)
}
if info, ok := (sources{s.Pipeline}).TextureMetadata(artID); !ok || info.Region != multi || info.Width != 4 || info.Height != 4 {
t.Fatalf("source-only metadata lost first-frame bounds: %+v/%v", info, ok)
}
if _, ok := (sources{s.Pipeline}).Material(materialID); ok == multi {
t.Fatalf("source-only material resolver result=%v for multi=%v", ok, multi)
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"sync/atomic"
"time"
)
@@ -16,7 +17,7 @@ const maxBody = 8 << 20
// HTTP serves MCP as JSON-RPC over POST on loopback, for an agent the editor runs (D341).
type HTTP struct {
URL string
Token string
token atomic.Pointer[string]
srv *http.Server
s *Server
}
@@ -27,12 +28,8 @@ func ListenHTTP(opt Options) (*HTTP, error) {
if err != nil {
return nil, err
}
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
ln.Close()
return nil, err
}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", Token: hex.EncodeToString(key[:]), s: newServer(opt)}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", s: newServer(opt)}
h.RotateToken()
h.s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/mcp", h.serve)
@@ -41,9 +38,18 @@ func ListenHTTP(opt Options) (*HTTP, error) {
return h, nil
}
func (h *HTTP) Token() string { return *h.token.Load() }
func (h *HTTP) RotateToken() {
var key [32]byte
rand.Read(key[:])
token := hex.EncodeToString(key[:])
h.token.Store(&token)
}
func (h *HTTP) authorized(r *http.Request) bool {
got := r.Header.Get("Authorization")
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token)) == 1
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token())) == 1
}
// local refuses a request whose Host is not loopback, so a web page cannot reach the server by DNS rebinding.
+9 -2
View File
@@ -41,7 +41,7 @@ var versions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
var skipDirs = []string{".aego", ".git", "exports", "native"}
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. For editable .aeart in a running editor, art_read and art_capture inspect the unsaved document; art_apply proposes an undoable batch at the returned revision. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
type request struct {
JSONRPC string `json:"jsonrpc"`
@@ -335,6 +335,13 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
}
for _, t := range s.opt.Tools {
if t.Name == p.Name {
if t.Output != nil {
out, err := t.Output(p.Arguments)
if err != nil {
return result(err.Error(), true), nil
}
return result(out, out != nil && out.Errors()), nil
}
text, failed := t.Run(p.Arguments)
return result(text, failed), nil
}
@@ -364,7 +371,7 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
if err != nil {
return result(err.Error(), true), nil
}
return result(out, false), nil
return result(out, out != nil && out.Errors()), nil
}
func (s *Server) registry() (*docs.Registry, error) {
+2 -1
View File
@@ -29,7 +29,8 @@ type Tool struct {
Schema map[string]any
ReadOnly bool
// Run answers the call; it may block while the user decides.
Run func(args map[string]any) (text string, failed bool)
Run func(args map[string]any) (text string, failed bool)
Output func(map[string]any) (*agent.Output, error)
}
type kitSource struct {
+221
View File
File diff suppressed because it is too large Load Diff
+101
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"os"
"path/filepath"
"testing"
"aego/modules/render2d"
"aego/scene/mutate"
)
type postCameraHost struct {
Host
doc *mutate.Document
edits int
}
func (h *postCameraHost) OpenScene(string) (*mutate.Document, bool) { return h.doc, true }
func (h *postCameraHost) ApplyScene(_ string, _ string, tx mutate.Tx) (*mutate.Result, error) {
r, err := h.doc.Apply(tx)
if err == nil {
h.edits++
}
return r, err
}
func TestPostEffectCommandTargetsDirtyCameraByStableID(t *testing.T) {
s := materialSession(t)
const file = "scenes/test.aescene"
disk := []byte(`scene version=1 {
node "43980000000000000000000000000001" name="Camera" {
component "engine.Camera2D" version=1 {}
component "render2d.PostProcess" version=1 { enabled=true effects=[{typeID="postfx.bloom" instanceID="first" enabled=true version=1 parameters=[]}] }
}
}`)
full := filepath.Join(s.Project.Root, file)
if err := os.WriteFile(full, disk, 0644); err != nil {
t.Fatal(err)
}
_, _, doc, err := s.sceneDisk(file)
if err != nil {
t.Fatal(err)
}
if _, err = doc.Apply(mutate.Tx{Label: "Unsaved rename", Ops: []mutate.Op{mutate.Rename("Camera", "UnsavedCamera")}}); err != nil {
t.Fatal(err)
}
h := &postCameraHost{doc: doc}
s.host = h
a := Args{"file": file, "node": "UnsavedCamera", "instance": "first", "field": "Intensity", "value": "0.37", "dry-run": true}
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 0 {
t.Fatalf("dry run: %v / %d", err, h.edits)
}
delete(a, "dry-run")
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 1 {
t.Fatalf("apply: %v / %d", err, h.edits)
}
o, err := Run(s, "postfx.get", Args{"file": file, "node": "UnsavedCamera"})
if err != nil {
t.Fatal(err)
}
stack := o.Value.(render2d.PostProcess)
e, ok := stack.Effect("first")
if !ok || len(e.Parameters) != 1 || e.Parameters[0].Value.X != 0.37 {
t.Fatal(stack)
}
for _, bad := range []string{"-1", "true", "[1,2]"} {
a["value"] = bad
if _, err := Run(s, "postfx.set", a); err == nil || h.edits != 1 {
t.Fatalf("invalid %q wrote scene", bad)
}
}
a["instance"] = "deleted"
if _, err := Run(s, "postfx.set", a); err == nil {
t.Fatal("missing instance accepted")
}
if b, err := os.ReadFile(full); err != nil || string(b) != string(disk) {
t.Fatal("saved document changed")
}
}
+9
View File
@@ -228,6 +228,9 @@ func looks() map[string]Look {
"schema.show": {"cat.engine", func(a Args) string {
return i18n.Tr("editor.agent.step.show_type", "Read the fields of {type}", i18n.Str("type", a.Str("type")))
}, ok},
"asset.paint": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
"asset.pixels": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
@@ -255,6 +258,12 @@ func looks() map[string]Look {
"refs.fix": {"panel.references", func(a Args) string {
return i18n.Tr("editor.agent.step.fix_refs", "Point references at {to}", i18n.Str("to", path.Base(a.Str("to"))))
}, changes},
"project.main-scene": {"file.scene", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "mainScene › "+a.Str("file")))
}, changes},
"project.input-action": {"doc.project", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "input › "+a.Str("name")))
}, changes},
"project.get": {"doc.project", func(Args) string {
return i18n.Tr("editor.agent.step.read_project", "Read project settings")
}, ok},
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,8 @@ import (
func projectCommands() []*Command {
return []*Command{
{Name: "project.main-scene", Doc: "Set an imported scene as the startup scene by project-relative path.", Args: []Arg{argFile}, Write: true, Run: (*Session).mainScene},
{Name: "project.input-action", Doc: "Create/update one action and add it to a context. Binding strings use the project input syntax. Only an empty initial stack is initialized.", Write: true, Args: []Arg{{Name: "name", Type: "string", Required: true, Pos: true}, {Name: "type", Type: "string", Required: true}, {Name: "bind", Type: "array", Items: map[string]any{"type": "string"}, Required: true}, {Name: "context", Type: "string", Doc: "Gameplay by default"}, {Name: "active", Type: "boolean", Doc: "Add the context to the initial stack if absent"}}, Run: (*Session).inputAction},
{Name: "project.validate", Doc: "missing assets and nodes, import errors, prefabs and scenes that do not load", Run: (*Session).projectValidate},
{Name: "project.get", Doc: "module sections of project.aego (physics, …) with their current values",
Args: []Arg{{Name: "section", Type: "string", Doc: "only this section", Pos: true}}, Run: (*Session).projectGet},
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -91,7 +92,7 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
var rep struct {
Result struct {
Content []struct {
Type, Text string
Type, Text, Data, MimeType string
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
@@ -122,6 +123,21 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
return &Output{OK: true, Text: text}, nil
}
o := out.Output
if c.Name == "art.capture" {
for _, part := range rep.Result.Content {
if part.Type != "image" || part.MimeType != "image/png" {
continue
}
if len(part.Data) > base64.StdEncoding.EncodedLen(16<<20) {
return nil, errors.New("art: PNG exceeds agent capture budget")
}
data, err := base64.StdEncoding.DecodeString(part.Data)
if err != nil {
return nil, fmt.Errorf("art: invalid PNG response: %w", err)
}
o.Images = append(o.Images, Image{Mime: part.MimeType, Data: data})
}
}
var s string
switch {
case c.Name == "scene.run":
+74 -2
View File
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -42,6 +42,7 @@ const (
)
type RunRequest struct {
Camera string `json:"camera,omitempty"`
Scene string `json:"scene,omitempty"`
Frames int `json:"frames"`
Seed uint64 `json:"seed"`
@@ -67,12 +68,14 @@ type Check struct {
// Shot is a captured frame: the full image under .aego/cache/runs, and a scaled PNG for the answer.
type Shot struct {
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PNG []byte `json:"png,omitempty"`
Error string `json:"error,omitempty"`
ViewSignature string `json:"viewSignature,omitempty"`
CaptureID string `json:"captureID,omitempty"`
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PNG []byte `json:"png,omitempty"`
Error string `json:"error,omitempty"`
}
// Track is where a watched node was: at the start, at each quarter of the run and at the end.
@@ -356,6 +359,14 @@ func (r sources) Texture(ids.AssetID) (texture.Region, bool) {
return texture.Region{}, false
}
// Disk scene validation reads the same prepared data/colour and atlas metadata as Play.
func (r sources) TextureMetadata(id ids.AssetID) (asset.TextureMetadata, bool) {
return asset.ReadTextureMetadata(id, func(id ids.AssetID) (*asset.Entry, bool) {
e, ok := r.pl.Entry(id)
return &e, ok && !e.Missing && e.Error == ""
}, func(a asset.Artifact) ([]byte, error) { return r.pl.Artifacts().ReadFile(a.Path) })
}
func (r sources) Mesh(ids.AssetID) (*mesh.Mesh, bool) {
return nil, false
}
@@ -414,6 +425,11 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
if id, err := ids.ParseAssetID(text); err == nil {
return id, nil
}
for _, builtin := range asset.Builtins() {
if strings.EqualFold(text, builtin.Name) {
return builtin.ID, nil
}
}
rel, err := s.rel(text)
if err != nil {
return ids.AssetID{}, err
@@ -425,6 +441,9 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
}
func (s *Session) label(id ids.AssetID) string {
if builtin, ok := asset.LookupBuiltin(id); ok {
return builtin.Name
}
if rel, ok := s.Pipeline.PathOf(id); ok {
return rel
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"aego/asset"
"aego/format/aetext"
)
func textureCommandFixture(t *testing.T) *Session {
t.Helper()
s := materialSession(t)
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: byte(x * 60), A: 255})
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Project.Root, "assets/height.png"), b.Bytes(), 0644); err != nil {
t.Fatal(err)
}
if _, _, err := s.Pipeline.Scan(); err != nil {
t.Fatal(err)
}
if batch, err := s.Pipeline.ImportAll(); err != nil || len(batch.Failed) != 0 {
t.Fatalf("fixture import %v %v", err, batch)
}
return s
}
func TestTextureCommandsShareAtomicMetadataMutationsAndImageRevision(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
get := materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)
hash := get["sourceHash"].(string)
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "role", "value": "height", "expected": hash})
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "2", "expected": hash}); err == nil {
t.Fatal("stale metadata hash accepted")
}
if _, err := Run(s, "texture.apply", Args{"file": file, "tx": `tx version=1 {op "texture-setting" {path="generate_normal" value=true} op "texture-setting" {path="height_strength" value="bad"}}`}); err == nil {
t.Fatal("invalid typed batch accepted")
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("failed batch partially wrote metadata")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "dry-run": true})
after, _ = os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("dry run wrote metadata")
}
hash = materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)["sourceHash"].(string)
image, _ := os.ReadFile(filepath.Join(s.Project.Root, file))
image = append(image, 0)
if err := os.WriteFile(filepath.Join(s.Project.Root, file), image, 0644); err != nil {
t.Fatal(err)
}
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "expected": hash}); err == nil {
t.Fatal("source image change was not in optimistic revision")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true"})
out := materialCommand(t, s, "texture.validate", Args{"file": file})
if out.Value.(map[string]any)["importChecked"] != true || out.Value.(map[string]any)["gpuLinked"] != false {
t.Fatal("validation claimed GPU link or did not run importer")
}
}
func TestTextureCommandsUseDirtySharedMetadataDocument(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
m, err := asset.ParseSourceMeta(before, file+".meta")
if err != nil {
t.Fatal(err)
}
m.Settings["role"], m.Settings["generate_normal"] = aetext.Ident("height"), aetext.Bool(true)
host := &auditMaterialHost{text: map[string]string{file + ".meta": string(asset.MarshalSourceMeta(m))}}
s.host = host
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "4"})
edited, err := asset.ParseSourceMeta([]byte(host.text[file+".meta"]), file+".meta")
if err != nil || edited.Settings.Float("height_strength", 1) != 4 || !edited.Settings.Bool("generate_normal", false) {
t.Fatal("mutation ignored dirty metadata", err)
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("editor-session texture mutation wrote disk behind history")
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"bytes"
"errors"
"image/png"
)
func variantCommand() *Command {
return &Command{Name: "asset.import-variant", Write: true, Doc: "Import an inspected generated PNG variant from this editor conversation as a stable project source asset", Args: []Arg{{Name: "variant", Type: "string", Required: true}, {Name: "path", Type: "string", Required: true}, {Name: "replace", Type: "boolean"}}, Look: Look{}, Run: func(s *Session, a Args) (*Output, error) {
host, ok := s.host.(interface{ GeneratedImage(string) ([]byte, bool) })
if !ok {
return nil, errors.New("requires an active editor generator session")
}
data, ok := host.GeneratedImage(a.Str("variant"))
if !ok {
return nil, errors.New("variant is missing or belongs to a previous conversation")
}
path, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return s.drawn(a, path, img, img, nil, false, nil, nil)
}}
}
+9 -1
View File
@@ -1,4 +1,4 @@
// Package backend runs a model for the Agent panel behind one interface: the Claude Code CLI, or a scripted fake for tests (D341).
// Package backend runs Claude Code or Codex for the Agent panel behind one interface.
package backend
import (
@@ -24,6 +24,7 @@ const (
const ToolPrefix = "mcp__aego__"
type Event struct {
Images [][]byte
Kind EventKind
Text string
Tool string
@@ -32,6 +33,9 @@ type Event struct {
Result string
Failed bool
Err error
// Delta appends Text verbatim to MessageID instead of starting a complete text block.
Delta bool
MessageID string
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
Cost float64
Millis int
@@ -42,11 +46,15 @@ type Event struct {
}
type Config struct {
Binding string
Dir string
Command string
MCPURL string
Token string
System string
// Empty Model and Effort inherit the selected CLI's configuration.
Model string
Effort string
// Record gets every line the CLI writes, for recording sessions the tests replay.
Record io.Writer
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
package backend
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestClaudeProcessFixture(t *testing.T) {
if os.Getenv("AEGO_CLAUDE_PROCESS_FIXTURE") != "1" {
return
}
for i := 0; i < 200; i++ {
fmt.Fprintln(os.Stdout, `{"type":"assistant","message":{"content":[{"type":"text","text":"stream"}]}}`)
}
_, _ = io.Copy(io.Discard, os.Stdin)
os.Exit(0)
}
func TestClaudeCloseReapsProcessWithUndrainedEvents(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestClaudeProcessFixture$")
cmd.Env = append(os.Environ(), "AEGO_CLAUDE_PROCESS_FIXTURE=1")
conf := filepath.Join(t.TempDir(), "mcp.json")
if err := os.WriteFile(conf, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
c := &claudeConv{cmd: cmd, conf: conf, events: make(chan Event, 64), done: make(chan struct{})}
var err error
c.in, err = cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = &c.stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
go c.read(out)
t.Cleanup(func() { _ = c.Close() })
deadline := time.Now().Add(5 * time.Second)
for len(c.events) < cap(c.events) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if len(c.events) != cap(c.events) {
t.Fatal("fixture did not reach a blocked event delivery")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case _, ok := <-c.Events():
if !ok {
if cmd.ProcessState == nil {
t.Fatal("child was not reaped")
}
if _, err := os.Stat(conf); !os.IsNotExist(err) {
t.Fatalf("temporary MCP credentials still exist: %v", err)
}
return
}
case <-timeout.C:
t.Fatal("closing a replaced Claude conversation leaked its process or reader")
}
}
}
+7 -1
View File
@@ -2,6 +2,7 @@ package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -57,6 +58,11 @@ func (e rpcErr) Error() string {
// CallRaw is the result of a tool call as the server sent it, images included.
func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, error) {
return c.CallRawContext(context.Background(), tool, args)
}
// CallRawContext cancels an outstanding question/tool when its turn is stopped.
func (c *Client) CallRawContext(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error) {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": c.next.Add(1), "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
@@ -64,7 +70,7 @@ func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, err
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
if err != nil {
return nil, err
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package backend
import (
"encoding/json"
"strings"
"testing"
)
func TestEditorCatalogRejectsInvalidOrMismatchedTools(t *testing.T) {
for _, tc := range []struct {
name string
raw string
ok bool
}{
{"named", `{"name":"scene_tree"}`, true},
{"key-only", `{}`, true},
{"null", `null`, false},
{"mismatch", `{"name":"different"}`, false},
{"invalid-type", `"scene_tree"`, false},
} {
t.Run(tc.name, func(t *testing.T) {
err := verifyEditorCatalog(map[string]json.RawMessage{"scene_tree": json.RawMessage(tc.raw)}, []string{"scene_tree"})
if (err == nil) != tc.ok || err != nil && !strings.Contains(err.Error(), "scene_tree") {
t.Fatalf("catalog verification: %v", err)
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
//go:build !windows
package backend
import "os/exec"
func hideCatalogProcess(*exec.Cmd) {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
package backend
import (
"os/exec"
"syscall"
)
func hideCatalogProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
package backend
import (
"strings"
"testing"
)
func TestClaudeDoesNotConfuseTextSessionWithSceneAccess(t *testing.T) {
c := &claudeConv{}
c.status([]byte(`{"type":"system","subtype":"init","session_id":"one","model":"fixture","mcp_servers":[{"name":"aego","status":"connected"}],"tools":["mcp__aego__scene_tree"]}`))
if d := c.Connection(); d.Tools != 1 || d.State != "missing tools" || d.Thread != "one" {
t.Fatal(d)
}
c.status([]byte(`{"type":"control_response","response":{"request_id":"aego-status","subtype":"success","response":{"mcpServers":[{"name":"aego","status":"connected","tools":[{"name":"editor_scene_context"},{"name":"scene_tree"},{"name":"script_read"}]}]}}}`))
if d := c.Connection(); d.State != "tools listed" || d.Tools != 3 {
t.Fatal(d)
}
}
func TestCodexRefusesMissingToolAndStaleEditorBinding(t *testing.T) {
for _, tt := range []struct{ mode, state, reason string }{{"missing-scene-tool", "missing tools", "scene_tree"}, {"missing-context", "missing tools", "editor_scene_context"}, {"old-editor", "call rejected", "different editor session"}} {
t.Run(tt.mode, func(t *testing.T) {
c := startCodexFixture(t, tt.mode, func(cfg *Config) { cfg.Binding = "new-editor" })
evs := codexEventUntil(t, c, func(e Event) bool { return e.Kind == EvError })
err := evs[len(evs)-1].Err
if err == nil || !strings.Contains(err.Error(), tt.reason) || c.Connection().State != tt.state {
t.Fatalf("%+v: %v", c.Connection(), err)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package backend
import (
"bytes"
"encoding/base64"
"encoding/json"
"image/png"
)
func toolImages(raw json.RawMessage) [][]byte {
// Claude nests the base64 source; Codex reports MCP content directly.
var b []struct {
Type, MimeType, Data string
Source struct {
Type string
Mime string `json:"media_type"`
Data string
}
}
var r struct{ Content json.RawMessage }
if len(raw) > 0 && raw[0] == '{' {
if json.Unmarshal(raw, &r) != nil {
return nil
}
raw = r.Content
}
if json.Unmarshal(raw, &b) != nil {
return nil
}
var out [][]byte
for _, v := range b {
if v.Type != "image" {
continue
}
data, mime := v.Data, v.MimeType
if v.Source.Type == "base64" {
data, mime = v.Source.Data, v.Source.Mime
}
if mime != "image/png" || len(data) > 7<<20 {
continue
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil || len(decoded) > 5<<20 {
continue
}
c, err := png.DecodeConfig(bytes.NewReader(decoded))
if err != nil || c.Width < 1 || c.Height < 1 || c.Width > 16384 || c.Height > (16<<20)/c.Width {
continue
}
out = append(out, decoded)
if len(out) == 3 {
break
}
}
return out
}

Some files were not shown because too many files have changed in this diff Show More