fix(picoclaw): cancel runtime lifecycle on mode switch

Cancel PicoClaw start/readiness waits when MCP takes control, clear held manual input state after failed release reports, and keep paste duration below the control-mode wait budget.

Also add MCP copy failure translations and avoid reconnect effect churn when the locale changes.
This commit is contained in:
SiYue-ZO
2026-07-20 21:56:31 +08:00
committed by Guoguo
parent d578c63036
commit 57e1f6a4d6
31 changed files with 343 additions and 31 deletions

View File

@@ -1,6 +1,9 @@
package hid package hid
import "testing" import (
"testing"
"time"
)
func TestReportLengthValidation(t *testing.T) { func TestReportLengthValidation(t *testing.T) {
h := &Hid{} h := &Hid{}
@@ -14,3 +17,12 @@ func TestReportLengthValidation(t *testing.T) {
t.Fatal("expected absolute mouse length error") t.Fatal("expected absolute mouse length error")
} }
} }
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
if maxPasteDuration >= 30*time.Second {
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
}
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
}
}

View File

@@ -23,7 +23,7 @@ type PasteReq struct {
const ( const (
defaultPasteDelay = 30 * time.Millisecond defaultPasteDelay = 30 * time.Millisecond
maxPasteDuration = 30 * time.Second maxPasteDuration = 25 * time.Second
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay) maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
) )
@@ -193,7 +193,7 @@ func (s *Service) Paste(c *gin.Context) {
} }
} }
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration { if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
rsp.ErrRsp(c, -2, "paste duration exceeds 30s") rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
return return
} }

View File

@@ -429,6 +429,10 @@ func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held
case ManualAbsoluteMouse: case ManualAbsoluteMouse:
s.absoluteMouseHeld = held s.absoluteMouseHeld = held
} }
} else {
s.keyboardHeld = false
s.relativeMouseHeld = false
s.absoluteMouseHeld = false
} }
releaseControl, end, startCooldown := s.finishIfIdleLocked() releaseControl, end, startCooldown := s.finishIfIdleLocked()
s.mu.Unlock() s.mu.Unlock()

View File

