package agents import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" "time" "g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/mcp" "g.fc.run/misc/byre/internal/store" ) const ( bundleTimeout = 5 * time.Minute MaxRunOutput = 64 << 10 ) type runOnClientArgs struct { Paths []string `json:"paths"` Command string `json:"command"` Why string `json:"why"` } func (m *Manager) runOnClientTool(l *live) mcp.Tool { return mcp.Tool{ Name: "run_on_client", Description: "Ask the human to run a command on their own Mac, with files from your worktree, for example to install and launch an app you built. " + "byre packs paths into a bundle now, so later changes to them aren't sent, and files a card in the human's inbox showing why, the command and the files. " + "Every Mac the human has byre open on shows the card with Run and Dismiss; each Mac where they click Run downloads the bundle, unpacks it into a new empty directory, " + "and runs command there with zsh as a login shell, as the human. The directory is deleted once the command finishes, so copy anything that must stay elsewhere first. " + "The command keeps running if it quits byre's app, and the app reports it when it next starts. " + "This call returns at once; you don't need to end your turn. Each Mac's run reaches you as a message with its host name, exit status and the last 64 KiB of its output. " + "The card stays until the human dismisses it, so it can be run again or on another Mac.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "paths": {"type": "array", "items": {"type": "string"}, "description": "Files or directories to send, relative to your worktree; each is unpacked at the same relative path. May be empty."}, "command": {"type": "string", "description": "The zsh command to run in the directory the bundle unpacks into, e.g. 'rm -rf ~/Applications/byre.app && ditto mac/.build/byre.app ~/Applications/byre.app && (pkill -x Byre; open ~/Applications/byre.app)'."}, "why": {"type": "string", "description": "One or two sentences for the human: what running it does and why."} }, "required": ["paths", "command", "why"] }`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { return m.runOnClient(ctx, l, raw) }, } } func (m *Manager) runOnClient(ctx context.Context, l *live, raw json.RawMessage) (string, error) { args := runOnClientArgs{} err := json.Unmarshal(raw, &args) if err != nil { return "", fmt.Errorf("malformed run_on_client call: %w", err) } if strings.TrimSpace(args.Command) == "" { return "", errors.New("command is empty") } if strings.TrimSpace(args.Why) == "" { return "", errors.New("why is empty: say what running it does and why") } paths, err := checkBundlePaths(l.dir, args.Paths) if err != nil { return "", err } id := store.NewID() size, err := m.writeBundle(ctx, l.dir, paths, m.store.BundlePath(id)) if err != nil { return "", err } payload := map[string]any{ "why": args.Why, "command": args.Command, "paths": paths, "bytes": size, } err = m.store.CreateInboxItem(ctx, m.newItem(l, id, id, api.ClientRun, payload)) if err != nil { os.Remove(m.store.BundlePath(id)) return "", err } return fmt.Sprintf("Run request %s filed in the human's inbox with a %d-byte bundle. Each Mac's run will reach you as a message.", id, size), nil } func checkBundlePaths(dir string, paths []string) ([]string, error) { root, err := os.OpenRoot(dir) if err != nil { return nil, err } defer root.Close() out := []string{} for _, p := range paths { clean := filepath.Clean(p) if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, "-") { return nil, fmt.Errorf("path %q must be relative to your worktree and inside it", p) } _, err = root.Lstat(clean) if err != nil { return nil, fmt.Errorf("path %q: %w", p, err) } out = append(out, clean) } return out, nil } func (m *Manager) writeBundle(ctx context.Context, dir string, paths []string, dest string) (int64, error) { err := os.MkdirAll(filepath.Dir(dest), 0o755) if err != nil { return 0, err } tmp := dest + ".tmp" cctx, cancel := context.WithTimeout(ctx, bundleTimeout) defer cancel() // -T /dev/null lets tar write an empty archive when paths is empty. cmd := exec.CommandContext(cctx, "tar", append([]string{"-czf", tmp, "-C", dir, "-T", "/dev/null", "--"}, paths...)...) out, err := cmd.CombinedOutput() if err != nil { os.Remove(tmp) return 0, fmt.Errorf("pack bundle: %w: %s", err, out) } err = os.Rename(tmp, dest) if err != nil { return 0, err } info, err := os.Stat(dest) if err != nil { return 0, err } return info.Size(), nil } func (m *Manager) ReportRun(ctx context.Context, user, itemID string, report api.RunReport) (api.InboxItem, error) { item, err := m.store.GetInboxItem(ctx, itemID) if err != nil { return item, err } if item.Kind != api.ClientRun { return item, fmt.Errorf("%w: item %s is a %s, not a client_run", ErrInvalid, itemID, item.Kind) } client := strings.TrimSpace(report.Client) if client == "" { return item, fmt.Errorf("%w: client is empty", ErrInvalid) } if strings.TrimSpace(report.RunId) == "" { return item, fmt.Errorf("%w: run_id is empty", ErrInvalid) } output := report.Output if len(output) > MaxRunOutput { output = strings.ToValidUTF8(output[len(output)-MaxRunOutput:], "") } resp := api.InboxResponse{ Id: report.RunId, ItemId: itemID, User: user, Type: api.Run, Text: &output, Client: &client, ExitCode: &report.ExitCode, CreatedAt: time.Now().UTC(), } added, err := m.store.AddRunResponse(ctx, resp) if err != nil { return item, err } if !added { slog.Info("client run already reported", "item", itemID, "response", resp.Id, "client", client) return m.store.GetInboxItem(ctx, itemID) } slog.Info("client run reported", "item", itemID, "response", resp.Id, "user", user, "client", client, "exit", report.ExitCode) go func() { err := m.Deliver(context.Background(), item.AgentSessionId) if err != nil { slog.Error("deliver run report", "session", item.AgentSessionId, "err", err) } }() return m.store.GetInboxItem(ctx, itemID) }