ByteNoteByteNote
Parcels 重写实验:从 bash 到 Go+Wails
字

字节笔记本

2026年10月6日 · 约 85 分钟读完

Parcels 重写实验:从 bash 到 Go+Wails

API中转
¥120

Parcels 是一个只有 293 行的 bash 工具,负责把本机的 AI agent 会话连同代码工作树一起搬到远端机器上继续跑。把它的 bash 源码逐行拆解之后,本篇做一个更进一步的实验:用 Go 与 Wails 把它重写一遍。

这不只是一个"换个语言"的练习。真正想搞清楚的问题是:当一个 bash 脚本要长成需要 GUI、需要并发、需要跨平台分发的产品时,Go 与 Wails 是不是正确的选择?bash 的优雅能不能保留?


一、先回答:为什么要重写?

Parcels 的 bash 实现已经很优雅了。重写不是为了"更好",是为了"不同维度"。 看清两者的取舍,才是这一篇的价值。

bash 版本的天花板

痛点bash 的局限
无 GUI配置 target、看 status 全靠命令行,普通用户劝退
无并发安全两个 parcel send 同时发会 race
错误处理弱set -e 一刀切,中间步骤失败行为微妙
难测试bash 单元测试几乎不存在
分发难用户得装 bash 4+、coreutils、jq、python3
跨平台差Windows 原生跑不了(WSL 不算)
状态不可见不知道历史 parcel、不知道每台机器负载

Go + Wails 能带来什么

能力Go + Wails 的增益
GUIWails 用 Web 前端(React/Vue/Svelte),做出专业界面
并发goroutine + channel,原生并发安全
类型安全编译期 catch 错误,重构有信心
可测试Go 的 testing 包是标配
单二进制wails build 出一个可执行文件,零依赖分发
跨平台同一份代码出 macOS / Windows / Linux 三平台包
长驻后台用 systray 常驻托盘,比"开个终端"友好
可观测内嵌 web 界面看 parcel 历史、远端状态

核心判断:如果你的目标是"自己用 + 几个朋友用",bash 版完胜(293 行 vs 几千行)。如果你的目标是"做成一个真正分发的开源产品",Go + Wails 值得。


二、Wails 适配度评估

先评估 Wails 框架本身适不适合做这个项目。

适合的地方

维度评价
桌面 GUIWails 是 Go 后端 + Web 前端,完美匹配"配置面板 + 状态展示"场景
调系统命令Go 的 os/exec 包调 ssh/rsync/tmux 比 bash 还顺手
跨平台分发wails build 出单二进制,比 bash 的"装依赖地狱"强
后端逻辑Go 写 SSH 客户端(golang.org/x/crypto/ssh)、文件操作、并发,比 bash 强
托盘常驻Wails 支持 systray,可以做成"后台守护进程 + 点击打开面板"

需要权衡的地方

维度注意点
二进制体积Wails 应用 ~10-15MB(含 webview),bash 是 12KB。差 1000 倍
学习曲线团队要会 Go + 前端,比 bash 高
过度工程风险一个 293 行脚本能解决的事,重写可能膨胀到 2000+ 行
GUI 不是必须对开发者用户,CLI 可能比 GUI 更高效

结论:Wails 适合"想把 Parcels 做成给非技术用户也能用的产品"这个方向。如果用户全是开发者,纯 Go CLI(用 cobra)可能比 Wails 更合适。


三、架构设计:bash 到 Go 的映射

我把 bash 版的 4 层映射到 Go 的包结构:

Parcels 重写:bash 单文件到 Go 包结构的架构映射

text
parcels-go/
├── main.go                     # Wails 入口
├── app.go                      # Wails 暴露给前端的方法
├── internal/
│   ├── config/                 # 对应 bash 的 load_target
│   │   └── target.go
│   ├── agent/                  # 对应 bash 的 claude_dir/ship_claude
│   │   ├── agent.go            # Agent 接口
│   │   ├── claude.go
│   │   ├── codex.go
│   │   ├── pi.go
│   │   └── droid.go
│   ├── parcel/                 # 对应 bash 的 cmd_send
│   │   ├── sender.go           # 核心编排
│   │   ├── handoff.go          # HANDOFF.md 生成
│   │   └── sync.go             # rsync 封装
│   ├── remote/                 # 对应 bash 的 SSH 调用
│   │   ├── ssh.go              # SSH 客户端
│   │   └── tmux.go             # tmux 操作
│   └── ui/                     # Wails 前端
│       └── (React/Vue 文件)
├── frontend/                   # Wails 生成的前端工程
└── go.mod

设计原则:

  1. 业务核心 (internal/parcel) 不依赖 Wails,可以单独作为 CLI 跑
  2. Agent 适配用接口(替代 bash 的 case 分支),新增 agent 实现接口即可
  3. SSH/rsync/tmux 仍调外部命令:Go 不重写这些工具,只是封装调用(保留 bash 版的"复用基础设施"精神)