@@ -162,6 +162,49 @@ func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) {
release() release()
} }
func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
tests := []struct {
name string
kind ManualReportKind
}{
{name: "keyboard", kind: ManualKeyboard},
{name: "relative mouse", kind: ManualRelativeMouse},
{name: "absolute mouse", kind: ManualAbsoluteMouse},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
now := time.Unix(260, 0)
coordinator := newCoordinator(time.Second, func() time.Time { return now })
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
manual := NewManualSession(control, coordinator)
defer manual.Close()
down, err := manual.Reserve(context.Background(), tc.kind, true, nil)
if err != nil {
t.Fatal(err)
}
down.Complete(true)
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
if err != nil {
t.Fatal(err)
}
up.Complete(false)
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
}
now = now.Add(2 * time.Second)
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
if err != nil {
t.Fatalf("failed release report left held input active: %v", err)
}
release()
})
}
}
func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) { func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) {
coordinator := newCoordinator(time.Second, time.Now) coordinator := newCoordinator(time.Second, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
@@ -327,6 +370,40 @@ func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) {
up.Complete(true) up.Complete(true)
} }
func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
now := time.Unix(325, 0)
coordinator := newCoordinator(time.Second, func() time.Time { return now })
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
manual := NewManualSession(control, coordinator)
defer manual.Close()
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
if err != nil {
t.Fatal(err)
}
down.Complete(true)
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
}
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
if err != nil {
t.Fatalf("release report was blocked: %v", err)
}
up.Complete(false)
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
}
now = now.Add(2 * time.Second)
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
if err != nil {
t.Fatalf("failed blocked release report left held input active: %v", err)
}
release()
}
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) { func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
coordinator := newCoordinator(defaultManualCooldown, time.Now) coordinator := newCoordinator(defaultManualCooldown, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)

View File

@@ -104,6 +104,17 @@ func (s *Service) beginControlOperation(parent context.Context) (context.Context
} }
} }
func (s *Service) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) {
if parent == nil {
parent = context.Background()
}
if s == nil || s.operations == nil {
ctx, cancel := context.WithCancelCause(parent)
return ctx, func() { cancel(context.Canceled) }
}
return s.operations.begin(parent)
}
func (s *Service) CancelActiveControlOperations() int { func (s *Service) CancelActiveControlOperations() int {
if s == nil || s.operations == nil { if s == nil || s.operations == nil {
return 0 return 0
@@ -115,6 +126,22 @@ func (s *Service) CancelActiveControlOperations() int {
return count return count
} }
func runtimeLifecycleOperationError(ctx context.Context) *PicoclawError {
if ctx == nil || ctx.Err() == nil {
return nil
}
cause := context.Cause(ctx)
switch {
case errors.Is(cause, errControlModeSwitch):
return newPicoclawError(CodeControlModeConflict, "PicoClaw runtime lifecycle canceled because the control mode is switching")
case errors.Is(cause, context.DeadlineExceeded):
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out")
default:
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled")
}
}
func controlOperationError(ctx context.Context) *PicoclawError { func controlOperationError(ctx context.Context) *PicoclawError {
if ctx == nil || ctx.Err() == nil { if ctx == nil || ctx.Err() == nil {
return nil return nil

View File

@@ -67,6 +67,70 @@ func TestControlModeSwitchCancelsActiveWait(t *testing.T) {
} }
} }
func TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
service := &Service{
control: control,
operations: newControlOperationTracker(),
}
operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background())
releaseMode, modeErr := service.acquireControlMode()
if modeErr != nil {
t.Fatal(modeErr)
}
lifecycleDone := make(chan *PicoclawError, 1)
go func() {
<-operationCtx.Done()
lifecycleErr := runtimeLifecycleOperationError(operationCtx)
releaseOperation()
releaseMode()
lifecycleDone <- lifecycleErr
}()
switchDone := make(chan error, 1)
go func() {
switchDone <- control.Switch(controlmode.ModeMCP, func() error {
service.CancelActiveControlOperations()
return nil
})
}()
select {
case err := <-switchDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("control mode switch did not cancel the runtime lifecycle operation")
}
select {
case lifecycleErr := <-lifecycleDone:
if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict {
t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict)
}
case <-time.After(time.Second):
t.Fatal("canceled runtime lifecycle operation did not return")
}
if got := control.Current(); got != controlmode.ModeMCP {
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
}
}
func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) {
service := &Service{}
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errControlModeSwitch)
err := service.waitForRuntimeReadyContext(ctx, time.Hour)
if err == nil || err.Code != CodeControlModeConflict {
t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict)
}
}
func TestWaitDurationIsBounded(t *testing.T) { func TestWaitDurationIsBounded(t *testing.T) {
service := &Service{} service := &Service{}
_, err := service.executeAction(context.Background(), Action{ _, err := service.executeAction(context.Background(), Action{

View File

@@ -35,16 +35,24 @@ func (s *Service) StartRuntime(c *gin.Context) {
} }
defer releaseControl() defer releaseControl()
operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context())
defer releaseOperation()
unlockLifecycle := s.lockRuntimeLifecycle() unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle() defer unlockLifecycle()
if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil {
writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()})
return
}
if currentStatus := s.runtime.Get(); currentStatus.Installing { if currentStatus := s.runtime.Get(); currentStatus.Installing {
runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress") runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()}) writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()})
return return
} }
if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil { if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); readyErr == nil {
s.setRuntimeIntentDesired(true, "web") s.setRuntimeIntentDesired(true, "web")
status := s.runtimeStatus() status := s.runtimeStatus()
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -67,8 +75,9 @@ func (s *Service) StartRuntime(c *gin.Context) {
status.CheckedAt = time.Now() status.CheckedAt = time.Now()
}) })
command, output, startErr := s.startRuntime() command, output, startErr := s.startRuntimeContext(operationCtx)
if startErr != nil { if startErr != nil {
s.markRuntimeLifecycleCanceled(startErr)
s.setRuntimeIntentError(startErr.Message) s.setRuntimeIntentError(startErr.Message)
status := s.runtimeStatus() status := s.runtimeStatus()
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@@ -14,9 +14,19 @@ import (
) )
func (s *Service) startRuntime() (string, string, *PicoclawError) { func (s *Service) startRuntime() (string, string, *PicoclawError) {
return s.startRuntimeContext(context.Background())
}
func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
if s == nil { if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
} }
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", "", lifecycleErr
}
s.ensureDependencies() s.ensureDependencies()
if installed, statErr := isPicoclawInstalled(); statErr != nil { if installed, statErr := isPicoclawInstalled(); statErr != nil {
@@ -77,12 +87,15 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
} }
command := scriptPath + " start" command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout) startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout)
defer cancel() defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput() output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output)) trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil { if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return command, trimmedOutput, lifecycleErr
}
s.runtime.Update(func(status *RuntimeStatus) { s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false status.Ready = false
status.Installed = true status.Installed = true
@@ -96,17 +109,24 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime") return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
} }
time.Sleep(picoclawStartWaitPeriod) if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil {
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil { return command, trimmedOutput, lifecycleErr
}
if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message) startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
failureStatus := "unavailable" failureStatus := "unavailable"
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil { if runtimeErr.Code == CodeControlModeConflict {
failureStatus = "error" startErr = runtimeErr
startErr.Message = fmt.Sprintf( failureStatus = "starting"
"%s; failed to stop partially started runtime: %v", } else {
startErr.Message, if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
cleanupErr, failureStatus = "error"
) startErr.Message = fmt.Sprintf(
"%s; failed to stop partially started runtime: %v",
startErr.Message,
cleanupErr,
)
}
} }
s.runtime.Update(func(status *RuntimeStatus) { s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false status.Ready = false
@@ -121,6 +141,23 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
return command, trimmedOutput, nil return command, trimmedOutput, nil
} }
func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) {
if s == nil || err == nil || err.Code != CodeControlModeConflict {
return
}
s.ensureDependencies()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
if isRuntimeLifecycleStatusPending(*status) {
status.Status = "stopped"
}
status.LastError = err.Message
status.CurrentSession = ""
status.CheckedAt = time.Now()
})
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) { func (s *Service) stopRuntime() (string, string, *PicoclawError) {
if s == nil { if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
@@ -183,11 +220,22 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) {
} }
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError { func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
return s.waitForRuntimeReadyContext(context.Background(), timeout)
}
func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
var lastErr *PicoclawError var lastErr *PicoclawError
for { for {
runtimeErr := s.ensureRuntimeReadyForLifecycle() if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx)
if runtimeErr == nil { if runtimeErr == nil {
return nil return nil
} else { } else {
@@ -198,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
break break
} }
time.Sleep(picoclawReadyPollPeriod) if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil {
return lifecycleErr
}
} }
if lastErr != nil { if lastErr != nil {
@@ -218,18 +268,31 @@ func resolvePicoclawStartScript() (string, error) {
} }
func runPicoclawOnboard() (string, *PicoclawError) { func runPicoclawOnboard() (string, *PicoclawError) {
return runPicoclawOnboardContext(context.Background())
}
func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
scriptPath, err := resolvePicoclawStartScript() scriptPath, err := resolvePicoclawStartScript()
if err != nil { if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error()) return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
} }
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", lifecycleErr
}
command := scriptPath + " onboard" command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout) onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout)
defer cancel() defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput() output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output)) trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil { if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return trimmedOutput, lifecycleErr
}
if trimmedOutput == "" { if trimmedOutput == "" {
trimmedOutput = execErr.Error() trimmedOutput = execErr.Error()
} }
@@ -239,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) {
return trimmedOutput, nil return trimmedOutput, nil
} }
func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
if delay <= 0 {
return runtimeLifecycleOperationError(ctx)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return runtimeLifecycleOperationError(ctx)
case <-timer.C:
return nil
}
}
func isPicoclawInstalled() (bool, error) { func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath) info, err := os.Stat(picoclawBinaryPath)
if err == nil { if err == nil {

View File

@@ -1,6 +1,7 @@
package picoclaw package picoclaw
import ( import (
"context"
"os" "os"
"sync" "sync"
"time" "time"
@@ -355,17 +356,24 @@ func (s *Service) lockRuntimeLifecycle() func() {
} }
func (s *Service) ensureRuntimeReady() *PicoclawError { func (s *Service) ensureRuntimeReady() *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(false) return s.ensureRuntimeReadyWithProbeProtection(context.Background(), false)
} }
func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError { func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(true) return s.ensureRuntimeReadyForLifecycleContext(context.Background())
} }
func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite bool) *PicoclawError { func (s *Service) ensureRuntimeReadyForLifecycleContext(ctx context.Context) *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(ctx, true)
}
func (s *Service) ensureRuntimeReadyWithProbeProtection(ctx context.Context, allowLifecycleOverwrite bool) *PicoclawError {
if s == nil { if s == nil {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable") return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable")
} }
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
s.ensureDependencies() s.ensureDependencies()
setStatus := func(status RuntimeStatus) { setStatus := func(status RuntimeStatus) {
if allowLifecycleOverwrite { if allowLifecycleOverwrite {
@@ -440,7 +448,10 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
} }
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil { if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
if _, onboardErr := runPicoclawOnboardContext(ctx); onboardErr != nil {
setStatus(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
@@ -455,6 +466,9 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
}) })
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
} }
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
if _, statErr := os.Stat(configPath); statErr == nil { if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady goto configReady
} }

