diff --git a/docs/operations.md b/docs/operations.md index aed4d25..dd01c73 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -90,7 +90,7 @@ Before the agent gets the prompt, Merge runs `git fetch origin` (every configure Each squash commit gets a git note under `refs/notes/byre`, the durable record of the task (`mergeNote` in `internal/agents/notes.go`): a Markdown heading with the merge title; the task's id, title, creator, merging user, TODO item and fork source; a pointer to the full transcripts as the task's events in byre (`GET /api/events?task_id=`), which the note doesn't copy; the task summary, when the agent wrote one; every handoff its sessions wrote on compaction (the `result` of each `turn.ended` with `handoff: true`); each review's state, error, `change` and `summary`, by snapshot; and each `answered` question with its context and the responses to it. Plans and eval results aren't recorded yet, since byre has neither. `git.NoteCommit` builds the notes commit on top of the checkout's current `refs/notes/byre` in a temporary index, authored as the merging user, without moving the ref, so a refused push leaves the checkout's notes as they were. Notes don't fetch by default, so registering a project (`POST /api/projects`), and every byre startup for each existing project, adds the refspec `refs/notes/byre*:refs/notes/byre*` to the checkout's `remote.origin.fetch` if it isn't there (`git.ConfigureNotesFetch`); it's a pattern because git refuses a fetch whose exact refspec names a ref the remote doesn't have yet, as it doesn't before the first note is pushed. Another clone adds the same refspec with `git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'`. `git log --notes=byre` shows the notes, and `git notes --ref=byre show ` one commit's. -git's network commands (`git fetch` and `git push`, through `runNetwork` in `internal/git/git.go`) are bounded by `networkTimeout` and by the caller's context, and run non-interactively: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` set to `ssh` with `BatchMode=yes` and a connect timeout (`networkEnv`), which replaces any `core.sshCommand` the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. Every git command runs in its own process group, and cancelling one kills the whole group, so an `ssh` or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above. +git's network commands (`git fetch` and `git push`, through `runNetwork` in `internal/git/git.go`) are bounded by `networkTimeout` and by the caller's context, and run non-interactively: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` set to `ssh` with `BatchMode=yes` and a connect timeout (`networkEnv`), which replaces any `core.sshCommand` the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. All Git subprocesses use `runCodeInput`, which preserves stdout bytes for commit signing and NUL-delimited filenames; the text-oriented wrappers trim their output. Every git command runs in its own process group, and cancelling one kills the whole group, so an `ssh` or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above. One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (`projectMerge` in `internal/agents/mergein.go`, in `Manager.merges`), from when `merging_by` is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. It then gives the task a head start (`headStartMerge`): when the task's agent is idle (or it has none), it sends the same merge-in of the default branch as the merge-in action, from the project checkout's local default branch without a fetch, so the later Merge turn has less left to resolve; a running agent skips it. It moves the session to `running` under the project's merge lock, as a direct Merge does, so a held merge can't start in between and find the session taken. The head-start prompt tells the agent to call `stop_merge` if resolving the conflicts took substantive changes; on a task with no `merging_by` but a held merge, `stop_merge` clears the held request (`TakeQueuedMerge`) instead, so nothing is pushed before the review. A held merge whose head-start turn is still running waits for that turn to end, like any running task. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre records the error in the task's transcript and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges. @@ -235,7 +235,7 @@ Task agents spawn parallel work as subtasks (see "Subtasks"). Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`, under the task's mutex, see "Teams"), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit records a `session.error` in the task's transcript and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work. -A session runs on one of three vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), `gemini-*` models on Antigravity (`agy`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to another vendor's model. All vendors sit behind `agentProcess` (`internal/agents/process.go`), and all end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. +A session runs on one of three vendors, picked by the model it is created with (`vendorOf` in `internal/agents/process.go`): `gpt-*` models run on Codex (`codex app-server`), `gemini-*` models on Antigravity (`agy`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to another vendor's model. All vendors sit behind `agentProcess` (`internal/agents/process.go`), and all end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Antigravity does the same with its conversation id, which `agy` reports in its `init` line at launch (`startAntigravity`), resumed with `--conversation`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). A Claude session counts as started, and so is resumed rather than created with `--session-id`, once byre has sent its process a message (`agent_sessions.started`, set by `Manager.send`, read by `Store.SessionStarted`), not when the message is recorded. `agent_sessions.pending` is set in the transaction that records a turn's `user.message` (`Store.RecordTurnMessage`) and cleared when the message is sent. On startup, a `running` session with `pending` set is sent its last recorded `user.message` (`Manager.deliverRecorded`) instead of the restart notice, so a restart between recording a message and sending it, while the process starts, doesn't lose it. Sessions are `running` from the moment a turn is sent until it ends (Claude's and Antigravity's `result`, Codex's `turn/completed`); on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`. Every agent CLI starts in its own process group, and killing one sends `SIGKILL` to the whole group. That way a launcher that runs the real agent as its child, along with the agent's MCP servers, can't keep holding byre's stdout pipe, which would leave the kill waiting forever for end of file. @@ -501,7 +501,7 @@ A workflow is a named tree of steps: stages, each a stage name, a model and the - **Names.** The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (`workflow.CleanName`). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment. - **Definition.** A workflow is a name and a `WorkflowDefinition` (`api/openapi.yaml`): an optional `description` and its `steps`, run in order. Any workflow can run on a brief, each stage its own task, and any whose stages are single agents can be a project's review workflow, each stage a reviewer of every snapshot (see "Reviews"). A step (`WorkflowStep`) is exactly one of a `stage` (its name, with `model` or `agents`, and `prompt`), an `order` (steps one after another) or a `parallel` (branches at the same time). A stage gets the outputs of the stages that end the step before it (the last stage of an `order`, the last stage of each branch of a `parallel`; `workflow.After` derives them by walking the tree), and a stage with nothing before it starts from the brief. Where its task branches from follows from that: from the commit its one earlier stage finished at, from the default branch when it comes after several, and for a stage with none, from where the run was started. In place of `model`, a stage may list `agents` (each `name`, `model` and `prompt`) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls `finish_stage`. It travels as JSON everywhere; `workflow.Parse` reads it, refuses unknown fields and validates it. - **Storage.** Definitions are rows, never text: `workflow_definitions` (`id`, `description`), `workflow_steps` (one per step: `definition_id`, `id` numbered depth-first from 0, `parent` (NULL at the top level), `position` among its siblings, `kind` `stage`, `order` or `parallel`, and a stage's `stage`, `model` and `prompt`) and `workflow_agents` (a team stage's agents: `definition_id`, `step_id`, `position`, `name`, `model`, `prompt`). A definition is never changed once written: a save writes a new one and points `workflows.definition_id` at it, so freezing is keeping the id, in `tasks.workflow_definition_id` and `reviews.definition_id`. `insertDefinition` and `loadDefinition` (`internal/store/definition.go`) write and rebuild the tree. -- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed, each located by its path such as `steps[0].parallel[1]`) with no steps, a step that isn't exactly one of `stage`, `order` and `parallel`, `model`, `prompt` or `agents` on an `order` or `parallel`, an empty `order` or `parallel`, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, or an empty prompt. A workflow may end with several stages (a last `parallel`). The tree can't express a cycle. A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. +- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed, each located by its path such as `steps[0].parallel[1]`) with no steps, a step that isn't exactly one of `stage`, `order` and `parallel`, `model`, `prompt` or `agents` on an `order` or `parallel`, an empty `order` or `parallel`, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, or an empty prompt. A workflow may end with several stages (a last `parallel`). The tree can't express a cycle. A stage's vendor follows from its model (`vendorOf` in `internal/agents/process.go`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. - **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` returns its definition; `PUT` takes a definition as the body and creates or replaces the workflow; `DELETE` removes it and turns reviews off (a `project.updated` event each) for the projects that reviewed with it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them. - **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy; returns the definition as JSON), `save_workflow` (`name` and `definition`, the definition as an object whose input schema spells out every field, so nothing is quoted or escaped by hand) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them. - **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title, required then, as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) loads `GET /api/workflows` when it opens, and when the message (after any project prefix) starts with a workflow's name, ignoring case, followed by a space or a colon and more text (the longest such name when several match), it offers a "Start in …" row before each "Start in …" row: `deploy: byre` or `deploy byre` starts a run of `deploy` with `byre` as its title and brief, and opens its first stage task. Text that starts with no workflow's name gets no workflow rows. The switcher's box grows with what is typed (Shift+Enter adds a line) and takes pasted or dropped images; with images attached it lists only the "Start in …" rows, and "Start in …" uploads them as blobs and sends their ids as `POST /api/projects/{id}/tasks`'s `images`, which start the agent with or without a `prompt` (refused with `workflow`). Closing the switcher drops its images. A run gets a new id and creates a task for every stage with nothing before it (`workflow.Starts`), branched from the lead's `from_task_id` HEAD when given, else from the default branch. Initial stages use that starting base directly; later stages choose their base from their loaded parents: one parent supplies its commit, several parents use the default branch (the audited commit for an audit). Each stage task keeps the workflow's name and definition as they are when the run starts in `tasks.workflow_name` and `tasks.workflow_definition_id`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished. diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 773fd05..fe70ea8 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -11,7 +11,6 @@ import ( "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/store" - "g.fc.run/ai/byre/internal/workflow" ) const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them. @@ -98,7 +97,7 @@ func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, } id := newUUID() - vendor := workflow.VendorOf(model) + vendor := vendorOf(model) sess := api.AgentSession{ Id: id, Owner: owner.Login, diff --git a/internal/agents/manager.go b/internal/agents/manager.go index f39f5de..e21462e 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -21,7 +21,6 @@ import ( "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" "g.fc.run/ai/byre/internal/terminal" - "g.fc.run/ai/byre/internal/workflow" "tailscale.com/tsnet" ) @@ -235,7 +234,7 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use } id := newUUID() - vendor := workflow.VendorOf(model) + vendor := vendorOf(model) sess := api.AgentSession{ Id: id, TaskId: task.Id, diff --git a/internal/agents/process.go b/internal/agents/process.go index b19c0bc..e82baed 100644 --- a/internal/agents/process.go +++ b/internal/agents/process.go @@ -14,7 +14,6 @@ import ( "g.fc.run/ai/byre/internal/claude" "g.fc.run/ai/byre/internal/codex" "g.fc.run/ai/byre/internal/mcp" - "g.fc.run/ai/byre/internal/workflow" ) type image struct { @@ -34,6 +33,16 @@ type agentProcess interface { discard() } +func vendorOf(model string) api.AgentSessionVendor { + switch { + case strings.HasPrefix(model, "gpt-"): + return api.Codex + case strings.HasPrefix(model, "gemini-"): + return api.Antigravity + } + return api.Claude +} + func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string { if vendor != api.Claude { return "" @@ -42,8 +51,8 @@ func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string { } func checkModel(sess api.AgentSession, model string) error { - if workflow.VendorOf(model) != sess.Vendor { - return fmt.Errorf("%w: session %s is %s; %s is a %s model", ErrInvalid, sess.Id, sess.Vendor, model, workflow.VendorOf(model)) + if vendorOf(model) != sess.Vendor { + return fmt.Errorf("%w: session %s is %s; %s is a %s model", ErrInvalid, sess.Id, sess.Vendor, model, vendorOf(model)) } return nil } diff --git a/internal/agents/review.go b/internal/agents/review.go index 344a105..cb0a117 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -292,7 +292,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool { func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, defID string, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) { model := workflow.Get(def, stage).Model - vendor := workflow.VendorOf(model) + vendor := vendorOf(model) reviewID := store.NewID() recorded := false defer func() { diff --git a/internal/git/git.go b/internal/git/git.go index 48e8ee6..e27171d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -236,22 +236,23 @@ func SquashCommit(repo, worktree, target, message string, env []string, sign fun } func signCommit(repo, commit string, sign func(payload []byte) ([]byte, error)) (string, error) { - cmd := exec.Command("git", "cat-file", "commit", commit) - cmd.Dir = repo - payload, err := cmd.Output() + payload, stderr, code, err := runCodeInput(context.Background(), repo, nil, nil, "cat-file", "commit", commit) if err != nil { - return "", fmt.Errorf("git cat-file commit %s: %w", commit, err) + return "", err } - sig, err := sign(payload) + if code != 0 { + return "", fmt.Errorf("git cat-file commit %s: exit status %d: %s", commit, code, stderr) + } + sig, err := sign([]byte(payload)) if err != nil { return "", fmt.Errorf("signing the commit: %w", err) } - eoh := strings.Index(string(payload), "\n\n") + eoh := strings.Index(payload, "\n\n") if eoh < 0 { return "", fmt.Errorf("commit %s has no message", commit) } header := "gpgsig " + strings.ReplaceAll(strings.TrimSuffix(string(sig), "\n"), "\n", "\n ") + "\n" - signed := string(payload[:eoh+1]) + header + string(payload[eoh+1:]) + signed := payload[:eoh+1] + header + payload[eoh+1:] return runInput(repo, nil, signed, "hash-object", "-t", "commit", "-w", "--stdin") } @@ -418,17 +419,19 @@ func runInput(dir string, env []string, input string, args ...string) (string, e if code != 0 { return "", fmt.Errorf("git %s: exit status %d: %s", strings.Join(args, " "), code, stderr) } - return out, nil + return strings.TrimSpace(out), nil } func runCode(dir string, env []string, args ...string) (string, string, int, error) { - return runCodeInput(context.Background(), dir, env, nil, args...) + out, stderr, code, err := runCodeInput(context.Background(), dir, env, nil, args...) + return strings.TrimSpace(out), stderr, code, err } func runNetwork(ctx context.Context, dir string, args ...string) (string, string, int, error) { ctx, cancel := context.WithTimeout(ctx, networkTimeout) defer cancel() - return runCodeInput(ctx, dir, networkEnv, nil, args...) + out, stderr, code, err := runCodeInput(ctx, dir, networkEnv, nil, args...) + return strings.TrimSpace(out), stderr, code, err } func runCodeInput(ctx context.Context, dir string, env []string, stdin io.Reader, args ...string) (string, string, int, error) { @@ -449,10 +452,10 @@ func runCodeInput(ctx context.Context, dir string, env []string, stdin io.Reader } exit := &exec.ExitError{} if errors.As(err, &exit) { - return strings.TrimSpace(string(out)), strings.TrimSpace(stderr.String()), exit.ExitCode(), nil + return string(out), strings.TrimSpace(stderr.String()), exit.ExitCode(), nil } if err != nil { return "", "", 0, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } - return strings.TrimSpace(string(out)), strings.TrimSpace(stderr.String()), 0, nil + return string(out), strings.TrimSpace(stderr.String()), 0, nil } diff --git a/internal/git/ignore.go b/internal/git/ignore.go index ebd26c6..549019b 100644 --- a/internal/git/ignore.go +++ b/internal/git/ignore.go @@ -1,10 +1,8 @@ package git import ( - "bytes" - "errors" + "context" "fmt" - "os/exec" "strings" ) @@ -14,23 +12,20 @@ func Ignored(worktree string, paths []string) (map[string]bool, error) { return ignored, nil } - cmd := exec.Command("git", "check-ignore", "--stdin", "-z") - cmd.Dir = worktree - cmd.Stdin = strings.NewReader(strings.Join(paths, "\x00") + "\x00") - stderr := &strings.Builder{} - cmd.Stderr = stderr - out, err := cmd.Output() - exit := &exec.ExitError{} - if errors.As(err, &exit) && exit.ExitCode() == 1 { + out, stderr, code, err := runCodeInput(context.Background(), worktree, nil, strings.NewReader(strings.Join(paths, "\x00")+"\x00"), "check-ignore", "--stdin", "-z") + if err != nil { + return nil, err + } + if code == 1 { return ignored, nil } - if err != nil { - return nil, fmt.Errorf("git check-ignore: %w: %s", err, strings.TrimSpace(stderr.String())) + if code != 0 { + return nil, fmt.Errorf("git check-ignore: exit status %d: %s", code, stderr) } - for _, p := range bytes.Split(out, []byte{0}) { + for _, p := range strings.Split(out, "\x00") { if len(p) > 0 { - ignored[string(p)] = true + ignored[p] = true } } return ignored, nil diff --git a/internal/git/snapshot.go b/internal/git/snapshot.go index 3e236ea..b22b2c5 100644 --- a/internal/git/snapshot.go +++ b/internal/git/snapshot.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" "strings" ) @@ -54,12 +53,6 @@ func DeleteSnapshotRefs(repo, taskID string) error { for _, ref := range refs { fmt.Fprintf(b, "delete %s\n", ref) } - cmd := exec.Command("git", "update-ref", "--stdin") - cmd.Dir = repo - cmd.Stdin = strings.NewReader(b.String()) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git update-ref --stdin: %w: %s", err, strings.TrimSpace(string(out))) - } - return nil + _, err = runInput(repo, nil, b.String(), "update-ref", "--stdin") + return err } diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 0ae5316..5481a0c 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -15,16 +15,6 @@ var ErrInvalid = errors.New("invalid workflow") const maxNameRunes = 100 -func VendorOf(model string) api.AgentSessionVendor { - switch { - case strings.HasPrefix(model, "gpt-"): - return api.Codex - case strings.HasPrefix(model, "gemini-"): - return api.Antigravity - } - return api.Claude -} - func CleanName(name string) (string, error) { name = strings.TrimSpace(name) problem := nameProblem(name)