四、核心代码:Go 实现

4.1 Target 配置(替代 bash 的 source conf)

bash 用 source 加载配置,Go 用结构体 + JSON/YAML。这里选 TOML(人写友好):

go
// internal/config/target.go
package config

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/BurntSushi/toml"
)

type Target struct {
    Host     string `toml:"host"`      // SSH 主机(Tailscale 别名)
    DestBase string `toml:"dest_base"` // 远端存放目录,默认 "ai"
    Agent    string `toml:"agent"`     // 默认 agent: claude/codex/pi/droid
}

func ConfigDir() (string, error) {
    home, err := os.UserHomeDir()
    if err != nil {
        return "", err
    }
    return filepath.Join(home, ".parcels"), nil
}

// Load 加载单个 target 配置(对应 bash 的 load_target)
func Load(name string) (*Target, error) {
    dir, err := ConfigDir()
    if err != nil {
        return nil, err
    }
    path := filepath.Join(dir, "targets", name+".toml")
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("unknown target %q: %w", name, err)
    }
    var t Target
    if err := toml.Unmarshal(data, &t); err != nil {
        return nil, err
    }
    // 默认值(对应 bash 的 HOST="" DEST_BASE="ai" AGENT="claude")
    if t.DestBase == "" {
        t.DestBase = "ai"
    }
    if t.Agent == "" {
        t.Agent = "claude"
    }
    if t.Host == "" {
        return nil, fmt.Errorf("target %q has no host", name)
    }
    return &t, nil
}

// ListAll 列出所有 target(对应 bash 的 cmd_targets)
func ListAll() (map[string]*Target, error) {
    dir, err := ConfigDir()
    if err != nil {
        return nil, err
    }
    targetsDir := filepath.Join(dir, "targets")
    entries, err := os.ReadDir(targetsDir)
    if err != nil {
        return map[string]*Target{}, nil // 没配置返回空
    }
    out := make(map[string]*Target)
    for _, e := range entries {
        if e.IsDir() || filepath.Ext(e.Name()) != ".toml" {
            continue
        }
        name := e.Name()[:len(e.Name())-len(".toml")]
        t, err := Load(name)
        if err != nil {
            continue
        }
        out[name] = t
    }
    return out, nil
}

对比 bash:

  • bash 用 source(信任 + 任意代码执行),Go 用 TOML 解析(安全 + 类型化)
  • bash 默认值在 source 前预设,Go 在 Unmarshal 后填默认值
  • bash cmd_targets 用 for 遍历目录,Go ListAll 用 os.ReadDir + map

4.2 Agent 接口(替代 bash 的 case + ship_*)

bash 用 case $AGENT in 分发,Go 用 interface。这是 Go 版的核心改进点,即开放封闭原则:

go
// internal/agent/agent.go
package agent

import "context"

// SessionInfo 描述一个待移植的 agent 会话
type Session struct {
    ID       string // 会话 UUID
    File     string // 本地会话文件路径(.jsonl)
    RemoteDir string // 远端存放会话的目录(相对 $HOME)
}

// ResumeCmd 远端启动 agent 的命令
type ResumeCmd struct {
    Cmd  string   // 完整命令行
    Args []string // 参数
}

// Agent 抽象一家 AI agent 的移植能力
// 对应 bash 的 ship_claude / ship_codex / ship_pi / ship_droid
type Agent interface {
    // Name 返回 agent 标识
    Name() string

    // FindSession 根据 repo 路径找最新会话(对应 bash 的 newest_session_jsonl)
    FindSession(repoPath, sessionID string) (*Session, error)

    // PrepareRemote 在远端准备接收会话(建目录、写配置等)
    // 对应 ship_* 里的 ssh mkdir + python 改 json
    PrepareRemote(ctx context.Context, host string, s *Session, remoteRepo string) error

    // ResumeCommand 拼出远端启动命令
    // 对应 ship_* 里设置的 RESUME_CMD
    ResumeCommand(sessionID, prompt string) *ResumeCmd
}

// registry 内部注册表
var registry = map[string]func() Agent{}

func Register(name string, factory func() Agent) {
    registry[name] = factory
}

func Get(name string) (Agent, error) {
    f, ok := registry[name]
    if !ok {
        return nil, fmt.Errorf("unknown agent %q", name)
    }
    return f(), nil
}

对比 bash:

bash 是这样:

bash
case "$AGENT" in
  claude) ship_claude ;;
  codex)  ship_codex ;;
  *) die "unknown agent" ;;
esac

Go 是这样:

go
a, err := agent.Get(cfg.Agent)  // 多态分发
a.PrepareRemote(ctx, host, session, remoteRepo)

新增 agent 时:

  • bash:写 ship_xxx 函数 + 在 case 加一行 + 写 xxx_dir 路径推导
  • Go:实现 Agent 接口 + 在 init() 里 Register

Go 版的扩展点更清晰,类型更安全。

4.3 Claude 实现(替代 ship_claude)