View File

@@ -290,6 +290,7 @@ const ca = {
enableConfirmDesc: enableConfirmDesc:
'En activar MCP, PicoClaw saturarà i es tancarà qualsevol sessió activa de PicoClaw.', 'En activar MCP, PicoClaw saturarà i es tancarà qualsevol sessió activa de PicoClaw.',
failed: 'Loperació MCP ha fallat', failed: 'Loperació MCP ha fallat',
copyFailed: 'La còpia ha fallat. Copieu-ho manualment.',
okBtn: 'Confirma', okBtn: 'Confirma',
cancelBtn: 'Cancel·la' cancelBtn: 'Cancel·la'
}, },
@@ -599,7 +600,6 @@ const ca = {
success: 'PicoClaw instal·lat correctament', success: 'PicoClaw instal·lat correctament',
failed: "No s'ha pogut instal·lar PicoClaw", failed: "No s'ha pogut instal·lar PicoClaw",
uninstalling: "S'està desinstal·lant el temps d'execució...", uninstalling: "S'està desinstal·lant el temps d'execució...",
uninstaling: "S'està desinstal·lant el temps d'execució...",
uninstalled: "El temps d'execució s'ha desinstal·lat correctament.", uninstalled: "El temps d'execució s'ha desinstal·lat correctament.",
uninstallFailed: 'La desinstal·lació ha fallat.', uninstallFailed: 'La desinstal·lació ha fallat.',
requiredTitle: 'PicoClaw no està instal·lat', requiredTitle: 'PicoClaw no està instal·lat',

View File

@@ -293,6 +293,7 @@ const cz = {
enableConfirmDesc: enableConfirmDesc:
'Povolením MCP se zastaví PicoClaw a ukončí se všechny aktivní relace PicoClaw.', 'Povolením MCP se zastaví PicoClaw a ukončí se všechny aktivní relace PicoClaw.',
failed: 'Operace MCP se nezdařila', failed: 'Operace MCP se nezdařila',
copyFailed: 'Kopírování se nezdařilo. Zkopírujte ručně.',
okBtn: 'Potvrdit', okBtn: 'Potvrdit',
cancelBtn: 'Zrušit' cancelBtn: 'Zrušit'
}, },

