Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ api-contracts
.worktrees

# GoReleaser output
dist/
dist/

# Connect log file (retask sandbox connect)
retask.log
retask.log.*
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
module github.com/nwebxyz/retask-cli

go 1.26.4
go 1.26.5

require (
connectrpc.com/connect v1.20.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/coder/websocket v1.8.14
github.com/hoaitan/agentfleet v0.6.27
github.com/hoaitan/agentfleet v0.8.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.43.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hoaitan/agentfleet v0.6.27 h1:mAsBWhaZnwC6eUhxtZCgV4GXfcGgASUStHqRgBU3Gmk=
github.com/hoaitan/agentfleet v0.6.27/go.mod h1:nYrGTu9Xzn4fwzlYA9Na8g2PuBKBgYov5uy0mj1J1uM=
github.com/hoaitan/agentfleet v0.8.0 h1:Xj6C1KkzknM4t+TBFo6fZT1X9moOdRcY9xPLQPCdrtU=
github.com/hoaitan/agentfleet v0.8.0/go.mod h1:PtXaJtEF+1UfL2yJbkSll+Y5Vj0LKw8Ej96PqbhRdK4=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/helpcmd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func buildManifest() manifest {
{Command: "retask sandbox session update", Description: "Partial update a session", Flags: []string{"--name", "--seed-nrn", "--seed-prompt"}, Example: "retask sandbox session update <id> --name \"My Session\""},
{Command: "retask sandbox session stop", Description: "Stop a session", Example: "retask sandbox session stop <session-id>"},
{Command: "retask sandbox session delete", Description: "Delete a session", Example: "retask sandbox session delete <session-id>"},
{Command: "retask sandbox connect", Description: "Connect this machine as a Private VM sandbox (long-running)", Flags: []string{"--mode", "--auto-open", "--no-auto-respond", "--session-buffer"}, Example: "retask sandbox connect <sandbox-id>"},
{Command: "retask sandbox connect", Description: "Connect this machine as a Private VM sandbox (long-running). Logs go to the TUI (stderr when headless) and to retask.log in the current folder, which rotates into retask.log.1 ... retask.log.N", Flags: []string{"--mode", "--auto-open", "--no-auto-respond", "--session-buffer", "--log-file", "--no-log-file", "--log-max-size", "--log-backups", "--no-log-path"}, Example: "retask sandbox connect <sandbox-id>"},
{Command: "retask sandbox attach", Description: "Attach terminal to a running local session", Example: "retask sandbox attach <session-id>"},
{Command: "retask agent list", Description: "List agents", Flags: []string{"--role"}, Example: "retask agent list --role ROLE_TASK_PROCESSOR"},
{Command: "retask agent get", Description: "Get an agent by ID", Example: "retask agent get <agent-id>"},
Expand Down
65 changes: 50 additions & 15 deletions internal/cmd/sandbox/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func newConnectCommand(gf *flags.Global) *cobra.Command {
var autoOpen bool
var noAutoRespond bool
var sessionBuffer string
var logFlags logFileFlags
cmd := &cobra.Command{
Use: "connect <id>",
Short: "Connect this machine as a Private VM sandbox",
Expand All @@ -70,18 +71,36 @@ session lane reconnect on their own with exponential backoff. While a session
lane is down, its output is buffered (drop-oldest) and flushed to the proxy on
reconnect, so the viewer keeps the most recent output across the gap.

Every log line goes to two places: the TUI log panel (stderr in headless mode)
and retask.log in the current folder. The log file rotates Unix-style — the live
file keeps its name and older generations shift down through retask.log.1,
retask.log.2, ... up to --log-backups. The TUI Logs divider shows the active path.

Flags:
--mode string Running mode: auto, tui, headless (default: auto)
--auto-open Auto-open a terminal tab for each new session (default: false)
--no-auto-respond Disable auto-accepting known agent startup prompts (default: false)
--session-buffer Per-session output retained across a session-lane drop, flushed on
reconnect (default: 10MB). 0 disables buffering. Accepts 512KB, 10MB, ...
--log-file string Log file written alongside the TUI/stderr output, relative to the
current folder (default: retask.log)
--no-log-file Do not write a log file; log to the TUI/stderr only (default: false)
--log-max-size Rotate the log file once it exceeds this size (default: 10MB).
0 disables rotation. Accepts 512KB, 10MB, ...
--log-backups int Rotated log generations kept, retask.log.1 ... retask.log.N
(default: 5). 0 keeps none
--no-log-path Hide the log file path from the TUI Logs divider (default: false)

Environment:
SANDBOX_PROXY_ENDPOINT Proxy base URL (default: https://sandbox-proxy.prd.nweb.app/)
RETASK_SANDBOX_AUTO_OPEN_SESSION=1 Enable auto-open without the flag
RETASK_SANDBOX_NO_AUTO_RESPOND=1 Disable prompt auto-response without the flag
RETASK_SANDBOX_SESSION_BUFFER Session output buffer size (overridden by --session-buffer)`,
RETASK_SANDBOX_SESSION_BUFFER Session output buffer size (overridden by --session-buffer)
RETASK_SANDBOX_LOG_FILE Log file path (overridden by --log-file)
RETASK_SANDBOX_NO_LOG_FILE=1 Disable the log file without the flag
RETASK_SANDBOX_LOG_MAX_SIZE Log rotation threshold (overridden by --log-max-size)
RETASK_SANDBOX_LOG_BACKUPS Rotated generations kept (overridden by --log-backups)
RETASK_SANDBOX_NO_LOG_PATH=1 Hide the log path from the TUI without the flag`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if mode != "auto" && mode != "tui" && mode != "headless" {
Expand Down Expand Up @@ -131,14 +150,38 @@ Environment:

useTUI := mode == "tui" || (mode == "auto" && term.IsTerminal(int(os.Stdout.Fd())))

// agentfleet config.
fleetCfg := agentfleet.DefaultConfig()
fleetCfg.Agent = sessionAgentConfig()
fleetCfg.TUI.Title = makeTitleFunc(sbResp.Msg.Name, sbResp.Msg.SandboxId)
fleetCfg.TUI.TitleRight = makeConnStatusFunc(&rawConnState)
fleetCfg.TUI.AutoOpen = autoOpen || os.Getenv("RETASK_SANDBOX_AUTO_OPEN_SESSION") == "1"
fleetCfg.TUI.FilterLines = filterLines

logCfg, showLogPath, err := logFlags.resolve(cmd)
if err != nil {
return err
}
// A disabled log file yields a nil *LogFile, which discards writes,
// so the tee below needs no branch for it.
logFile, err := agentfleet.OpenLogFile(logCfg)
if err != nil {
return err
}
defer logFile.Close() //nolint:errcheck

// LogBuffer captures all events; in TUI mode it feeds the log panel,
// in headless mode it drains to stderr so output is identical.
// in headless mode it drains to stderr so output is identical. Either
// way the same lines are appended to the log file.
logBuf := agentfleet.NewLogBuffer(500)
var logOut io.Writer = os.Stderr
var termOut io.Writer = os.Stderr
if useTUI {
logOut = logBuf
termOut = logBuf
fleetCfg.TUI.Log = logBuf
fleetCfg.TUI.LogPath = logFile.Path()
fleetCfg.TUI.ShowLogPath = showLogPath
}
logger := slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{
logger := slog.New(slog.NewTextHandler(io.MultiWriter(termOut, logFile), &slog.HandlerOptions{
Level: slog.LevelInfo,
}))

Expand All @@ -150,18 +193,9 @@ Environment:
"mode", mode,
"tui", useTUI,
"proxy", wsBase,
"log_file", logFile.Path(),
)

// agentfleet config.
fleetCfg := agentfleet.DefaultConfig()
fleetCfg.Agent = sessionAgentConfig()
fleetCfg.TUI.Title = makeTitleFunc(sbResp.Msg.Name, sbResp.Msg.SandboxId)
fleetCfg.TUI.TitleRight = makeConnStatusFunc(&rawConnState)
fleetCfg.TUI.AutoOpen = autoOpen || os.Getenv("RETASK_SANDBOX_AUTO_OPEN_SESSION") == "1"
fleetCfg.TUI.FilterLines = filterLines
if useTUI {
fleetCfg.TUI.Log = logBuf
}
fleet := agentfleet.NewFleet(fleetCfg.Fleet)

baseDir, err := os.Getwd()
Expand Down Expand Up @@ -219,6 +253,7 @@ Environment:
cmd.Flags().BoolVar(&autoOpen, "auto-open", false, "Auto-open a terminal tab for each new session")
cmd.Flags().BoolVar(&noAutoRespond, "no-auto-respond", false, "Disable auto-accepting known agent startup prompts (e.g. folder-trust)")
cmd.Flags().StringVar(&sessionBuffer, "session-buffer", "10MB", "Per-session output buffered across a session-lane drop (drop-oldest), flushed on reconnect. 0 disables. Accepts 512KB, 10MB, ...")
logFlags.register(cmd)
return cmd
}

Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/sandbox/datalane.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ func (dl *DataLane) connectOnce(ctx context.Context) (established bool, err erro

conn, _, err := websocket.Dial(ctx, dialURL, nil)
if err != nil {
return false, err
// The dial error quotes dialURL, JWT and all — scrub it before it
// reaches the log panel or retask.log.
return false, redactErr(err)
}
established = true
defer conn.CloseNow() //nolint:errcheck
Expand Down
92 changes: 92 additions & 0 deletions internal/cmd/sandbox/logfileflags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// internal/cmd/sandbox/logfileflags.go
package sandbox

import (
"fmt"
"os"
"strconv"

agentfleet "github.com/hoaitan/agentfleet"
"github.com/spf13/cobra"
)

// Defaults for the on-disk connect log. The file lives in the folder the
// command was started from — for a sandbox that is the session folder — so the
// log sits next to the work it describes.
const (
defaultLogFileName = "retask.log"
defaultLogMaxSize = "10MB"
defaultLogBackups = 5
)

// logFileFlags holds the connect flags controlling the on-disk log file and
// whether the TUI shows its path.
type logFileFlags struct {
path string
disabled bool
maxSize string
backups int
hidePath bool
}

// register binds the log flags to cmd.
func (f *logFileFlags) register(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.path, "log-file", defaultLogFileName,
"Log file written alongside the TUI/stderr output, relative to the current folder")
cmd.Flags().BoolVar(&f.disabled, "no-log-file", false,
"Do not write a log file; log to the TUI/stderr only")
cmd.Flags().StringVar(&f.maxSize, "log-max-size", defaultLogMaxSize,
"Rotate the log file once it exceeds this size. 0 disables rotation. Accepts 512KB, 10MB, ...")
cmd.Flags().IntVar(&f.backups, "log-backups", defaultLogBackups,
"Rotated log generations kept (retask.log.1 ... retask.log.N). 0 keeps none")
cmd.Flags().BoolVar(&f.hidePath, "no-log-path", false,
"Hide the log file path from the TUI Logs divider")
}

// resolve applies the environment fallbacks and returns the agentfleet log file
// config plus whether the TUI should show the path. A flag always wins over its
// environment variable.
func (f *logFileFlags) resolve(cmd *cobra.Command) (cfg agentfleet.LogFileConfig, showPath bool, err error) {
path := f.path
if v, ok := os.LookupEnv("RETASK_SANDBOX_LOG_FILE"); ok && !cmd.Flags().Changed("log-file") {
path = v
}
disabled := f.disabled
if os.Getenv("RETASK_SANDBOX_NO_LOG_FILE") == "1" && !cmd.Flags().Changed("no-log-file") {
disabled = true
}

maxSize := f.maxSize
if v := os.Getenv("RETASK_SANDBOX_LOG_MAX_SIZE"); v != "" && !cmd.Flags().Changed("log-max-size") {
maxSize = v
}
maxBytes, err := parseByteSize(maxSize)
if err != nil {
return cfg, false, fmt.Errorf("--log-max-size: %w", err)
}

backups := f.backups
if v := os.Getenv("RETASK_SANDBOX_LOG_BACKUPS"); v != "" && !cmd.Flags().Changed("log-backups") {
backups, err = strconv.Atoi(v)
if err != nil {
return cfg, false, fmt.Errorf("RETASK_SANDBOX_LOG_BACKUPS: invalid count %q", v)
}
}
if backups < 0 {
return cfg, false, fmt.Errorf("--log-backups: must not be negative")
}

showPath = !f.hidePath
if os.Getenv("RETASK_SANDBOX_NO_LOG_PATH") == "1" && !cmd.Flags().Changed("no-log-path") {
showPath = false
}

// An empty path is the same intent as --no-log-file, so both spellings work.
cfg = agentfleet.LogFileConfig{
Enabled: !disabled && path != "",
Path: path,
MaxBytes: int64(maxBytes),
Backups: backups,
}
return cfg, showPath, nil
}
Loading