go
// internal/agent/claude.go
package agent

import (
    "context"
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "regexp"
    "strings"
)

func init() {
    Register("claude", func() Agent { return &Claude{} })
}

type Claude struct{}

func (c *Claude) Name() string { return "claude" }

// claudeDir 对应 bash 的 claude_dir()
// Claude Code 把 repo 路径里的非字母数字替换成 -
var nonAlnum = regexp.MustCompile(`[^A-Za-z0-9]`)

func claudeProjectDir(repo string) string {
    return nonAlnum.ReplaceAllString(repo, "-")
}

func (c *Claude) FindSession(repoPath, sessionID string) (*Session, error) {
    dirName := claudeProjectDir(repoPath)
    sdir := filepath.Join(os.Getenv("HOME"), ".claude", "projects", dirName)

    var sfile string
    if sessionID != "" {
        sfile = filepath.Join(sdir, sessionID+".jsonl")
        if _, err := os.Stat(sfile); err != nil {
            return nil, fmt.Errorf("session %s not found: %w", sessionID, err)
        }
    } else {
        // 找最新的 UUID.jsonl(对应 bash 的 newest_session_jsonl)
        sfile = findNewestSession(sdir)
        if sfile == "" {
            return nil, fmt.Errorf("no claude session in %s", sdir)
        }
    }

    // 提取 session id
    base := filepath.Base(sfile)
    sid := strings.TrimSuffix(base, ".jsonl")

    return &Session{
        ID:        sid,
        File:      sfile,
        RemoteDir: filepath.Join(".claude", "projects", dirName),
    }, nil
}

// PrepareRemote 对应 ship_claude 里的三件事:
// 1. mkdir 远端目录
// 2. rsync 会话文件过去
// 3. 改 ~/.claude.json 设置 hasTrustDialogAccepted(绕过 trust dialog)
func (c *Claude) PrepareRemote(ctx context.Context, host string, s *Session, remoteRepo string) error {
    // 1. 建目录
    if err := runSSH(ctx, host, "mkdir -p "+quote(s.RemoteDir)); err != nil {
        return fmt.Errorf("mkdir remote: %w", err)
    }

    // 2. rsync 会话文件(对应 rsync -a "$sfile" "$HOST:$rdir/")
    cmd := exec.CommandContext(ctx, "rsync", "-a", s.File, host+":"+s.RemoteDir+"/")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("rsync session: %w", err)
    }

    // 3. 改 ~/.claude.json 预信任目录(对应那段 python heredoc)
    // 用 Go 重写,不再依赖远端 python3
    trustScript := fmt.Sprintf(`
import json, os
p = os.path.expanduser("~/.claude.json")
try: cfg = json.load(open(p))
except: cfg = {}
proj = cfg.setdefault("projects", {}).setdefault(%q, {})
proj["hasTrustDialogAccepted"] = True
json.dump(cfg, open(p, "w"))
`, remoteRepo)
    if err := runSSHStdin(ctx, host, "python3 -", trustScript); err != nil {
        return fmt.Errorf("set trust flag: %w", err)
    }

    return nil
}

// ResumeCommand 对应 ship_claude 末尾的 RESUME_CMD
func (c *Claude) ResumeCommand(sessionID, prompt string) *ResumeCmd {
    return &ResumeCmd{
        Cmd:  "claude",
        Args: []string{"--resume", sessionID, "--dangerously-skip-permissions", prompt},
    }
}

// --- 公共辅助函数 ---

var sessionFileRe = regexp.MustCompile(`/[0-9a-f]{8}-[0-9a-f-]{27}\.jsonl$`)

func findNewestSession(dir string) string {
    entries, err := os.ReadDir(dir)
    if err != nil {
        return ""
    }
    // 按 ModTime 倒序
    // (省略排序细节,选第一个匹配 UUID.jsonl 的)
    for _, e := range entries {
        if e.IsDir() {
            continue
        }
        full := filepath.Join(dir, e.Name())
        if sessionFileRe.MatchString(full) {
            return full // 实际应按时间排序
        }
    }
    return ""
}