View File

@@ -290,6 +290,7 @@ const da = {
enableConfirmDesc: enableConfirmDesc:
'Aktivering af MCP stopper PicoClaw og lukker alle aktive PicoClaw-sessioner.', 'Aktivering af MCP stopper PicoClaw og lukker alle aktive PicoClaw-sessioner.',
failed: 'MCP-handlingen mislykkedes', failed: 'MCP-handlingen mislykkedes',
copyFailed: 'Kopiering mislykkedes. Kopiér manuelt.',
okBtn: 'Bekræft', okBtn: 'Bekræft',
cancelBtn: 'Annuller' cancelBtn: 'Annuller'
}, },

View File

@@ -296,6 +296,7 @@ const de = {
enableConfirmDesc: enableConfirmDesc:
'Durch Aktivieren von MCP wird PicoClaw gestoppt und jede aktive PicoClaw-Sitzung geschlossen.', 'Durch Aktivieren von MCP wird PicoClaw gestoppt und jede aktive PicoClaw-Sitzung geschlossen.',
failed: 'MCP-Aktion fehlgeschlagen', failed: 'MCP-Aktion fehlgeschlagen',
copyFailed: 'Kopieren fehlgeschlagen. Bitte manuell kopieren.',
okBtn: 'Bestätigen', okBtn: 'Bestätigen',
cancelBtn: 'Abbrechen' cancelBtn: 'Abbrechen'
}, },

