From f145f480598854cbaa81505504a57d5e418c0118 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 9 Jun 2026 18:03:04 +0900 Subject: [PATCH 1/2] Report structured launch and restart status Make launch success paths machine-readable so agents can tell whether Unity was already running, newly launched, or restarted. Include process IDs in restart responses and bump the CLI contract version for the new launch output contract. --- .agents/skills/uloop-launch/SKILL.md | 16 +- .claude/skills/uloop-launch/SKILL.md | 16 +- .../CliOnlyTools~/Launch/Skill/SKILL.md | 16 +- Packages/src/Editor/Domain/CliConstants.cs | 2 +- cli/contract.json | 2 +- cli/internal/cli/launch.go | 27 +++- cli/internal/cli/launch_ready.go | 85 ++++++++-- cli/internal/cli/launch_test.go | 145 ++++++++++++++++++ cli/internal/tools/default-tools.json | 2 +- 9 files changed, 269 insertions(+), 42 deletions(-) diff --git a/.agents/skills/uloop-launch/SKILL.md b/.agents/skills/uloop-launch/SKILL.md index c86f117b60..185de54d96 100644 --- a/.agents/skills/uloop-launch/SKILL.md +++ b/.agents/skills/uloop-launch/SKILL.md @@ -49,12 +49,18 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/.claude/skills/uloop-launch/SKILL.md b/.claude/skills/uloop-launch/SKILL.md index c86f117b60..185de54d96 100644 --- a/.claude/skills/uloop-launch/SKILL.md +++ b/.claude/skills/uloop-launch/SKILL.md @@ -49,12 +49,18 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index c86f117b60..185de54d96 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -49,12 +49,18 @@ uloop launch --quit - Prints detected Unity version - Prints project path -- If Unity is already running, focuses the existing window -- If launching, prints when it is waiting for Unity CLI Loop server readiness -- If launching, waits until Unity finishes startup and the CLI can connect to the project -- When launch readiness completes, returns JSON with: - - `Success`: whether launch readiness completed +- If Unity is already running, focuses the existing window and verifies tool readiness +- If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness +- If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project +- Successful launch, restart, existing-process, and quit paths return JSON with: + - `Success`: whether the command completed - `Ready`: whether Unity CLI Loop is ready for commands - `ServerReady`: whether the Unity CLI Loop server accepted requests - `ProjectIpcReady`: whether the project IPC path accepted tool requests + - `AlreadyRunning`: whether an existing Unity process was reused + - `Launched`: whether this command launched a Unity process + - `Restarted`: whether this command stopped an existing process and launched a new one + - `PreviousProcessId`: process ID stopped by restart or quit, when available + - `CurrentProcessId`: current Unity process ID, when available + - `ProjectRoot`: resolved project root - `Message`: readiness summary diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index cd519d4e88..299d862e6f 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.27"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.28"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/cli/contract.json b/cli/contract.json index eb610a641d..d1b2bf8d03 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.27" + "cliVersion": "3.0.0-beta.28" } diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 4c948d4baf..9f25193af9 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -29,6 +29,7 @@ const ( var ( findRunningUnityProcessForLaunch = findRunningUnityProcess focusUnityProcessForLaunch = focusUnityProcess + killUnityProcessForLaunch = killUnityProcess resolveUnityExecutablePathForLaunch = resolveUnityExecutablePath waitForUnityLockfileForLaunch = waitForUnityLockfile waitForToolReadinessForLaunch = waitForToolReadiness @@ -203,22 +204,27 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std if runningProcess != nil { if !options.restart && !options.quit { logLaunchExistingFocus(ctx, projectRoot, runningProcess.pid) - writeFormat(stdout, "Unity is already running for %s (PID: %d)\n", projectRoot, runningProcess.pid) - return 0 + spinner := newLaunchSpinner(stdout, stderr) + defer spinner.Stop() + writeLaunchReadinessWait(stdout, spinner) + if err := waitForToolReadinessForLaunch(ctx, projectRoot); err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) + return 1 + } + spinner.Stop() + return writeExistingLaunchReadyResponse(stdout, stderr, projectRoot, runningProcess.pid) } - if err := killUnityProcess(runningProcess.pid); err != nil { + if err := killUnityProcessForLaunch(runningProcess.pid); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } if options.quit { - writeFormat(stdout, "Unity process stopped (PID: %d)\n", runningProcess.pid) - return 0 + return writeLaunchQuitResponse(stdout, stderr, projectRoot, &runningProcess.pid, launchStoppedMessage) } } if options.quit { - writeLine(stdout, "No Unity process is running for this project.") - return 0 + return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage) } removedStaleTemp, err := cleanStaleUnityTemp(projectRoot) @@ -258,6 +264,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } + currentPid := command.Process.Pid if err := command.Process.Release(); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 @@ -272,7 +279,11 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std return 1 } spinner.Stop() - return writeLaunchReadyResponse(stdout, stderr, projectRoot) + var previousPid *int + if runningProcess != nil { + previousPid = &runningProcess.pid + } + return writeLaunchedReadyResponse(stdout, stderr, projectRoot, previousPid, currentPid) } func newUnityLaunchCommand(unityPath string, launchArgs []string) *exec.Cmd { diff --git a/cli/internal/cli/launch_ready.go b/cli/internal/cli/launch_ready.go index 00af31cc57..fdd935b530 100644 --- a/cli/internal/cli/launch_ready.go +++ b/cli/internal/cli/launch_ready.go @@ -6,16 +6,26 @@ import ( ) const ( - launchReadinessMessage = "Waiting for Unity CLI Loop server readiness..." - launchReadyMessage = "Unity CLI Loop is ready." + launchReadinessMessage = "Waiting for Unity CLI Loop server readiness..." + launchReadyMessage = "Unity CLI Loop is ready." + launchAlreadyRunningReadyMessage = "Unity is already running and ready." + launchStoppedMessage = "Unity process stopped." + launchNoProcessMessage = "No Unity process is running for this project." ) type launchReadyResponse struct { - Success bool `json:"Success"` - Ready bool `json:"Ready"` - ServerReady bool `json:"ServerReady"` - ProjectIpcReady bool `json:"ProjectIpcReady"` - Message string `json:"Message"` + Success bool `json:"Success"` + Ready bool `json:"Ready"` + ServerReady bool `json:"ServerReady"` + ProjectIpcReady bool `json:"ProjectIpcReady"` + AlreadyRunning bool `json:"AlreadyRunning"` + Launched bool `json:"Launched"` + Restarted bool `json:"Restarted"` + Quit bool `json:"Quit"` + PreviousProcessId *int `json:"PreviousProcessId,omitempty"` + CurrentProcessId *int `json:"CurrentProcessId,omitempty"` + ProjectRoot string `json:"ProjectRoot"` + Message string `json:"Message"` } func writeLaunchReadinessWait(stdout io.Writer, spinner *terminalSpinner) { @@ -25,17 +35,60 @@ func writeLaunchReadinessWait(stdout io.Writer, spinner *terminalSpinner) { } } -func writeLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string) int { - response := launchReadyResponse{ - Success: true, - Ready: true, - ServerReady: true, - ProjectIpcReady: true, - Message: launchReadyMessage, - } +func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + ServerReady: true, + ProjectIpcReady: true, + AlreadyRunning: true, + CurrentProcessId: ¤tPid, + ProjectRoot: projectRoot, + Message: launchAlreadyRunningReadyMessage, + }) +} + +func writeLaunchedReadyResponse( + stdout io.Writer, + stderr io.Writer, + projectRoot string, + previousPid *int, + currentPid int, +) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + ServerReady: true, + ProjectIpcReady: true, + Launched: true, + Restarted: previousPid != nil, + PreviousProcessId: previousPid, + CurrentProcessId: ¤tPid, + ProjectRoot: projectRoot, + Message: launchReadyMessage, + }) +} + +func writeLaunchQuitResponse( + stdout io.Writer, + stderr io.Writer, + projectRoot string, + previousPid *int, + message string, +) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Quit: true, + PreviousProcessId: previousPid, + ProjectRoot: projectRoot, + Message: message, + }) +} + +func writeLaunchResponse(stdout io.Writer, stderr io.Writer, response launchReadyResponse) int { payload, err := json.Marshal(response) if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) + writeClassifiedError(stderr, err, errorContext{projectRoot: response.ProjectRoot, command: launchCommandName}) return 1 } writeJSON(stdout, payload) diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index b0b0c44e80..b40329da57 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -167,21 +168,146 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { } } +func TestRunLaunchWritesStructuredResponseForExistingUnityProcess(t *testing.T) { + // Verifies launch reports machine-readable readiness when Unity was already running. + originalFinder := findRunningUnityProcessForLaunch + originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch + readinessChecked := false + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 111}, nil + } + focusUnityProcessForLaunch = func(context.Context, int) error { + return nil + } + waitForToolReadinessForLaunch = func(context.Context, string) error { + readinessChecked = true + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch( + context.Background(), + launchOptions{projectPath: projectRoot}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + if !readinessChecked { + t.Fatal("launch should verify tool readiness before reporting an existing Unity process as ready") + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || !response.ProjectIpcReady { + t.Fatalf("ready flags mismatch: %#v", response) + } + if !response.AlreadyRunning || response.Launched || response.Restarted { + t.Fatalf("process state flags mismatch: %#v", response) + } + if response.CurrentProcessId == nil || *response.CurrentProcessId != 111 { + t.Fatalf("current process id mismatch: %#v", response.CurrentProcessId) + } + if response.PreviousProcessId != nil { + t.Fatalf("existing launch should not report a previous process: %#v", response.PreviousProcessId) + } +} + +func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { + // Verifies restart reports both the stopped process and the newly launched process. + originalFinder := findRunningUnityProcessForLaunch + originalKiller := killUnityProcessForLaunch + originalResolver := resolveUnityExecutablePathForLaunch + originalLockfileWait := waitForUnityLockfileForLaunch + originalReadinessWait := waitForToolReadinessForLaunch + killedPid := 0 + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 222}, nil + } + killUnityProcessForLaunch = func(pid int) error { + killedPid = pid + return nil + } + resolveUnityExecutablePathForLaunch = func(string) (string, error) { + return "/usr/bin/true", nil + } + waitForUnityLockfileForLaunch = func(context.Context, string, time.Duration, time.Duration) error { + return nil + } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + killUnityProcessForLaunch = originalKiller + resolveUnityExecutablePathForLaunch = originalResolver + waitForUnityLockfileForLaunch = originalLockfileWait + waitForToolReadinessForLaunch = originalReadinessWait + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch( + context.Background(), + launchOptions{projectPath: projectRoot, restart: true}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + if killedPid != 222 { + t.Fatalf("restart killed pid mismatch: %d", killedPid) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || !response.ProjectIpcReady { + t.Fatalf("ready flags mismatch: %#v", response) + } + if !response.Launched || !response.Restarted || response.AlreadyRunning { + t.Fatalf("process state flags mismatch: %#v", response) + } + if response.PreviousProcessId == nil || *response.PreviousProcessId != 222 { + t.Fatalf("previous process id mismatch: %#v", response.PreviousProcessId) + } + if response.CurrentProcessId == nil || *response.CurrentProcessId <= 0 { + t.Fatalf("current process id mismatch: %#v", response.CurrentProcessId) + } +} + // Verifies launch logs when it focuses an already-running Unity process. func TestRunLaunchWritesExistingFocusSuccessVibeLog(t *testing.T) { enableCliVibeLog(t) originalFinder := findRunningUnityProcessForLaunch originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { return &unityProcess{pid: 111}, nil } focusUnityProcessForLaunch = func(context.Context, int) error { return nil } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } t.Cleanup(func() { findRunningUnityProcessForLaunch = originalFinder focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait }) projectRoot := createLaunchTestProject(t) @@ -218,15 +344,20 @@ func TestRunLaunchWritesExistingFocusFailureVibeLog(t *testing.T) { originalFinder := findRunningUnityProcessForLaunch originalFocus := focusUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { return &unityProcess{pid: 222}, nil } focusUnityProcessForLaunch = func(context.Context, int) error { return fmt.Errorf("activation denied") } + waitForToolReadinessForLaunch = func(context.Context, string) error { + return nil + } t.Cleanup(func() { findRunningUnityProcessForLaunch = originalFinder focusUnityProcessForLaunch = originalFocus + waitForToolReadinessForLaunch = originalReadinessWait }) projectRoot := createLaunchTestProject(t) @@ -351,3 +482,17 @@ func createLaunchTestProject(t *testing.T) string { } return projectRoot } + +func decodeLaunchResponseFromOutput(t *testing.T, output string) launchReadyResponse { + t.Helper() + + jsonStart := strings.LastIndex(output, "{") + if jsonStart < 0 { + t.Fatalf("launch output did not contain a JSON object:\n%s", output) + } + var response launchReadyResponse + if err := json.Unmarshal([]byte(output[jsonStart:]), &response); err != nil { + t.Fatalf("failed to decode launch JSON: %v\n%s", err, output) + } + return response +} diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 1575fafc3d..90a4c143c3 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.27", + "version": "3.0.0-beta.28", "tools": [ { "name": "compile", From 2e975644471806fdafaab0b210809da7749db7be Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 9 Jun 2026 18:44:54 +0900 Subject: [PATCH 2/2] Document launch quit response field List the Quit flag in the launch skill output contract so agents can detect quit outcomes from the structured response instead of parsing the message text. --- .agents/skills/uloop-launch/SKILL.md | 1 + .claude/skills/uloop-launch/SKILL.md | 1 + Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md | 1 + 3 files changed, 3 insertions(+) diff --git a/.agents/skills/uloop-launch/SKILL.md b/.agents/skills/uloop-launch/SKILL.md index 185de54d96..3530881aad 100644 --- a/.agents/skills/uloop-launch/SKILL.md +++ b/.agents/skills/uloop-launch/SKILL.md @@ -60,6 +60,7 @@ uloop launch --quit - `AlreadyRunning`: whether an existing Unity process was reused - `Launched`: whether this command launched a Unity process - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process - `PreviousProcessId`: process ID stopped by restart or quit, when available - `CurrentProcessId`: current Unity process ID, when available - `ProjectRoot`: resolved project root diff --git a/.claude/skills/uloop-launch/SKILL.md b/.claude/skills/uloop-launch/SKILL.md index 185de54d96..3530881aad 100644 --- a/.claude/skills/uloop-launch/SKILL.md +++ b/.claude/skills/uloop-launch/SKILL.md @@ -60,6 +60,7 @@ uloop launch --quit - `AlreadyRunning`: whether an existing Unity process was reused - `Launched`: whether this command launched a Unity process - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process - `PreviousProcessId`: process ID stopped by restart or quit, when available - `CurrentProcessId`: current Unity process ID, when available - `ProjectRoot`: resolved project root diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index 185de54d96..3530881aad 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -60,6 +60,7 @@ uloop launch --quit - `AlreadyRunning`: whether an existing Unity process was reused - `Launched`: whether this command launched a Unity process - `Restarted`: whether this command stopped an existing process and launched a new one + - `Quit`: whether this command stopped Unity without launching a new process - `PreviousProcessId`: process ID stopped by restart or quit, when available - `CurrentProcessId`: current Unity process ID, when available - `ProjectRoot`: resolved project root