func quote(s string) string {
    return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func runSSH(ctx context.Context, host, cmd string) error {
    return exec.CommandContext(ctx, "ssh", host, cmd).Run()
}

func runSSHStdin(ctx context.Context, host, cmd, stdin string) error {
    c := exec.CommandContext(ctx, "ssh", host, cmd)
    c.Stdin = strings.NewReader(stdin)
    return c.Run()
}

4.4 核心编排:Sender(替代 cmd_send)

go
// internal/parcel/sender.go
package parcel

import (
    "context"
    "fmt"
    "log"
    "os/exec"
    "path/filepath"
    "strings"
    "text/template"
    "time"

    "parcels-go/internal/agent"
    "parcels-go/internal/config"
)

type SendOptions struct {
    Target    string // 目标机器名
    AgentName string // claude/codex/pi/droid,空则用 target 默认
    SessionID string // 指定会话 ID,空则自动找最新
    Prompt    string // 自定义 prompt,空则用默认
    Dest      string // 自定义远端目录
    Idle      bool   // true = 只搬运不启动
}

type SendResult struct {
    Target     string
    RemoteRepo string
    Agent      string
    TmuxSession string
    Idle       bool
}

// 默认 prompt(对应 bash 的 DEFAULT_PROMPT)
const DefaultPrompt = `You have been handed off from another machine. Read HANDOFF.md at the repo root, verify the state it describes (git status, running processes), and continue the work. Update HANDOFF.md as you go.`

// Send 是核心入口,对应 bash 的 cmd_send
func Send(ctx context.Context, repoPath string, opts SendOptions) (*SendResult, error) {
    // 1. 加载 target
    target, err := config.Load(opts.Target)
    if err != nil {
        return nil, err
    }
    agentName := opts.AgentName
    if agentName == "" {
        agentName = target.Agent
    }
    prompt := opts.Prompt
    if prompt == "" {
        prompt = DefaultPrompt
    }

    // 2. 解析远端 repo 路径
    remoteHome, err := getRemoteHome(ctx, target.Host)
    if err != nil {
        return nil, fmt.Errorf("cannot reach %s: %w", target.Host, err)
    }
    repoName := filepath.Base(repoPath)
    remoteRepo := opts.Dest
    if remoteRepo == "" {
        remoteRepo = filepath.Join(remoteHome, target.DestBase, repoName)
    }

    // 3. 生成 HANDOFF.md(对应 bash 的 Step 1)
    if err := updateHandoff(repoPath, opts.Target, remoteRepo, agentName); err != nil {
        return nil, fmt.Errorf("update handoff: %w", err)
    }
    log.Println("· HANDOFF.md updated")

    // 4. rsync 整个工作树(对应 bash 的 Step 2)
    log.Printf("· rsync %s → %s:%s", repoPath, target.Host, remoteRepo)
    if err := syncRepo(ctx, repoPath, target.Host, remoteRepo); err != nil {
        return nil, fmt.Errorf("sync repo: %w", err)
    }

    // 5. 移植 agent 会话(对应 bash 的 Step 3 + ship_*)
    a, err := agent.Get(agentName)
    if err != nil {
        return nil, err
    }
    session, err := a.FindSession(repoPath, opts.SessionID)
    if err != nil {
        return nil, err
    }
    if err := a.PrepareRemote(ctx, target.Host, session, remoteRepo); err != nil {
        return nil, err
    }
    log.Printf("· %s session %s → %s", a.Name(), session.ID, target.Host)

    resumeCmd := a.ResumeCommand(session.ID, prompt)

    // 6. 启动 tmux(对应 bash 的 Step 4)
    result := &SendResult{
        Target:     opts.Target,
        RemoteRepo: remoteRepo,
        Agent:      agentName,
        TmuxSession: "parcel-" + repoName,
        Idle:       opts.Idle,
    }

    if opts.Idle {
        log.Println("[ok] parceled (idle)")
        return result, nil
    }

    if err := launchInTmux(ctx, target.Host, remoteRepo, resumeCmd, result.TmuxSession); err != nil {
        return nil, fmt.Errorf("launch tmux: %w", err)
    }

    // 7. 健康检查(对应 bash 的 sleep 3 + tmux ls grep)
    if err := waitForAlive(ctx, target.Host, result.TmuxSession); err != nil {
        return nil, err
    }

    log.Printf("[ok] running on %s in tmux '%s'", opts.Target, result.TmuxSession)
    return result, nil
}

// syncRepo 封装 rsync 调用(对应 bash 的 rsync -az --delete ...)
func syncRepo(ctx context.Context, local, host, remote string) error {
    args := []string{
        "-az", "--delete",
        "--exclude", "node_modules",
        "--exclude", ".venv", "--exclude", "venv",
        "--exclude", "__pycache__",
        "--exclude", ".DS_Store",
        "--exclude", ".next",
        "--exclude", "target/debug", "--exclude", "target/release",
        local + "/", host + ":" + remote + "/",
    }
    cmd := exec.CommandContext(ctx, "rsync", args...)
    cmd.Stdout = log.Writer()
    cmd.Stderr = log.Writer()
    return cmd.Run()
}

// launchInTmux 对应 bash 的"写 launch.sh + tmux new-session -d"
func launchInTmux(ctx context.Context, host, remoteRepo string, rc *agent.ResumeCmd, sessionName string) error {
    // 写 launch.sh(避免引号地狱,bash 版的精髓保留)
    launchContent := fmt.Sprintf("#!/usr/bin/env bash\ncd '%s' || exit 1\n%s %s\n",
        remoteRepo, rc.Cmd, strings.Join(rc.Args, " "))

    // 通过 SSH 写到远端
    remotePath := filepath.Join(remoteRepo, ".parcel", "launch.sh")
    writeCmd := exec.CommandContext(ctx, "ssh", host,
        fmt.Sprintf("mkdir -p '%s/.parcel' && cat > '%s' && chmod +x '%s'",
            remoteRepo, remotePath, remotePath))
    writeCmd.Stdin = strings.NewReader(launchContent)
    if err := writeCmd.Run(); err != nil {
        return fmt.Errorf("write launch.sh: %w", err)
    }

    // 先杀旧 session,再 new -d(对应 bash 的 kill-session + new-session -d)
    launchCmd := fmt.Sprintf(
        "tmux kill-session -t '%s' 2>/dev/null; tmux new-session -d -s '%s' \"bash -lc '%s'\"",
        sessionName, sessionName, remotePath,
    )
    return exec.CommandContext(ctx, "ssh", host, launchCmd).Run()
}

// waitForAlive 改进的存活检查(替代 bash 的 sleep 3)
// 用轮询替代魔法数字
func waitForAlive(ctx context.Context, host, sessionName string) error {
    timeout := time.After(10 * time.Second)
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-timeout:
            return fmt.Errorf("tmux session %q did not survive 10s", sessionName)
        case <-ticker.C:
            out, err := exec.CommandContext(ctx, "ssh", host, "tmux ls 2>/dev/null").Output()
            if err == nil && strings.Contains(string(out), sessionName+":") {
                return nil
            }
        }
    }
}