View File

@@ -293,6 +293,7 @@ const es = {
enableConfirmDesc: enableConfirmDesc:
'Al activar MCP se detendrá PicoClaw y se cerrará cualquier sesión activa de PicoClaw.', 'Al activar MCP se detendrá PicoClaw y se cerrará cualquier sesión activa de PicoClaw.',
failed: 'Error en la operación MCP', failed: 'Error en la operación MCP',
copyFailed: 'Error al copiar. Copia manualmente.',
okBtn: 'Confirmar', okBtn: 'Confirmar',
cancelBtn: 'Cancelar' cancelBtn: 'Cancelar'
}, },

View File

@@ -295,6 +295,7 @@ const fr = {
enableConfirmDesc: enableConfirmDesc:
'Lactivation de MCP arrêtera PicoClaw et fermera toute session PicoClaw active.', 'Lactivation de MCP arrêtera PicoClaw et fermera toute session PicoClaw active.',
failed: 'Échec de lopération MCP', failed: 'Échec de lopération MCP',
copyFailed: 'La copie a échoué. Copiez manuellement.',
okBtn: 'Confirmer', okBtn: 'Confirmer',
cancelBtn: 'Annuler' cancelBtn: 'Annuler'
}, },

View File

@@ -294,6 +294,7 @@ const hu = {
enableConfirmDesc: enableConfirmDesc:
'Az MCP engedélyezése leállítja a PicoClaw-t, és bezár minden aktív PicoClaw-munkamenetet.', 'Az MCP engedélyezése leállítja a PicoClaw-t, és bezár minden aktív PicoClaw-munkamenetet.',
failed: 'Az MCP-művelet sikertelen', failed: 'Az MCP-művelet sikertelen',
copyFailed: 'A másolás sikertelen. Másolja kézzel.',
okBtn: 'Megerősítés', okBtn: 'Megerősítés',
cancelBtn: 'Mégse' cancelBtn: 'Mégse'
}, },

View File

