Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ jobs:
run: |
& $env:GODOT --headless --path . --editor --import --quit
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Contract, Studio, pieces and the art download
- name: Contract, Studio, pieces, colors, the shared list, the agent's tools and the window
env:
STUDIO_PIECES: ${{ github.workspace }}/build/pieces
run: |
& $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect
& $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect recolour shared_pieces mcp window
if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' }
- name: Every piece the suites made passes Agent Guild's own check
run: |
Expand Down Expand Up @@ -231,11 +231,11 @@ jobs:
run: |
& $env:GODOT --headless --path . --editor --import --quit
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Contract, Studio, pieces and the art download
- name: Contract, Studio, pieces, colors, the shared list, the agent's tools and the window
env:
STUDIO_PIECES: ${{ github.workspace }}/build/pieces
run: |
& $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect
& $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect recolour shared_pieces mcp window
if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' }
- name: Every piece the suites made passes Agent Guild's own check
run: |
Expand Down
47 changes: 40 additions & 7 deletions src/app/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ var mcp: McpServer
var _art_reason := ""
var _section: Texture2D
var _orbiting := false
var _closing := false
## What closing the window ends with; tests name another.
var on_quit := func(): get_tree().quit()

func _ready() -> void:
for arg in OS.get_cmdline_user_args():
Expand Down Expand Up @@ -59,6 +62,7 @@ func _ready() -> void:
return
if options.has("mcp"):
Studio.quiet = true
_library()
mcp = McpServer.new()
mcp.host = self
add_child(mcp)
Expand Down Expand Up @@ -101,15 +105,29 @@ func _fetch(what: String) -> int:
print("studio: the art is in %s" % StudioArt.folder())
return 0

func _library() -> void:
if pieces != null:
return
pieces = PieceLibrary.new()
pieces.default_clips = default_clips
add_child(pieces)

## The window: the stage, with the first screen beside it.
func _open() -> void:
world = options.get("world", world) if YardModel.WORLDS.has(options.get("world", world)) else world
theme = "dark" if options.get("theme", "light") == "dark" else "light"
get_window().min_size = Vector2i(960, 600)
pieces = PieceLibrary.new()
pieces.default_clips = default_clips
add_child(pieces)
get_tree().auto_accept_quit = false
_library()
pieces.changed.connect(_show_home)
# An agent's connection is another program with the same list of pieces.
var look := Timer.new()
look.wait_time = 2.0
look.timeout.connect(func():
if flow == null:
pieces.refresh())
add_child(look)
look.start()
stage = StudioStage.new()
add_child(stage)
camera = CameraRig.new()
Expand Down Expand Up @@ -256,8 +274,20 @@ func _unhandled_input(event: InputEvent) -> void:
camera.orbit(-event.relative.x * 0.006, 0.0)

func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST and flow != null and panel.visible:
panel.leave_requested.emit()
if what == NOTIFICATION_WM_CLOSE_REQUEST and home != null:
close()

## The window's close button: a save under way finishes first, so nothing is
## left half written; a download under way is dropped whole.
func close() -> void:
if _closing:
return
_closing = true
if flow == null:
on_quit.call()
return
flow.ended.connect(func(): on_quit.call(), CONNECT_ONE_SHOT)
panel.leave_requested.emit()

func _shot(path: String) -> void:
for i in 30:
Expand All @@ -273,8 +303,11 @@ func agent_disconnected() -> void:

## Studio's window, as a program of its own beside an agent's connection.
func open_window() -> bool:
var before := [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")]
return OS.create_process(OS.get_executable_path(), before) > 0
return OS.create_process(OS.get_executable_path(), window_args()) > 0

## What Studio is started with to open its window: nothing, in the app itself.
static func window_args() -> Array:
return [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")]

## For PowerShell on Windows, and for Terminal's shell (zsh) elsewhere.
static func connect_commands(exe: String, before: Array, posix := OS.get_name() != "Windows") -> Array:
Expand Down
46 changes: 38 additions & 8 deletions src/mcp/server.gd
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ const METHOD_NOT_FOUND := -32601
const INVALID_PARAMS := -32602
const UNSUPPORTED_VERSION := -32022
const PERSON_WAIT_MS := 30000
const WINDOW_INIT := "studio-window-initialize"
const WINDOW_START := "studio-window-start"
const WINDOW_RETRY_MS := 10000
const AGENT_LEFT := "notifications/studio/agent_disconnected"
const WITHOUT_WINDOW := []
const INSTRUCTIONS := "Agent Guild: Studio makes characters and companions for Agent Guild from a model file, or by recoloring one of a world's own looks. It works without a window.
Start with list_slots: a piece is made for one slot, a world's crew such as guild/anthropic, or a world's companions such as guild/companion.
make_character takes one model (.glb, .gltf or .vrm); make_companions takes one model or four; recolor_look changes a world's own look. Each writes a piece folder at `to` and checks it. A world's art downloads the first time it is needed, which can take a while.
make_character takes one model (.glb, .gltf or .vrm); make_companions takes one model or four; recolor_look starts from a world's own look. Each can change the piece's colors, writes a piece folder at `to` and checks it. A world's art downloads the first time it is needed, which can take a while.
When Studio can't be sure of a model's bones, make_character says which, and you call it again with `bones`.
Use check_piece on any piece folder. show_window opens Studio for the person."
Pieces made here are listed for the person in Studio's window too: list_pieces, remove_piece. Use check_piece on any piece folder. show_window opens Studio for the person."

var host: Node
var tools: McpTools
var read_stdin := true
var write_line := func(line: String): print(line)
var relay := false
var from_relay := false
var window_args: Array = []
var launch_window := _launch_window
var write_window := _write_window
Expand All @@ -36,9 +39,13 @@ var _queue: Array = []
var _active: Dictionary = {}
var _waiting_since := -1
var _closed := false
var _left := false
var _window := {}
var _window_open := false
var _window_starting := false
var _window_failed_at := -1
var _window_threads: Array = []
var _exited_windows: Array = []
var _forwarded := {}

func _ready() -> void:
Expand Down Expand Up @@ -71,9 +78,11 @@ func _stdin_closed() -> void:
_thread.wait_to_finish()
_thread = null
if _window_open:
if not _window.is_empty():
_window.stdio.close()
write_window.call(JSON.stringify({"jsonrpc": "2.0", "method": AGENT_LEFT}))
return
if _left:
return
_left = true
if host and host.has_method("agent_disconnected"):
host.agent_disconnected()

Expand All @@ -82,6 +91,8 @@ func _exit_tree() -> void:
_thread.wait_to_finish()

func _process(_delta: float) -> void:
if not _exited_windows.is_empty():
_exited_windows = _exited_windows.filter(OS.is_process_running)
if not _queue.is_empty():
_pump()

Expand Down Expand Up @@ -126,6 +137,9 @@ static func _id_of(msg: Variant) -> Variant:
return null

func _notification_received(msg: Dictionary) -> void:
if msg.method == AGENT_LEFT and from_relay:
_stdin_closed()
return
if msg.method != "notifications/cancelled":
return
var params = msg.get("params", {})
Expand Down Expand Up @@ -279,12 +293,20 @@ func _send(msg: Dictionary) -> void:

func _forward(ctx: Dictionary, msg: Dictionary) -> void:
if not _window_open:
var wait := _window_failed_at + WINDOW_RETRY_MS - Time.get_ticks_msec() if _window_failed_at >= 0 else 0
if wait > 0:
respond(ctx, tools.failure("Studio's window failed to start. Try again in %d seconds." % ceili(wait / 1000.0)))
return
if not launch_window.call():
_window_failed_at = Time.get_ticks_msec()
respond(ctx, tools.failure("Studio couldn't open its window."))
return
_window_open = true
_window_starting = true
if _legacy_version != "":
write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_INIT, "method": "initialize", "params": {"protocolVersion": _legacy_version}}))
write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_START, "method": "initialize", "params": {"protocolVersion": _legacy_version}}))
else:
write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_START, "method": "ping"}))
_forwarded[ctx.id] = ctx
msg["id"] = ctx.id
write_window.call(JSON.stringify(msg))
Expand All @@ -311,7 +333,9 @@ func _from_window(line: String) -> void:
if not msg is Dictionary:
return
if msg.has("id") and not msg.has("method"):
if msg.id is String and msg.id == WINDOW_INIT:
if _window_starting:
_window_starting = false
_window_failed_at = -1
return
var id = _id_of(msg)
if not _forwarded.has(id):
Expand All @@ -322,13 +346,19 @@ func _from_window(line: String) -> void:

func _window_closed() -> void:
_window_open = false
var failed := _window_starting
_window_starting = false
if failed:
_window_failed_at = Time.get_ticks_msec()
for thread in _window_threads:
thread.wait_to_finish()
_window_threads.clear()
if _window.has("pid") and OS.is_process_running(_window.pid):
_exited_windows.append(_window.pid)
_window = {}
var waiting := _forwarded.values()
_forwarded.clear()
for ctx in waiting:
respond(ctx, tools.failure("The Studio window was closed. Call the tool again to reopen it."))
respond(ctx, tools.failure("Studio's window failed to start, so nothing was done." if failed else "The Studio window was closed. Call the tool again to reopen it."))
if _closed:
_stdin_closed()
Loading
Loading