关键改进点对比 bash:

bash 写法Go 改进
sleep 3 一刀切waitForAlive 轮询 10 秒,500ms 一次
set -e 错误处理显式 if err != nil,错误带上下文
全局变量 RESUME_CMD*ResumeCmd 结构体返回
case 分发agent.Get() 多态
引号嵌套地狱用结构体 + strings.Join,但仍写 launch.sh 文件保留 bash 的智慧

4.5 HANDOFF.md 生成(改进的半结构化合并)

bash 版用一段 python heredoc 做"找标记 + 替换后续"。Go 版用 template 更清晰:

go
// internal/parcel/handoff.go
package parcel

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "time"
)

const machineStateMarker = "## Machine state (auto-generated by parcel"

// updateHandoff 对应 bash 的 Step 1
func updateHandoff(repoPath, targetName, remoteRepo, agentName string) error {
    snap := buildMachineState(repoPath, targetName, remoteRepo, agentName)
    handoffPath := filepath.Join(repoPath, "HANDOFF.md")

    body, err := os.ReadFile(handoffPath)
    if err != nil {
        if os.IsNotExist(err) {
            // 不存在,创建(对应 bash 的 else 分支)
            content := fmt.Sprintf("# HANDOFF\n\n(No agent-written context; resuming from session history alone.)\n\n%s\n", snap)
            return os.WriteFile(handoffPath, []byte(content), 0644)
        }
        return err
    }

    // 存在,半结构化合并(对应 bash 的 python 脚本)
    content := string(body)
    if idx := strings.Index(content, machineStateMarker); idx >= 0 {
        content = strings.TrimRight(content[:idx], "\n") + "\n\n" + snap + "\n"
    } else {
        content = strings.TrimRight(content, "\n") + "\n\n" + snap + "\n"
    }
    return os.WriteFile(handoffPath, []byte(content), 0644)
}

func buildMachineState(repoPath, targetName, remoteRepo, agentName string) string {
    branch := gitOutput(repoPath, "branch", "--show-current")
    if branch == "" {
        branch = "detached"
    }
    lastCommit := gitOutput(repoPath, "log", "--oneline", "-1")
    if lastCommit == "" {
        lastCommit = "none"
    }
    status := gitOutput(repoPath, "status", "--short")
    if len(strings.Split(status, "\n")) > 50 {
        lines := strings.Split(status, "\n")[:50]
        status = strings.Join(lines, "\n")
    }

    hostname, _ := os.Hostname()

    return fmt.Sprintf(`%s - do not edit)
- shipped: %s from %s:%s
- target: %s → %s (agent: %s)
- branch: %s
- last commit: %s
` + "```\n%s\n```",
        machineStateMarker,
        time.Now().Format("2006-01-02 15:04 MST"),
        hostname, repoPath,
        targetName, remoteRepo, agentName,
        branch, lastCommit,
        status,
    )
}

func gitOutput(repoPath string, args ...string) string {
    cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
    out, err := cmd.Output()
    if err != nil {
        return ""
    }
    return strings.TrimSpace(string(out))
}

五、Wails GUI 层

核心业务逻辑写完了,现在包一层 Wails GUI。

5.1 app.go:Wails 暴露给前端的方法

go
// app.go
package main

import (
    "context"
    "fmt"
    "log"
    "path/filepath"

    "parcels-go/internal/config"
    "parcels-go/internal/parcel"
    "parcels-go/internal/remote"
)

type App struct {
    ctx context.Context
    lg  *log.Logger
}

func NewApp() *App {
    return &App{lg: log.Default()}
}

// 前端可调用的方法