@@ -292,6 +292,7 @@ const id = {
enableConfirmDesc: enableConfirmDesc:
'Mengaktifkan MCP akan menghentikan PicoClaw dan menutup semua sesi PicoClaw yang aktif.', 'Mengaktifkan MCP akan menghentikan PicoClaw dan menutup semua sesi PicoClaw yang aktif.',
failed: 'Operasi MCP gagal', failed: 'Operasi MCP gagal',
copyFailed: 'Gagal menyalin. Salin secara manual.',
okBtn: 'Konfirmasi', okBtn: 'Konfirmasi',
cancelBtn: 'Batal' cancelBtn: 'Batal'
}, },

View File

@@ -294,6 +294,7 @@ const it = {
enableConfirmDesc: enableConfirmDesc:
'Labilitazione di MCP arresterà PicoClaw e chiuderà tutte le sessioni PicoClaw attive.', 'Labilitazione di MCP arresterà PicoClaw e chiuderà tutte le sessioni PicoClaw attive.',
failed: 'Operazione MCP non riuscita', failed: 'Operazione MCP non riuscita',
copyFailed: 'Copia non riuscita. Copia manualmente.',
okBtn: 'Conferma', okBtn: 'Conferma',
cancelBtn: 'Annulla' cancelBtn: 'Annulla'
}, },

View File

@@ -293,6 +293,7 @@ const ja = {
enableConfirmDesc: enableConfirmDesc:
'MCP を有効にすると PicoClaw が停止し、アクティブな PicoClaw セッションがすべて終了します。', 'MCP を有効にすると PicoClaw が停止し、アクティブな PicoClaw セッションがすべて終了します。',
failed: 'MCP 操作に失敗しました', failed: 'MCP 操作に失敗しました',
copyFailed: 'コピーに失敗しました。手動でコピーしてください。',
okBtn: '確認', okBtn: '確認',
cancelBtn: 'キャンセル' cancelBtn: 'キャンセル'
}, },

View File

@@ -288,6 +288,7 @@ const ko = {
enableConfirmDesc: enableConfirmDesc:
'MCP를 활성화하면 PicoClaw가 중지되고 활성 PicoClaw 세션이 모두 닫힙니다.', 'MCP를 활성화하면 PicoClaw가 중지되고 활성 PicoClaw 세션이 모두 닫힙니다.',
failed: 'MCP 작업에 실패했습니다', failed: 'MCP 작업에 실패했습니다',
copyFailed: '복사에 실패했습니다. 수동으로 복사하세요.',
okBtn: '확인', okBtn: '확인',
cancelBtn: '취소' cancelBtn: '취소'
}, },

View File

@@ -291,6 +291,7 @@ const nb = {
enableConfirmDesc: enableConfirmDesc:
'Aktivering av MCP stopper PicoClaw og lukker alle aktive PicoClaw-økter.', 'Aktivering av MCP stopper PicoClaw og lukker alle aktive PicoClaw-økter.',
failed: 'MCP-operasjonen mislyktes', failed: 'MCP-operasjonen mislyktes',
copyFailed: 'Kopiering mislyktes. Kopier manuelt.',
okBtn: 'Bekreft', okBtn: 'Bekreft',
cancelBtn: 'Avbryt' cancelBtn: 'Avbryt'
}, },

View File

@@ -294,6 +294,7 @@ const nl = {
enableConfirmDesc: enableConfirmDesc:
'Als MCP wordt ingeschakeld, stopt PicoClaw en worden alle actieve PicoClaw-sessies gesloten.', 'Als MCP wordt ingeschakeld, stopt PicoClaw en worden alle actieve PicoClaw-sessies gesloten.',
failed: 'MCP-bewerking mislukt', failed: 'MCP-bewerking mislukt',
copyFailed: 'Kopiëren mislukt. Kopieer handmatig.',
okBtn: 'Bevestigen', okBtn: 'Bevestigen',
cancelBtn: 'Annuleren' cancelBtn: 'Annuleren'
}, },

View File

