Skip to content
34 changes: 32 additions & 2 deletions cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/sipeed/picoclaw/pkg/models"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
svcmgr "github.com/sipeed/picoclaw/pkg/service"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
Expand Down Expand Up @@ -249,8 +250,8 @@ func printHelp() {
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" models Manage models (list, set, effort, status)")
fmt.Println(" auth Manage authentication (login, import-op, logout, status)")
fmt.Println(" gateway Start sciClaw gateway")
fmt.Println(" service Manage background gateway service (launchd/systemd)")
fmt.Println(" gateway Start sciClaw gateway in foreground (debug/containers)")
fmt.Println(" service Manage background gateway service (launchd/systemd, recommended)")
fmt.Println(" vm Manage a Multipass sciClaw VM (no repo checkout required)")
fmt.Println(" docker Convenience wrapper for sciClaw container workflows")
fmt.Println(" channels Setup and manage chat channels (Telegram, Discord, etc.)")
Expand Down Expand Up @@ -1146,6 +1147,16 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func gatewayCmd() {
// Check for --debug flag
args := os.Args[2:]
if len(args) > 0 && strings.EqualFold(strings.TrimSpace(args[0]), "status") {
// Backward-compatible alias: avoid accidentally launching a second
// gateway process when users ask for status.
originalArgs := append([]string(nil), os.Args...)
os.Args = []string{originalArgs[0], "service", "status"}
defer func() { os.Args = originalArgs }()
serviceCmd()
return
}

for _, arg := range args {
if arg == "--debug" || arg == "-d" {
logger.SetLevel(logger.DEBUG)
Expand All @@ -1154,6 +1165,25 @@ func gatewayCmd() {
}
}

// Guard against double-running channel pollers (e.g. Telegram 409 conflicts)
// when users already have the managed service active.
exePath, err := resolveServiceExecutablePath(os.Args[0], exec.LookPath, os.Executable)
if err == nil {
if mgr, mgrErr := svcmgr.NewManager(exePath); mgrErr == nil {
if st, statusErr := mgr.Status(); statusErr == nil && st.Running {
backend := strings.TrimSpace(st.Backend)
if backend == "" {
backend = mgr.Backend()
}
fmt.Fprintf(os.Stderr, "Gateway is already running via %s service.\n", backend)
fmt.Fprintf(os.Stderr, " Stop it first: %s service stop\n", invokedCLIName())
fmt.Fprintf(os.Stderr, " View logs: %s service logs\n", invokedCLIName())
fmt.Fprintf(os.Stderr, " Restart: %s service restart\n", invokedCLIName())
os.Exit(1)
}
}
}

cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
Expand Down
19 changes: 11 additions & 8 deletions cmd/picoclaw/service_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ func resolveServiceExecutablePath(
executable func() (string, error),
) (string, error) {
arg0 := strings.TrimSpace(argv0)

// If argv0 is an explicit path, prefer it verbatim (after Abs). This keeps
// service bindings stable when users invoke via a managed shim path such as
// /opt/homebrew/bin/sciclaw, even if PATH points to another copy first.
if arg0 != "" && (strings.Contains(arg0, "/") || strings.Contains(arg0, `\`)) {
if abs, err := filepath.Abs(arg0); err == nil {
return abs, nil
}
return arg0, nil
}

base := strings.TrimSpace(filepath.Base(arg0))
if base != "" {
if resolved, err := lookPath(base); err == nil && strings.TrimSpace(resolved) != "" {
Expand All @@ -168,14 +179,6 @@ func resolveServiceExecutablePath(
}
}

// If argv0 is an explicit path, keep using it as a fallback.
if arg0 != "" && (strings.Contains(arg0, "/") || strings.Contains(arg0, `\`)) {
if abs, err := filepath.Abs(arg0); err == nil {
return abs, nil
}
return arg0, nil
}

return executable()
}

Expand Down
27 changes: 24 additions & 3 deletions cmd/picoclaw/service_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,45 @@ func TestParseServiceLogsOptionsInvalid(t *testing.T) {
}
}

func TestResolveServiceExecutablePath_PrefersLookPath(t *testing.T) {
func TestResolveServiceExecutablePath_PrefersExplicitArgv0Path(t *testing.T) {
got, err := resolveServiceExecutablePath(
"/home/linuxbrew/.linuxbrew/Cellar/sciclaw/0.1.39/bin/sciclaw",
func(file string) (string, error) {
t.Fatalf("lookPath should not be called for explicit argv0 path")
return "", nil
},
func() (string, error) {
t.Fatalf("executable fallback should not be called for explicit argv0 path")
return "", nil
},
)
if err != nil {
t.Fatalf("resolveServiceExecutablePath returned error: %v", err)
}
if got != "/home/linuxbrew/.linuxbrew/Cellar/sciclaw/0.1.39/bin/sciclaw" {
t.Fatalf("expected argv0 path, got %q", got)
}
}

func TestResolveServiceExecutablePath_PrefersLookPathForBareCommand(t *testing.T) {
got, err := resolveServiceExecutablePath(
"sciclaw",
func(file string) (string, error) {
if file != "sciclaw" {
t.Fatalf("expected lookup for sciclaw, got %q", file)
}
return "/home/linuxbrew/.linuxbrew/bin/sciclaw", nil
},
func() (string, error) {
return "/home/linuxbrew/.linuxbrew/Cellar/sciclaw/0.1.39/bin/sciclaw", nil
t.Fatalf("executable fallback should not be called when lookPath succeeds")
return "", nil
},
)
if err != nil {
t.Fatalf("resolveServiceExecutablePath returned error: %v", err)
}
if got != "/home/linuxbrew/.linuxbrew/bin/sciclaw" {
t.Fatalf("expected stable Homebrew path, got %q", got)
t.Fatalf("expected lookPath result, got %q", got)
}
}

Expand Down
17 changes: 17 additions & 0 deletions cmd/picoclaw/tui/configedit.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,20 @@ func removeAllowFrom(exec Executor, channel string, idx int) error {
ch["allow_from"] = existing
return writeConfigMap(exec, cfg)
}

// replaceAllowFrom replaces an entry by index in a channel's allow_from.
func replaceAllowFrom(exec Executor, channel string, idx int, entry string) error {
cfg, err := readConfigMap(exec)
if err != nil {
return err
}
channels := ensureMap(cfg, "channels")
ch := ensureMap(channels, channel)
existing := getStringSlice(ch, "allow_from")
if idx < 0 || idx >= len(existing) {
return nil
}
existing[idx] = strings.TrimSpace(entry)
ch["allow_from"] = existing
return writeConfigMap(exec, cfg)
}
47 changes: 37 additions & 10 deletions cmd/picoclaw/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ type Model struct {
height int

// Shared state
snapshot *VMSnapshot
snapshotErr error
loading bool
spinner spinner.Model
lastRefresh time.Time
snapshot *VMSnapshot
snapshotErr error
loading bool
spinner spinner.Model
lastRefresh time.Time
lastAction string
lastActionAt time.Time

// Sub-models for each tab
home HomeModel
Expand Down Expand Up @@ -87,8 +89,8 @@ func buildTabs(mode Mode) []tabEntry {
tabs = append(tabs, tabEntry{"Files", tabFiles})
}
tabs = append(tabs,
tabEntry{"Settings", tabSettings},
tabEntry{"Gateway", tabAgent},
tabEntry{"Settings", tabSettings},
tabEntry{"Login", tabLogin},
tabEntry{"Health", tabDoctor},
)
Expand Down Expand Up @@ -304,11 +306,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case routingActionMsg:
m.routing.HandleAction(msg)
return m, tea.Batch(fetchRoutingStatus(m.exec), fetchRoutingListCmd(m.exec))
m.loading = true
return m, tea.Batch(
m.spinner.Tick,
fetchSnapshotCmd(m.exec),
fetchSettingsData(m.exec),
fetchRoutingStatus(m.exec),
fetchRoutingListCmd(m.exec),
)

case actionDoneMsg:
if trimmed := strings.TrimSpace(msg.output); trimmed != "" {
m.lastAction = trimmed
m.lastActionAt = time.Now()
}
m.loading = true
return m, tea.Batch(m.spinner.Tick, fetchSnapshotCmd(m.exec))
return m, tea.Batch(m.spinner.Tick, fetchSnapshotCmd(m.exec), fetchSettingsData(m.exec))

case chatResponseMsg:
m.chat.HandleResponse(msg)
Expand All @@ -324,6 +337,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case serviceActionMsg:
m.agent.HandleServiceAction(msg)
m.settings.HandleServiceAction(msg)
m.loading = true
return m, tea.Batch(m.spinner.Tick, fetchSnapshotCmd(m.exec))

Expand Down Expand Up @@ -387,9 +401,9 @@ func (m Model) View() string {
var b strings.Builder

// Header
title := " sciClaw Control Center"
title := "🦞🧪 sciClaw Control Center"
if m.exec.Mode() == ModeVM {
title = " sciClaw VM Control Center"
title = "🦞🧪 sciClaw VM Control Center"
}
header := lipgloss.NewStyle().Bold(true).Foreground(colorAccent).Render(title)
b.WriteString(header)
Expand Down Expand Up @@ -507,6 +521,19 @@ func (m Model) renderStatusBar() string {
left = fmt.Sprintf(" %s Connecting...", m.spinner.View())
}

if !m.lastActionAt.IsZero() && time.Since(m.lastActionAt) <= 8*time.Second {
msgStyle := styleOK
lower := strings.ToLower(m.lastAction)
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") {
msgStyle = styleErr
}
if strings.TrimSpace(left) == "" {
left = " " + msgStyle.Render(m.lastAction)
} else {
left += " " + msgStyle.Render(m.lastAction)
}
}

right := "Tab: switch section Enter: select q: quit"
if !m.lastRefresh.IsZero() {
ago := time.Since(m.lastRefresh).Truncate(time.Second)
Expand Down
52 changes: 40 additions & 12 deletions cmd/picoclaw/tui/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type VMSnapshot struct {
// Service state
ServiceInstalled bool
ServiceRunning bool
ServiceAutoStart bool

// Mount state (VM-only)
Mounts []MountInfo
Expand Down Expand Up @@ -188,12 +189,8 @@ func collectVMSnapshot(exec Executor) VMSnapshot {
snap.Discord = channelState(cfg.Channels.Discord)
snap.Telegram = channelState(cfg.Channels.Telegram)

// Service state (parallel).
var wg2 sync.WaitGroup
wg2.Add(2)
go func() { defer wg2.Done(); snap.ServiceInstalled = exec.ServiceInstalled() }()
go func() { defer wg2.Done(); snap.ServiceRunning = exec.ServiceActive() }()
wg2.Wait()
// Service state from a single status snapshot.
snap.ServiceInstalled, snap.ServiceRunning, snap.ServiceAutoStart = collectServiceState(exec)

return snap
}
Expand Down Expand Up @@ -245,12 +242,8 @@ func collectLocalSnapshot(exec Executor) VMSnapshot {
snap.Discord = channelState(cfg.Channels.Discord)
snap.Telegram = channelState(cfg.Channels.Telegram)

// Service state (parallel).
var wg2 sync.WaitGroup
wg2.Add(2)
go func() { defer wg2.Done(); snap.ServiceInstalled = exec.ServiceInstalled() }()
go func() { defer wg2.Done(); snap.ServiceRunning = exec.ServiceActive() }()
wg2.Wait()
// Service state from a single status snapshot.
snap.ServiceInstalled, snap.ServiceRunning, snap.ServiceAutoStart = collectServiceState(exec)

return snap
}
Expand All @@ -265,6 +258,41 @@ func providerState(prov providerJSON, cred authCredJSON) string {
return "missing"
}

func collectServiceState(exec Executor) (installed, running, autoStart bool) {
cmd := "HOME=" + exec.HomePath() + " sciclaw service status 2>&1"
out, err := exec.ExecShell(8*time.Second, cmd)
if err != nil {
installed = exec.ServiceInstalled()
running = exec.ServiceActive()
autoStart = installed
return installed, running, autoStart
}

parsedInstalled, hasInstalled := parseServiceStatusFlag(out, "installed")
parsedRunning, hasRunning := parseServiceStatusFlag(out, "running")
parsedEnabled, hasEnabled := parseServiceStatusFlag(out, "enabled")

if hasInstalled {
installed = parsedInstalled
} else {
installed = exec.ServiceInstalled()
}

if hasRunning {
running = parsedRunning
} else {
running = exec.ServiceActive()
}

if hasEnabled {
autoStart = parsedEnabled
} else {
autoStart = installed
}

return installed, running, autoStart
}

func channelState(ch channelJSON) ChannelSnapshot {
users := make([]ApprovedUser, 0, len(ch.AllowFrom))
for _, entry := range ch.AllowFrom {
Expand Down
10 changes: 10 additions & 0 deletions cmd/picoclaw/tui/tab_channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,3 +623,13 @@ func removeUserFromConfig(exec Executor, channel string, idx int) tea.Cmd {
return actionDoneMsg{output: "User removed."}
}
}

// updateUserInConfig replaces an existing allow_from entry by index.
func updateUserInConfig(exec Executor, channel string, idx int, entry string) tea.Cmd {
return func() tea.Msg {
if err := replaceAllowFrom(exec, channel, idx, entry); err != nil {
return actionDoneMsg{output: fmt.Sprintf("Failed to update user: %v", err)}
}
return actionDoneMsg{output: "User updated."}
}
}
Loading
Loading