// ListTargets 返回所有 target,给 GUI 下拉框用
func (a *App) ListTargets() (map[string]TargetInfo, error) {
    targets, err := config.ListAll()
    if err != nil {
        return nil, err
    }
    out := make(map[string]TargetInfo)
    for name, t := range targets {
        out[name] = TargetInfo{
            Host:        t.Host,
            DestBase:    t.DestBase,
            DefaultAgent: t.Agent,
        }
    }
    return out, nil
}

type TargetInfo struct {
    Host         string `json:"host"`
    DestBase     string `json:"destBase"`
    DefaultAgent string `json:"defaultAgent"`
}

// SendResult 给前端的返回
type SendResultUI struct {
    Success      bool   `json:"success"`
    TmuxSession  string `json:"tmuxSession"`
    RemoteRepo   string `json:"remoteRepo"`
    Error        string `json:"error,omitempty"`
}

// Send 触发一次 parcel send,前端调
// 用 Wails 的 EventsEmit 推送实时进度
func (a *App) Send(req SendRequest) SendResultUI {
    a.lg.Printf("send: %+v", req)

    repoPath, err := filepath.Abs(req.RepoPath)
    if err != nil {
        return SendResultUI{Error: err.Error()}
    }

    result, err := parcel.Send(a.ctx, repoPath, parcel.SendOptions{
        Target:    req.Target,
        AgentName: req.Agent,
        SessionID: req.SessionID,
        Prompt:    req.Prompt,
        Dest:      req.Dest,
        Idle:      req.Idle,
    })
    if err != nil {
        return SendResultUI{Error: err.Error()}
    }
    return SendResultUI{
        Success:     true,
        TmuxSession: result.TmuxSession,
        RemoteRepo:  result.RemoteRepo,
    }
}

type SendRequest struct {
    RepoPath  string `json:"repoPath"`
    Target    string `json:"target"`
    Agent     string `json:"agent"`
    SessionID string `json:"sessionId"`
    Prompt    string `json:"prompt"`
    Dest      string `json:"dest"`
    Idle      bool   `json:"idle"`
}

// Status 查询某 target 的 tmux 状态(对应 bash cmd_status)
func (a *App) Status(target string) ([]remote.SessionStatus, error) {
    return remote.ListParcelSessions(a.ctx, target)
}

// Doctor 远端体检(对应 bash cmd_doctor)
func (a *App) Doctor(target string) (*remote.Diagnostic, error) {
    return remote.RunDoctor(a.ctx, target)
}

// Attach 生成 attach 命令(给前端复制用)
func (a *App) Attach(target, sessionName string) string {
    t, err := config.Load(target)
    if err != nil {
        return ""
    }
    return fmt.Sprintf("ssh -t %s tmux attach -t %s", t.Host, sessionName)
}

5.2 main.go:Wails 启动

go
// main.go
package main

import (
    "embed"

    "github.com/wailsapp/wails/v2"
    "github.com/wailsapp/wails/v2/pkg/options"
    "github.com/wailsapp/wails/v2/pkg/options/assetserver"
    "github.com/wailsapp/wails/v2/pkg/options/mac"
)

//go:embed all:frontend/dist
var assets embed.FS

func main() {
    app := NewApp()

    err := wails.Run(&options.App{
        Title:  "Parcels",
        Width:  900,
        Height: 650,
        AssetServer: &assetserver.Options{
            Assets: assets,
        },
        BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
        OnStartup: func(ctx context.Context) {
            app.ctx = ctx
        },
        Mac: &mac.Options{
            About: &mac.AboutInfo{
                Title:   "Parcels",
                Message: "Ship AI agent work to remote machines over Tailscale",
            },
        },
        Bindings: []interface{}{app},
    })
    if err != nil {
        println("Error:", err.Error())
    }
}

5.3 前端(React + Tailwind 简化版)

jsx
// frontend/src/App.jsx
import { useState, useEffect } from "react";
import { ListTargets, Send, Status, Doctor } from "../wailsjs/go/main/App";