@@ -293,6 +293,7 @@ const pl = {
enableConfirmDesc: enableConfirmDesc:
'Włączenie MCP zatrzyma PicoClaw i zamknie wszystkie aktywne sesje PicoClaw.', 'Włączenie MCP zatrzyma PicoClaw i zamknie wszystkie aktywne sesje PicoClaw.',
failed: 'Operacja MCP nie powiodła się', failed: 'Operacja MCP nie powiodła się',
copyFailed: 'Kopiowanie nie powiodło się. Skopiuj ręcznie.',
okBtn: 'Potwierdź', okBtn: 'Potwierdź',
cancelBtn: 'Anuluj' cancelBtn: 'Anuluj'
}, },

View File

@@ -292,6 +292,7 @@ const pt_br = {
enableConfirmDesc: enableConfirmDesc:
'Habilitar o MCP interromperá o PicoClaw e fechará todas as sessões ativas do PicoClaw.', 'Habilitar o MCP interromperá o PicoClaw e fechará todas as sessões ativas do PicoClaw.',
failed: 'Falha na operação MCP', failed: 'Falha na operação MCP',
copyFailed: 'Falha ao copiar. Copie manualmente.',
okBtn: 'Confirmar', okBtn: 'Confirmar',
cancelBtn: 'Cancelar' cancelBtn: 'Cancelar'
}, },

View File

@@ -292,6 +292,7 @@ const ru = {
enableConfirmDesc: enableConfirmDesc:
'Включение MCP остановит PicoClaw и закроет все активные сеансы PicoClaw.', 'Включение MCP остановит PicoClaw и закроет все активные сеансы PicoClaw.',
failed: 'Операция MCP завершилась с ошибкой', failed: 'Операция MCP завершилась с ошибкой',
copyFailed: 'Не удалось скопировать. Скопируйте вручную.',
okBtn: 'Подтвердить', okBtn: 'Подтвердить',
cancelBtn: 'Отмена' cancelBtn: 'Отмена'
}, },

View File

@@ -289,6 +289,7 @@ const se = {
enableConfirmDesc: enableConfirmDesc:
'Om MCP aktiveras stoppas PicoClaw och alla aktiva PicoClaw-sessioner stängs.', 'Om MCP aktiveras stoppas PicoClaw och alla aktiva PicoClaw-sessioner stängs.',
failed: 'MCP-åtgärden misslyckades', failed: 'MCP-åtgärden misslyckades',
copyFailed: 'Kopiering misslyckades. Kopiera manuellt.',
okBtn: 'Bekräfta', okBtn: 'Bekräfta',
cancelBtn: 'Avbryt' cancelBtn: 'Avbryt'
}, },

View File

@@ -285,6 +285,7 @@ const th = {
enableConfirmDesc: enableConfirmDesc:
'การเปิดใช้งาน MCP จะหยุด PicoClaw และปิดเซสชัน PicoClaw ที่ใช้งานอยู่ทั้งหมด', 'การเปิดใช้งาน MCP จะหยุด PicoClaw และปิดเซสชัน PicoClaw ที่ใช้งานอยู่ทั้งหมด',
failed: 'การดำเนินการ MCP ล้มเหลว', failed: 'การดำเนินการ MCP ล้มเหลว',
copyFailed: 'คัดลอกไม่สำเร็จ โปรดคัดลอกด้วยตนเอง',
okBtn: 'ยืนยัน', okBtn: 'ยืนยัน',
cancelBtn: 'ยกเลิก' cancelBtn: 'ยกเลิก'
}, },

View File

@@ -291,6 +291,7 @@ const tr = {
enableConfirmDesc: enableConfirmDesc:
'MCP etkinleştirildiğinde PicoClaw durdurulur ve tüm etkin PicoClaw oturumları kapatılır.', 'MCP etkinleştirildiğinde PicoClaw durdurulur ve tüm etkin PicoClaw oturumları kapatılır.',
failed: 'MCP işlemi başarısız oldu', failed: 'MCP işlemi başarısız oldu',
copyFailed: 'Kopyalama başarısız. Elle kopyalayın.',
okBtn: 'Onayla', okBtn: 'Onayla',
cancelBtn: 'İptal' cancelBtn: 'İptal'
}, },

View File

@@ -292,6 +292,7 @@ const uk = {
enableConfirmTitle: 'Увімкнути зовнішнє керування MCP?', enableConfirmTitle: 'Увімкнути зовнішнє керування MCP?',
enableConfirmDesc: 'Увімкнення MCP зупинить PicoClaw і закриє всі активні сеанси PicoClaw.', enableConfirmDesc: 'Увімкнення MCP зупинить PicoClaw і закриє всі активні сеанси PicoClaw.',
failed: 'Операція MCP завершилася помилкою', failed: 'Операція MCP завершилася помилкою',
copyFailed: 'Не вдалося скопіювати. Скопіюйте вручну.',
okBtn: 'Підтвердити', okBtn: 'Підтвердити',
cancelBtn: 'Скасувати' cancelBtn: 'Скасувати'
}, },

View File

@@ -289,6 +289,7 @@ const vi = {
enableConfirmTitle: 'Bật điều khiển MCP bên ngoài?', enableConfirmTitle: 'Bật điều khiển MCP bên ngoài?',
enableConfirmDesc: 'Bật MCP sẽ dừng PicoClaw và đóng tất cả phiên PicoClaw đang hoạt động.', enableConfirmDesc: 'Bật MCP sẽ dừng PicoClaw và đóng tất cả phiên PicoClaw đang hoạt động.',
failed: 'Thao tác MCP không thành công', failed: 'Thao tác MCP không thành công',
copyFailed: 'Sao chép thất bại. Vui lòng sao chép thủ công.',
okBtn: 'Xác nhận', okBtn: 'Xác nhận',
cancelBtn: 'Hủy' cancelBtn: 'Hủy'
}, },

View File

@@ -379,6 +379,12 @@ export function usePicoclawSidebarLifecycle({
setTransportState, setTransportState,
setRunState setRunState
}: LifecycleOptions) { }: LifecycleOptions) {
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -396,7 +402,7 @@ export function usePicoclawSidebarLifecycle({
if (nextRuntimeStatus?.installing) { if (nextRuntimeStatus?.installing) {
setMessages((current) => [ setMessages((current) => [
...current, ...current,
createStatusMessage(t('picoclaw.install.installing')) createStatusMessage(tRef.current('picoclaw.install.installing'))
]); ]);
return; return;
} }
@@ -404,7 +410,7 @@ export function usePicoclawSidebarLifecycle({
if (nextRuntimeStatus?.installed === false) { if (nextRuntimeStatus?.installed === false) {
setMessages((current) => [ setMessages((current) => [
...current, ...current,
createStatusMessage(t('picoclaw.install.requiredDescription')) createStatusMessage(tRef.current('picoclaw.install.requiredDescription'))
]); ]);
return; return;
} }
@@ -412,7 +418,7 @@ export function usePicoclawSidebarLifecycle({
if (nextRuntimeStatus?.model_configured === false) { if (nextRuntimeStatus?.model_configured === false) {
setMessages((current) => [ setMessages((current) => [
...current, ...current,
createStatusMessage(t('picoclaw.model.requiredDescription')) createStatusMessage(tRef.current('picoclaw.model.requiredDescription'))
]); ]);
return; return;
} }
@@ -423,7 +429,7 @@ export function usePicoclawSidebarLifecycle({
setMessages((current) => [ setMessages((current) => [
...current, ...current,
createStatusMessage(t('picoclaw.status.connecting')) createStatusMessage(tRef.current('picoclaw.status.connecting'))
]); ]);
try { try {
await connectGateway(nextSessionId); await connectGateway(nextSessionId);
@@ -460,8 +466,7 @@ export function usePicoclawSidebarLifecycle({
setOverlay, setOverlay,
setRunState, setRunState,
setTakeover, setTakeover,
setTransportState, setTransportState
t
]); ]);
} }