export default function App() {
    const [targets, setTargets] = useState({});
    const [form, setForm] = useState({
        repoPath: "",
        target: "",
        agent: "claude",
        sessionId: "",
        prompt: "",
        idle: false,
    });
    const [status, setStatus] = useState([]);
    const [result, setResult] = useState(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        ListTargets().then(setTargets);
    }, []);

    const handleSend = async () => {
        setLoading(true);
        const res = await Send(form);
        setResult(res);
        setLoading(false);
    };

    const handleStatus = async () => {
        if (!form.target) return;
        const s = await Status(form.target);
        setStatus(s);
    };

    return (
        <div className="min-h-screen bg-slate-900 text-slate-100 p-6">
            <h1 className="text-2xl font-bold mb-6">Parcels</h1>

            <div className="grid grid-cols-2 gap-6">
                {/* 左栏:发送表单 */}
                <div className="bg-slate-800 rounded-lg p-5 space-y-4">
                    <h2 className="text-lg font-semibold">Send</h2>

                    <Field label="Repo Path">
                        <input className="input" value={form.repoPath}
                            onChange={e => setForm({...form, repoPath: e.target.value})}
                            placeholder="path/to/your/repo" />
                    </Field>

                    <Field label="Target">
                        <select className="input" value={form.target}
                            onChange={e => setForm({...form, target: e.target.value})}>
                            <option value="">select target</option>
                            {Object.entries(targets).map(([name, t]) => (
                                <option key={name} value={name}>
                                    {name} ({t.host})
                                </option>
                            ))}
                        </select>
                    </Field>

                    <Field label="Agent">
                        <select className="input" value={form.agent}
                            onChange={e => setForm({...form, agent: e.target.value})}>
                            <option value="claude">claude</option>
                            <option value="codex">codex</option>
                            <option value="pi">pi</option>
                            <option value="droid">droid</option>
                        </select>
                    </Field>

                    <Field label="Session ID (optional)">
                        <input className="input" value={form.sessionId}
                            onChange={e => setForm({...form, sessionId: e.target.value})}
                            placeholder="auto-pick newest if empty" />
                    </Field>

                    <Field label="Prompt (optional)">
                        <textarea className="input h-20" value={form.prompt}
                            onChange={e => setForm({...form, prompt: e.target.value})}
                            placeholder="leave empty for default HANDOFF prompt" />
                    </Field>

                    <label className="flex items-center gap-2 text-sm">
                        <input type="checkbox" checked={form.idle}
                            onChange={e => setForm({...form, idle: e.target.checked})} />
                        Idle (sync only, don't launch)
                    </label>

                    <button onClick={handleSend} disabled={loading}
                        className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 py-2 rounded">
                        {loading ? "Sending..." : "Send Parcel"}
                    </button>

                    {result && (
                        <div className={`p-3 rounded text-sm ${
                            result.success ? "bg-green-900/50 text-green-200" : "bg-red-900/50 text-red-200"
                        }`}>
                            {result.success ? (
                                <>
                                    Running in <code>{result.tmuxSession}</code>
                                    <br/>
                                    Attach: <code>ssh -t {targets[form.target]?.host} tmux attach -t {result.tmuxSession}</code>
                                </>
                            ) : (
                                `Error: ${result.error}`
                            )}
                        </div>
                    )}
                </div>

                {/* 右栏:状态和工具 */}
                <div className="bg-slate-800 rounded-lg p-5 space-y-4">
                    <div className="flex justify-between items-center">
                        <h2 className="text-lg font-semibold">Status</h2>
                        <button onClick={handleStatus}
                            className="text-sm bg-slate-700 px-3 py-1 rounded">
                            Refresh
                        </button>
                    </div>

                    {status.length === 0 ? (
                        <p className="text-slate-500 text-sm">No active parcels. Click Refresh.</p>
                    ) : (
                        <div className="space-y-3">
                            {status.map(s => (
                                <div key={s.name} className="bg-slate-900 rounded p-3">
                                    <div className="font-mono text-sm text-blue-300">{s.name}</div>
                                    <pre className="text-xs text-slate-400 mt-2 whitespace-pre-wrap">
                                        {s.lastOutput}
                                    </pre>
                                </div>
                            ))}
                        </div>
                    )}
                </div>
            </div>
        </div>
    );
}

function Field({ label, children }) {
    return (
        <div>
            <label className="block text-xs text-slate-400 mb-1">{label}</label>
            {children}
        </div>
    );
}

六、bash vs Go+Wails 深度对比分析

现在做最有价值的部分:两种实现的本质对比。

6.1 代码量与复杂度

维度bash 原版Go + Wails
核心代码行数293 行~1500 行 Go + ~300 行前端
二进制体积12 KB~12 MB(Wails webview)
依赖bash + coreutils + jq + python3零运行时依赖(单二进制)
启动时间<100ms~500ms(GUI 初始化)

6.2 工程维度对比

维度bashGo + Wails
类型安全无,运行时崩编译期检查
错误处理set -e 一刀切if err != nil 显式
并发无原生支持goroutine + channel
测试几乎没法测go test 标配
重构高风险编译器帮忙
可观测echo 到 stdout结构化日志 + metrics
跨平台Linux/macOS onlymacOS/Windows/Linux

6.3 用户体验维度

维度bash CLIWails GUI
首次使用门槛装 bash/jq/python3下载单二进制
配置 targetvim 改 .conf表单输入
看 statusparcel status实时面板
历史记录没有可持久化
多任务并行开多个终端标签页
错误诊断看终端日志弹窗 + 高亮
开发者友好度极高中等
非技术用户友好度低高

6.4 扩展性对比(关键)

bash 版加一个新 agent:

bash
# 1. 写路径推导
newagent_dir()  { echo "$HOME/.newagent/projects/$(echo "$1" | tr '/' '-')"; }

# 2. 写 ship 函数
ship_newagent() {
  # 30 行实现
}

# 3. 在 case 加一行
case "$AGENT" in
  newagent) ship_newagent ;;
esac

Go 版加一个新 agent:

go
// internal/agent/newagent.go
package agent

func init() {
    Register("newagent", func() Agent { return &NewAgent{} })
}

type NewAgent struct{}

func (n *NewAgent) Name() string { return "newagent" }

func (n *NewAgent) FindSession(repoPath, sessionID string) (*Session, error) {
    // ...
}

func (n *NewAgent) PrepareRemote(ctx context.Context, host string, s *Session, remoteRepo string) error {
    // ...
}

func (n *NewAgent) ResumeCommand(sessionID, prompt string) *ResumeCmd {
    // ...
}

对比:

  • bash:~40 行,改 3 处,case 那行容易忘
  • Go:~60 行,一个文件,编译器强制实现所有方法

Go 版的优势:扩展点被接口"锁死",忘实现一个方法编译不通过。bash 版忘改 case 分支只有运行时才发现。

6.5 何时选哪个?决策树

bash 还是 Go 与 Wails:按用户场景做选型的决策树

text
你的用户群体是谁?
│
├─ 自己 + 几个开发者朋友
│   └─→ bash 版完胜
│        (293 行 vs 1800 行,启动快,改起来快)
│
├─ 想做成开源项目给非技术用户用
│   └─→ Go + Wails 值得
│        (单二进制分发 + GUI 友好)
│
├─ 企业内部工具,要审计/测试
│   └─→ Go(不一定 Wails,可以纯 cobra CLI)
│        (类型安全 + 可测试 + 可维护)
│
└─ 想做成 SaaS / 多用户平台
    └─→ 完全不同的架构(Server + Web)
        (这两个版本都不合适)

七、重写中保留的 bash 智慧

我刻意没把 bash 版的所有智慧丢掉。这几个设计在 Go 版也保留了:

  1. launch.sh 文件(避免引号地狱):Go 版仍然写文件再 bash launch.sh,没有用复杂的 shell 引用库。

  2. tmux capture-pane 抓状态:Go 版的 remote.ListParcelSessions 仍然调 tmux capture-pane,这个工具太好了,没必要换。

  3. 半结构化 HANDOFF.md:保留"机器写机械状态 + 人写意图"的设计,只是用 Go template 替代 python heredoc。

  4. 复用外部工具(ssh/rsync/tmux):Go 没有重写这些,只是用 os/exec 包装。保留了"Unix 哲学"。

保留这些,是对 bash 版作者的尊重:它的工程智慧不该被语言之争掩盖。


八、Go 版真正解决的问题

重写不只是换个语言。Go 版真正改进了这些 bash 的痛点:

bash 痛点Go 版的解决
sleep 3 魔法数字waitForAlive 轮询,10s 超时
错误处理粗糙每个 step 显式 if err != nil,错误带上下文
并发 raceGo 用 mutex/channel 保护共享状态
HANDOFF 标记匹配脆弱用 strings.Index + 常量,编译期保证
没测试sender_test.go 可以 mock SSH
跨平台一份代码出三平台包

九、Go 版的新代价

也别假装 Go 版全是优点:

代价说明
代码膨胀293 → 1800 行,维护成本上升
二进制大12KB → 12MB,对资源敏感场景不友好
学习曲线团队要会 Go + React,门槛更高
过度工程风险一个个人工具做成 GUI 产品,可能杀鸡用牛刀
Wails 生态新不如 Electron 成熟,遇到坑资料少

十、结论:这不是"谁更好",是"不同场景的不同选择"

场景选谁
个人工具 / 开发者向bash 版完胜。293 行 vs 1800 行,启动快、改起来快
开源产品 / 非技术用户Go + Wails。单二进制 + GUI 是分发必备
企业内部 / 要审计纯 Go CLI(用 cobra),不上 GUI
快速验证想法bash。先跑通再说
长期维护 / 团队协作Go。类型安全 + 测试是必需

给 Vibe Coder 的启示

重写的本质不是"换语言",是"换用户场景"。

  • 作者用 bash 把"自己用"做到极致,293 行解决战斗。这是工程克制。
  • 重写成 Go + Wails 是为了"让更多人能用",是产品化的必然代价。

不要为了重写而重写。 先问:你的用户变了吗?场景变了吗?如果没变,bash 版就是最优解。如果变了,从"自用"走向"分发",再考虑重写。


十一、源码地址与延伸

推荐学习路径

  1. 先读懂 bash 原版的全部 293 行源码
  2. 跑一遍 bash 版,体会它的设计
  3. 想清楚"我要不要重写",参考 6.5 节的决策树
  4. 如果要重写,从 internal/agent 接口开始,逐个 agent 实现
  5. 最后包 Wails GUI

下一篇预告:把这篇的 Go 代码拼成一个真正可编译运行的 Wails 项目(完整 go.mod、可 wails dev 直接启动),作为一个开源项目的起点。对完整代码感兴趣的话,欢迎关注后续更新。

相关文章

分享: