Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
522c903344 | ||
|
|
11e21bcf18 | ||
|
|
293c21ca8c | ||
|
|
73519bf4dd | ||
|
|
0233e2c310 | ||
|
|
73291f7c35 | ||
|
|
f70ee02af4 | ||
|
|
7556135879 | ||
|
|
89025bee59 | ||
|
|
f707f628bf | ||
|
|
1361b67bed | ||
|
|
5fc8a88a82 | ||
|
|
de9c9a684c | ||
|
|
101dace80e | ||
|
|
fa0e521402 | ||
|
|
2e2b898486 | ||
|
|
1c0effc244 | ||
|
|
eed9b40533 |
@@ -22,7 +22,7 @@ A server and macOS app for running many coding agents (Claude Code, Codex and An
|
||||
|
||||
macOS and a [Tailscale](https://tailscale.com) tailnet with MagicDNS and HTTPS certificates turned on. byre and the app each join it as their own node, so no Mac needs the Tailscale app. Remote access uses Tailscale identity: byre serves the tailnet's users, as its node identifies them. Local scripts use a bearer token to check server status and run commands on connected Macs (see [docs/operations.md](docs/operations.md#tailnet)).
|
||||
|
||||
The Mac that runs the server needs `git` and at least one agent CLI: Claude Code (`claude`), Codex (`codex`) or Antigravity (`agy`, for Gemini models; `brew install --cask antigravity-cli`, signed in once by running `agy`). Browser tools also need Node.js (agents' Playwright tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`).
|
||||
The Mac that runs the server needs `git` and at least one agent CLI: Claude Code (`claude`), Codex (`codex`) or Antigravity (`agy`, for Gemini models). Browser tools also need Node.js (agents' Playwright tools run through `npx`) and Google Chrome. Codex, Antigravity, Node.js and Chrome install through Homebrew. The app's Server tab lists what's missing, each with a button that installs it and starts its sign-in.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -36,6 +36,7 @@ The app updates itself, and the server along with it on the server's Mac. To bui
|
||||
|
||||
- [docs/operations.md](docs/operations.md): external setup, vendor and platform quirks, and the intent behind byre's rules.
|
||||
- [docs/macos.md](docs/macos.md): the macOS app.
|
||||
- [docs/clean-install-vm.md](docs/clean-install-vm.md): testing a first-time install on a fresh Mac in a VM.
|
||||
- [AGENTS.md](AGENTS.md): rules for agents working on byre.
|
||||
|
||||
## License
|
||||
|
||||
+32
-1
@@ -1196,6 +1196,36 @@ paths:
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/sessions/{sessionId}/pause:
|
||||
parameters:
|
||||
- { name: sessionId, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
operationId: pauseSession
|
||||
description: Pauses a task agent without interrupting it (see AgentSession.paused).
|
||||
responses:
|
||||
"200":
|
||||
description: The agent is paused.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AgentSession" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/sessions/{sessionId}/resume:
|
||||
parameters:
|
||||
- { name: sessionId, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
operationId: resumeSession
|
||||
description: Ends a pause; messages that queued during it reach the agent now.
|
||||
responses:
|
||||
"200":
|
||||
description: The agent is running again.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AgentSession" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/blobs:
|
||||
post:
|
||||
operationId: uploadBlob
|
||||
@@ -2046,7 +2076,7 @@ components:
|
||||
|
||||
AgentSession:
|
||||
type: object
|
||||
required: [id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, subagents]
|
||||
required: [id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, subagents, paused]
|
||||
properties:
|
||||
id: { type: string }
|
||||
task_id: { type: string, description: Empty for a lead session. }
|
||||
@@ -2065,6 +2095,7 @@ components:
|
||||
forked_from: { type: string, description: "A fork's first start resumes this vendor session, cut at forked_at, under a new vendor_session_id; absent for other sessions." }
|
||||
forked_at: { type: string, description: "With forked_from: the vendor's id of the last thing the fork keeps, a message for Claude Code, a turn for Codex." }
|
||||
subagents: { type: integer, description: "How many of the agent's subagents are running, background ones included after the turn that started them has ended." }
|
||||
paused: { type: boolean, description: "A task agent's pause: mid-turn it holds at its next step (after the tool call or model call in progress finishes); idle, no turn starts and messages queue until resumed." }
|
||||
SessionCreate:
|
||||
type: object
|
||||
required: [prompt]
|
||||
|
||||
+29
-15
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,38 +18,48 @@ type check struct {
|
||||
Need string `json:"need"`
|
||||
For string `json:"for"`
|
||||
OK bool `json:"ok"`
|
||||
Command string `json:"command"`
|
||||
Tool string `json:"tool"`
|
||||
Fix string `json:"fix"`
|
||||
Blocking bool `json:"blocking"`
|
||||
}
|
||||
|
||||
func checks() []check {
|
||||
brew := command("Homebrew", "brew", needOptional, "installing codex, agy, node and Google Chrome", "brew")
|
||||
return []check{
|
||||
brew,
|
||||
gitCheck(),
|
||||
command("claude", needAgent, "Claude models", "curl -fsSL https://claude.ai/install.sh | bash", "Then run claude once to sign in."),
|
||||
command("codex", needAgent, "GPT models", "brew install --cask codex", "Then run codex once to sign in."),
|
||||
command("agy", needAgent, "Gemini models", "brew install --cask antigravity-cli", "Then run agy once to sign in."),
|
||||
command("npx", needOptional, "reviewers' Playwright browser tools", "brew install node", ""),
|
||||
appCheck("Google Chrome", needOptional, "browser tools for agents and reviewers", "brew install --cask google-chrome"),
|
||||
command("claude", "claude", needAgent, "Claude models", "claude"),
|
||||
withBrew(brew, command("codex", "codex", needAgent, "GPT models", "codex")),
|
||||
withBrew(brew, command("agy", "agy", needAgent, "Gemini models", "agy")),
|
||||
withBrew(brew, command("npx", "npx", needOptional, "reviewers' Playwright browser tools", "node")),
|
||||
withBrew(brew, appCheck("Google Chrome", needOptional, "browser tools for agents and reviewers", "chrome")),
|
||||
}
|
||||
}
|
||||
|
||||
func gitCheck() check {
|
||||
c := check{Name: "git", Need: needRequired, For: "worktrees, snapshots and merges", Command: "xcode-select --install"}
|
||||
c := check{Name: "git", Need: needRequired, For: "worktrees, snapshots and merges", Tool: "git"}
|
||||
path, err := exec.LookPath("git")
|
||||
// /usr/bin/git is a stub that offers to install the Command Line Tools when they're missing.
|
||||
c.OK = err == nil && (path != "/usr/bin/git" || exec.Command("xcode-select", "-p").Run() == nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func command(name, need, purpose, install, fix string) check {
|
||||
_, err := exec.LookPath(name)
|
||||
return check{Name: name, Need: need, For: purpose, OK: err == nil, Command: install, Fix: fix}
|
||||
func command(name, binary, need, purpose, tool string) check {
|
||||
_, err := exec.LookPath(binary)
|
||||
return check{Name: name, Need: need, For: purpose, OK: err == nil, Tool: tool}
|
||||
}
|
||||
|
||||
func appCheck(name, need, purpose, install string) check {
|
||||
func appCheck(name, need, purpose, tool string) check {
|
||||
_, err := os.Stat("/Applications/" + name + ".app")
|
||||
return check{Name: name, Need: need, For: purpose, OK: err == nil, Command: install}
|
||||
return check{Name: name, Need: need, For: purpose, OK: err == nil, Tool: tool}
|
||||
}
|
||||
|
||||
func withBrew(brew, c check) check {
|
||||
if !c.OK && !brew.OK {
|
||||
c.Tool = ""
|
||||
c.Fix = "Install Homebrew first."
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func markBlocking(cs []check) bool {
|
||||
@@ -87,9 +96,14 @@ func requireChecks() {
|
||||
}
|
||||
fmt.Println("byre can't run on this Mac yet. It needs git and at least one of claude, codex and agy:")
|
||||
for _, c := range cs {
|
||||
if c.Blocking {
|
||||
fmt.Printf(" %s: %s\n", c.Name, strings.TrimSpace(c.Command+" "+c.Fix))
|
||||
if !c.Blocking {
|
||||
continue
|
||||
}
|
||||
if c.Tool == "" {
|
||||
fmt.Printf(" %s: %s %s\n", c.Name, c.Fix, installCommand("brew"))
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s: %s\n", c.Name, installCommand(c.Tool))
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+9
-1
@@ -78,8 +78,15 @@ func installCmd() {
|
||||
return
|
||||
}
|
||||
// bootout kills every process the service started, which includes this one when an agent runs it.
|
||||
reload := exec.Command("/bin/sh", "-c", `launchctl bootout "$1"; launchctl bootstrap "$2" "$3"`, "sh", service, domain, plistPath)
|
||||
// It returns before the old server exits, and bootstrap fails while the job is still tearing down.
|
||||
reload := exec.Command("/bin/sh", "-c", `launchctl bootout "$1"; while launchctl print "$1" >/dev/null 2>&1; do sleep 1; done; launchctl bootstrap "$2" "$3"`, "sh", service, domain, plistPath)
|
||||
reload.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
logFile, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
fatal("open log", "err", err)
|
||||
}
|
||||
reload.Stdout = logFile
|
||||
reload.Stderr = logFile
|
||||
err = reload.Start()
|
||||
if err != nil {
|
||||
fatal("restart service", "err", err)
|
||||
@@ -89,6 +96,7 @@ func installCmd() {
|
||||
|
||||
func run(name string, args ...string) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err := cmd.Run()
|
||||
|
||||
+3
-1
@@ -18,6 +18,8 @@ func main() {
|
||||
installCmd()
|
||||
case "check":
|
||||
checkCmd()
|
||||
case "install-tool":
|
||||
installToolCmd(os.Args[2:])
|
||||
case "run":
|
||||
runCmd(os.Args[2:])
|
||||
case "signing":
|
||||
@@ -36,7 +38,7 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen")
|
||||
fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre install-tool NAME |byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type brewTool struct {
|
||||
args []string
|
||||
signIn []string
|
||||
}
|
||||
|
||||
var brewTools = map[string]brewTool{
|
||||
"codex": {args: []string{"--cask", "codex"}, signIn: []string{"codex", "login"}},
|
||||
"agy": {args: []string{"--cask", "antigravity-cli"}, signIn: []string{"agy"}},
|
||||
"node": {args: []string{"node"}},
|
||||
"chrome": {args: []string{"--cask", "google-chrome"}},
|
||||
}
|
||||
|
||||
const toolNames = "brew, git, claude, codex, agy, node or chrome"
|
||||
|
||||
func installToolCmd(args []string) {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: byre install-tool NAME, where NAME is "+toolNames)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch args[0] {
|
||||
case "brew":
|
||||
run("/bin/sh", "-c", `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`)
|
||||
addToProfile(".zprofile", `eval "$(/opt/homebrew/bin/brew shellenv)"`)
|
||||
case "git":
|
||||
run("xcode-select", "--install")
|
||||
fmt.Println("macOS is showing its own dialog to install the Command Line Tools: click Install there.")
|
||||
case "claude":
|
||||
run("/bin/sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash")
|
||||
addToProfile(".zshrc", `export PATH="$HOME/.local/bin:$PATH"`)
|
||||
signIn(filepath.Join(homeDir(), ".local", "bin", "claude"))
|
||||
default:
|
||||
t, ok := brewTools[args[0]]
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "unknown tool "+args[0]+": NAME is "+toolNames)
|
||||
os.Exit(2)
|
||||
}
|
||||
brew, err := exec.LookPath("brew")
|
||||
if err != nil {
|
||||
fmt.Println("Homebrew isn't installed. Install it first:", installCommand("brew"))
|
||||
os.Exit(1)
|
||||
}
|
||||
run(brew, append([]string{"install"}, t.args...)...)
|
||||
if t.signIn != nil {
|
||||
signIn(filepath.Join(filepath.Dir(brew), t.signIn[0]), t.signIn[1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installCommand(name string) string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fatal("find executable", "err", err)
|
||||
}
|
||||
return "'" + strings.ReplaceAll(exe, "'", `'\''`) + "' install-tool " + name
|
||||
}
|
||||
|
||||
func homeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fatal("home dir", "err", err)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func addToProfile(name, line string) {
|
||||
path := filepath.Join(homeDir(), name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
fatal("read profile", "path", path, "err", err)
|
||||
}
|
||||
if strings.Contains(string(data), line) {
|
||||
return
|
||||
}
|
||||
text := line + "\n"
|
||||
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
|
||||
text = "\n" + text
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
fatal("open profile", "path", path, "err", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(text)
|
||||
if err != nil {
|
||||
fatal("write profile", "path", path, "err", err)
|
||||
}
|
||||
fmt.Println("Added", line, "to", path)
|
||||
}
|
||||
|
||||
func signIn(path string, args ...string) {
|
||||
fmt.Println("Starting", filepath.Base(path), "so you can sign in.")
|
||||
err := syscall.Exec(path, append([]string{path}, args...), os.Environ())
|
||||
fatal("start "+filepath.Base(path), "err", err)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ There is no flag for a system prompt or MCP servers, so byre hands them over as
|
||||
- `plugin.json`: `{"name": "byre"}`.
|
||||
- `rules/AGENTS.md`: always-on guidance. `rules/notes.md` uses `trigger: always_on` frontmatter. The CLI cuts each rule file at 24,000 bytes, so longer guidance or notes must be refused before launch.
|
||||
- `mcp_config.json`: one entry per server, with a Streamable HTTP `url` or a stdio `command` and `args`. The CLI namespaces plugin servers as `<plugin>_<server>`, so byre's own server is `byre_byre`.
|
||||
- `hooks.json`: handlers keyed by hook event, each a command that reads the hook's JSON from stdin and prints its reply. Only flat events (`SessionStart`, `PreInvocation`, `PostInvocation`, `Stop`) fit this shape.
|
||||
- `hooks.json`: handlers keyed by hook event, each a command that reads the hook's JSON from stdin and prints its reply, with a `timeout` in seconds (default 30). Flat events (`SessionStart`, `PreInvocation`, `PostInvocation`, `Stop`) list handlers directly; tool events (`PreToolUse`, `PostToolUse`) wrap them in groups, `[{"matcher": "*", "hooks": [...]}]`, where `matcher` is a regex on the tool name.
|
||||
|
||||
## The session's listener
|
||||
|
||||
@@ -52,7 +52,7 @@ There is no way to switch the model of a running process. Close the idle process
|
||||
|
||||
## Hooks
|
||||
|
||||
Hook input and output are camelCase JSON. byre uses `PreInvocation`, which runs before every model call with `invocationNum` (0 for a turn's first call) and `initialNumSteps`; an output of `{"injectSteps":[{"userMessage":"..."}]}` adds a user message the model reads on that call. The `Stop` hook gets no final message, so byre doesn't use it.
|
||||
Hook input and output are camelCase JSON. byre uses `PreInvocation`, which runs before every model call with `invocationNum` (0 for a turn's first call) and `initialNumSteps`; an output of `{"injectSteps":[{"userMessage":"..."}]}` adds a user message the model reads on that call. byre also uses `PreToolUse` (input `toolCall` with `name` and `args`), whose output needs `decision`: `allow`, or `deny` with a `reason` the agent reads in place of the tool's result. The `Stop` hook gets no final message, so byre doesn't use it.
|
||||
|
||||
## Forking
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ NDJSON both ways: one JSON object per line on stdin and stdout. Non-JSON stdout
|
||||
- User turn, written to stdin: `{"type":"user","message":{"role":"user","content":"..."},"parent_tool_use_id":null,"session_id":"..."}`. With images, `content` is an array of Anthropic content blocks instead: `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}}` for each image, then `{"type":"text","text":"..."}`.
|
||||
- Stdout events: `system` (subtype `init` carries `session_id`, `model`, tools, MCP server status), `assistant` and `user` (Anthropic message content blocks: `text`, `tool_use`, `tool_result`), `result` (ends a turn; `subtype` is `success` or `error_during_execution` after an interrupt, with cost, duration, turn count).
|
||||
- `--include-partial-messages` adds `stream_event` lines wrapping the raw API stream (`message_start`, `content_block_start`, `content_block_delta` with `text_delta`, `content_block_stop`, `message_delta`, `message_stop`), each with `parent_tool_use_id` (set inside subagents). The complete `assistant` event for a block still arrives, before that block's `content_block_stop`.
|
||||
- Newer CLIs also send `command_lifecycle` lines (`command_uuid`, `state` such as `completed`). byre drops them, as it does `keep_alive`.
|
||||
- The `init` event arrives with the first turn, not at launch, so the session ID is unknown until the first message is sent.
|
||||
- While a tool runs, `tool_progress` lines with `"heartbeat": true`, the tool's `elapsed_time_seconds`, and a `parent_tool_use_id` naming the tool call arrive every 30 seconds. A Bash call also brings `system` `task_started` and `task_notification` lines around it.
|
||||
- Subagents (the `Agent` tool, listed in `init` tools as `Task`): `system` `task_started` (`task_type` `local_agent`, `tool_use_id` naming the `Agent` call, `subagent_type`, `description`, `prompt`, `is_backgrounded`), `task_progress` (`description` of what it is doing), `task_updated` (by `task_id` only), and `task_notification` (`status`, `summary`). `assistant` and `user` lines from inside the subagent carry `parent_tool_use_id` set to the `Agent` call's ID, plus `agent_id`, `subagent_type` and `task_description`. A foreground subagent (`run_in_background: false`) sends only its prompt this way, as a `user` text block, and its final report arrives as the `Agent` call's `tool_result`. A background one (the default) streams all its assistant and tool-result lines; the `Agent` call's `tool_result` comes back at once saying it launched, the turn can end with its own `result` while the subagent runs, and when the subagent finishes the CLI starts another turn by itself, ending in a second `result` carrying `origin.kind` `task-notification`.
|
||||
@@ -37,14 +38,14 @@ Either side sends `{"type":"control_request","request_id":ID,"request":{"subtype
|
||||
|
||||
Host → CLI:
|
||||
|
||||
- `initialize` must be first. It carries hook registrations: `{"hooks":{"Stop":[{"matcher":null,"hookCallbackIds":["hook_0"]}],...}}`.
|
||||
- `initialize` must be first. It carries hook registrations: `{"hooks":{"Stop":[{"matcher":null,"hookCallbackIds":["hook_0"],"timeout":<seconds>}],...}}`. Without `timeout` the CLI abandons a hook callback after its default; the timer is a JavaScript `setTimeout`, so values past about 24 days (2^31 ms) fire at once.
|
||||
- `interrupt` ends the current turn; the turn's `result` follows almost immediately and a running Bash tool is killed. The session stays usable.
|
||||
- `set_model` (`{"subtype":"set_model","model":M}`) switches the running process's model for later turns.
|
||||
- `set_permission_mode`, `mcp_status`, `get_context_usage`, `rewind_files`, `stop_task` exist and are not used yet.
|
||||
|
||||
CLI → host:
|
||||
|
||||
- `hook_callback` with `callback_id` and `input` (the hook's input JSON). The response body is the hook output. A Stop hook returning `{"decision":"block","reason":"..."}` keeps the agent going with the reason fed back to it.
|
||||
- `hook_callback` with `callback_id` and `input` (the hook's input JSON). The response body is the hook output. A Stop hook returning `{"decision":"block","reason":"..."}` keeps the agent going with the reason fed back to it. A PreToolUse hook returning `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` skips the call, and the model reads the reason in its place. The model treats that reason as tool output and won't follow instructions in it; an `additionalContext` beside `permissionDecision` reaches it as context it does follow. A hook's callback can take as long as it likes within its timeout; the agent waits.
|
||||
- `mcp_message` with `server_name` and a JSON-RPC `message` for an `sdk`-type MCP server; answer with `{"mcp_response": <JSON-RPC response>}`. Notifications still need a response.
|
||||
- `can_use_tool` only arrives when a permission prompt would show; under `bypassPermissions` it should not.
|
||||
- `control_cancel_request` abandons an in-flight request.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Testing a clean install in a macOS VM
|
||||
|
||||
A tart VM on the Mac byre runs on gives a fresh Mac in seconds, to install byre on as a new user would. `tart list` shows the VMs there.
|
||||
|
||||
## The base VM
|
||||
|
||||
`byre-base` is a stopped VM left as just set up, kept in `~/.tart/vms/` (outside any checkout), and never run or installed into. Tests run on clones of it. It has:
|
||||
|
||||
- the macOS that `tart create --from-ipsw latest` installed (the newest the host supports; `tart get byre-base` and `sw_vers` inside show it), 4 CPUs, 8 GB of memory and an 80 GB disk;
|
||||
- one account, `admin` with password `admin`, signed in automatically at boot, with passwordless `sudo`, the host user's `~/.ssh/id_ed25519.pub` in `authorized_keys`, Remote Login on, and sleep and screen lock off;
|
||||
- no Apple Account, FileVault, Siri, Location Services or analytics, and macOS updates that download without installing;
|
||||
- nothing else: no Homebrew, Command Line Tools, Tailscale or byre.
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
tart clone byre-base byre-test
|
||||
tart run byre-test
|
||||
```
|
||||
|
||||
Run `tart run` in the task's terminal: it keeps running while the VM is up, and the VM's screen opens as a `tart` window. `tart ip byre-test` prints the VM's address once it has booted; `ssh admin@<ip>` gets in. `tart delete byre-test` throws the clone away. A clone is a copy-on-write copy, so it takes seconds and little disk. Apple's license allows two macOS VMs running at once per Mac, and other tasks may be running one.
|
||||
|
||||
To install a build, copy `~/byre-deploy/byre.dmg` (or a `mac/bundle.sh` build) in and put `Byre.app` in `/Applications` there. Copy an app bundle as a zip (`ditto -c -k --keepParent` on the host, `ditto -x -k` in the VM), since `scp -r` fails on the symlinks in its frameworks. Open apps in the VM's GUI session from SSH with `sudo launchctl asuser 501 sudo -u admin open /Applications/Byre.app`.
|
||||
|
||||
The app's Tailscale sign-in opens Safari in the VM. To have the human approve it from their own Mac, read the address over SSH (`sudo launchctl asuser 501 sudo -u admin osascript -e 'tell application "Safari" to get URL of front document'`, which asks once in the VM for Automation of Safari) and open the `https://login.tailscale.com/a/<code>` it names with `show_page` on the human's Mac.
|
||||
|
||||
## Driving the VM's screen
|
||||
|
||||
- Click the `tart` window's title bar first: the VM takes clicks and keys only while its window is the key window.
|
||||
- Keys reach the VM without their modifiers through hands, so Shift, Cmd and symbols typed with `type` or `key` arrive wrong. Type only lowercase text and plain symbols there, and do the rest over SSH.
|
||||
- Scrolling doesn't reach the VM; drag the scroll bar instead.
|
||||
- `screencapture` over SSH fails for lack of Screen Recording in the VM; capture the `tart` window from the host with hands.
|
||||
- Check `screen_list` before acting: another task's `tart` window can sit exactly on top of yours.
|
||||
|
||||
## Rebuilding the base
|
||||
|
||||
Only when it needs a newer macOS. `tart create --from-ipsw latest --disk-size 80 byre-base` downloads the restore image (about 15 GB, cached in `~/.tart/cache/IPSWs/`) and installs it; `tart set byre-base --cpu 4 --memory 8192`. Run it and click through Setup Assistant: "Set up as new", account `admin`/`admin`, skip the Apple Account ("Sign in Later in Settings"), and decline everything else. Then turn on Remote Login from the VM's Terminal:
|
||||
|
||||
```
|
||||
sudo launchctl enable system/com.openssh.sshd; sudo launchctl bootstrap system /System/Library/LaunchDaemons/ssh.plist
|
||||
```
|
||||
|
||||
and over SSH from the host (`ssh-copy-id admin@<ip>`, then):
|
||||
|
||||
```
|
||||
echo 'admin ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/admin
|
||||
sudo chmod 440 /etc/sudoers.d/admin
|
||||
sudo launchctl asuser 501 sudo sysadminctl -autologin set -userName admin -password admin
|
||||
sudo pmset -a sleep 0 displaysleep 0
|
||||
sudo shutdown -h now
|
||||
```
|
||||
|
||||
`sysadminctl -autologin` fails with error 22 when run over SSH outside the GUI session, hence `launchctl asuser`.
|
||||
|
||||
tart itself is installed from its GitHub release (`gh release download --repo cirruslabs/tart`, unpacked to `~/Applications/tart.app`, with `~/bin/tart` linked to `tart.app/Contents/MacOS/tart`), since the Homebrew formula in `cirruslabs/cli` fails on current Homebrew.
|
||||
@@ -33,6 +33,10 @@ A `dynamicTools` entry of type `namespace` holds tools as `function`s with their
|
||||
|
||||
Closing stdin ends the process.
|
||||
|
||||
## Hooks
|
||||
|
||||
The `config` on `thread/start`, `thread/resume` and `thread/fork` takes hooks in Claude Code's shape: `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":...,"timeout":<seconds>}]}]}}`. They are ignored unless `config` also sets `bypass_hook_trust` true: hooks otherwise need trust recorded in `config.toml` (the `--dangerously-bypass-hook-trust` of `codex exec`, which `app-server` lacks as a flag). The command reads the hook's JSON on stdin and prints its reply on stdout; a sync hook holds the turn until it exits, and `hook/started` and `hook/completed` notifications report each run. `PostToolUse` runs after the tool's `item/completed`. A `PreToolUse` reply of `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":...}}` skips the call, and the model reads "Command blocked by PreToolUse hook:" and the reason in its place; an `additionalContext` beside it reaches the model too. Output that isn't valid JSON fails the hook (`hook returned invalid pre-tool-use JSON output`) and the call runs.
|
||||
|
||||
## Events
|
||||
|
||||
- `turn/started`, `turn/completed` (`turn.status` `completed`, `interrupted` or `failed` with `turn.error`). A failed request to the model also sends an `error` notification first, with `willRetry`.
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ Every build is signed for distribution outside the App Store: the team's Develop
|
||||
|
||||
- Startup waits until the app's tailnet node lists the server's node among its peers, not just until it is `Running`: a node starting from libtailscale's netmap cache reports `Running` with no peers before control sends the real netmap, and a dial made then fails.
|
||||
- Every request reaches the tailnet through byre's own SOCKS5 proxy on a loopback port (`Proxy.swift`), which dials with `tailscale_dial`, not libtailscale's `tailscale_loopback`. iOS reclaims a suspended app's listening sockets, and tsnet's proxy stops for good on the first failed accept, leaving the phone unable to reach any server. byre's proxy binds again on the same port each time the app returns to the foreground.
|
||||
- Run byre on this Mac and the Server tab run `byre install` through the user's login shell, since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`.
|
||||
- Run byre on this Mac and the Server tab run `byre install` through the user's shell as an interactive login shell (`-l -i`), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It must be interactive: claude's installer has the user add `~/.local/bin` to `.zshrc`, which a non-interactive login shell doesn't read.
|
||||
- Signing a new server's Tailscale node in for the first time can take well over a minute before its ts.net address answers (DNS and its first HTTPS certificate), so Run byre on this Mac waits up to 2 minutes for it.
|
||||
- SwiftUI's window restoration is off, but the scene keeps the id `main` that earlier builds used: AppKit restores their saved window by that id, and SwiftUI then opens no launch window at all when the id names no scene.
|
||||
|
||||
## Views
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ Closing a task deletes its branch, worktree and snapshot refs, so a task continu
|
||||
|
||||
## Running as a service
|
||||
|
||||
The service always runs from the app: `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run. `byre install` takes `PATH` from the shell it is run in, since the service's `PATH` must find `claude`, `codex`, `agy`, `git`, `curl` and `npx`. When the service is already loaded, `install` hands `launchctl bootout` and `bootstrap` to a detached `sh` and exits: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it.
|
||||
The service always runs from the app: `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run. `byre install` takes `PATH` from the shell it is run in, since the service's `PATH` must find `claude`, `codex`, `agy`, `git`, `curl` and `npx`. When the service is already loaded, `install` hands `launchctl bootout` and `bootstrap` to a detached `sh` and exits: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it. `bootout` returns once it has sent SIGTERM, not once the server has exited, and a `bootstrap` while the old job is still tearing down fails with `Bootstrap failed: 5: Input/output error`, leaving no service at all; so the `sh` waits until `launchctl print` no longer finds the service before `bootstrap`, and writes its output to `~/Library/Logs/byre.log`.
|
||||
|
||||
`byre serve` exits when its executable is replaced (logging `executable replaced, exiting`) and launchd starts the new one. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. The service log is owner-only (0600), including existing logs: Tailscale can put a sign-in URL there. Logs go to `~/Library/Logs/byre.log`; `launchctl print gui/$(id -u)/run.fc.byre` shows the service.
|
||||
|
||||
@@ -123,11 +123,11 @@ byre never signs with a key on the machine it runs on: every git command it runs
|
||||
- **Orion.** Orion ignores key events that carry only a Unicode string, so typing posts each ASCII character as its key code. Some apps list `confirm` but ignore it, Orion's address bar among them; send Return instead. `do JavaScript` needs Orion's Develop → "Allow JavaScript from Apple Events".
|
||||
- **Chrome.** Chrome refuses remote debugging on its default profile, so byre's Chrome is its own profile (`~/Library/Application Support/byre/chrome`), a separate Chrome beside any the human runs, and it keeps its logins: the human signs in there once. Chrome comes from `brew install --cask google-chrome`; macOS's "downloaded from the Internet" prompt on its first launch must be answered Open (or `osascript -e 'tell application "System Events" to tell process "CoreServicesUIAgent" to click button "Open" of window 1'`), and until it is, Chrome cannot accept debugging commands.
|
||||
- **Chrome's trust boundary.** Use Chrome's loopback debugging port with byre's separate persistent profile and Chrome's default browser-origin checks; never add `--remote-allow-origins=*`. Other local OS accounts can reach that debugger and control its logged-in sessions. This is an accepted risk on the personal Macs byre runs on, as is access to the app's local preview forwards; do not add account-isolating brokers for these listeners. Chrome must be launched with `--remote-debugging-port=0` so it records its port and browser WebSocket path in the profile’s `DevToolsActivePort` file. After switching from the pipe or fixed-port transport, quit the old byre-profile Chrome once so the next tool call can start it with that discovery file. The saved profile and logins stay in place.
|
||||
- **Permissions.** Agents' processes run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Accessibility and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant. Without Accessibility an untrusted process just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. TCC keys grants on the signing identifier and team, and every build is signed with Developer ID, so grants carry over from build to build. On a client Mac the grants belong to byre's app; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. The Desktop, Documents, Downloads and iCloud Drive folders each ask for their own Files & Folders grant the first time a process byre is responsible for reads them. `byre-hands check` prints the grants of the process that runs it, listing each of those folders so their prompts come up during the check rather than mid-turn; the Server and Settings tabs run it.
|
||||
- **Permissions.** Agents' processes run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Device Control and Data Access (macOS 27's name for Accessibility) and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant. Without Device Control and Data Access an untrusted process just sees no windows and no menu bar. A Mac running a server lists byre twice: the server's helper, a bare executable shown by its file name `byre` with a terminal icon, and the app, `Byre` with its icon; `byre-hands` names the app when the app is among its ancestor processes, and the server otherwise. Device Control and Data Access can list byre switched off; only its switch being on counts. TCC keys grants on the signing identifier and team, and every build is signed with Developer ID, so grants carry over from build to build. On a client Mac the grants belong to byre's app; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. The Desktop, Documents, Downloads and iCloud Drive folders each ask for their own Files & Folders grant the first time a process byre is responsible for reads them. `byre-hands check` prints the grants of the process that runs it, listing each of those folders so their prompts come up during the check rather than mid-turn; the Server and Settings tabs run it.
|
||||
|
||||
## Terminals
|
||||
|
||||
- `terminal_type` takes named keys because Claude Code passes tool arguments through as the text the model wrote, so an escape like `\u0003` in `text` reaches the shell as six characters.
|
||||
- In a new shell it waits for the prompt first: typing earlier would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. A terminal started from a saved command waits the same way before its command is typed.
|
||||
- Saved commands belong to a user on one server, because what they do is usually tied to that Mac: signing in again to the credential an `apiKeyHelper` uses (AppleConnect's `getToken -n cli` on a work Mac) is the case they were made for. Its terminal stays open after the command, so the result stays readable.
|
||||
- zsh gets shell integration through a `.zshenv` in `<data>/zsh` (via `ZDOTDIR`) that reports the prompt (OSC 133) and the line being edited, so byre can wake the agent when a line it left is run. It turns off `PROMPT_SP`, which would print a `%` padded to the width the shell started at, so an agent-opened shell (120 columns) replayed in a narrower tab wraps each one onto an extra line. A user `.zshrc` that sets `zle-line-pre-redraw` with `zle -N` rather than `add-zle-hook-widget` replaces the hook, and byre then sees the line as empty. Other shells get no integration and nothing wakes their agents.
|
||||
- zsh gets shell integration through a `.zshenv` in `<data>/zsh` (via `ZDOTDIR`) that reports the prompt (OSC 133) and the line being edited, so byre can wake the agent when a command it ran or a line it left finishes. It turns off `PROMPT_SP`, which would print a `%` padded to the width the shell started at, so an agent-opened shell (120 columns) replayed in a narrower tab wraps each one onto an extra line. A user `.zshrc` that sets `zle-line-pre-redraw` with `zle -N` rather than `add-zle-hook-widget` replaces the hook, and byre then sees the line as empty. Other shells get no integration and nothing wakes their agents.
|
||||
|
||||
@@ -80,7 +80,7 @@ func appElement(_ pid: pid_t) -> AXUIElement {
|
||||
|
||||
func requireTrust() throws {
|
||||
if !AXIsProcessTrusted() {
|
||||
throw ToolError("byre lacks Accessibility, so no app's tree can be read or acted on; the human must switch byre on in System Settings → Privacy & Security → Accessibility")
|
||||
throw ToolError("byre lacks \(deviceControl), so no app's tree can be read or acted on; the human must switch \(grantee) on in System Settings → Privacy & Security → \(deviceControl)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,24 @@ import ApplicationServices
|
||||
import ScreenCaptureKit
|
||||
|
||||
let orionBundle = "com.kagi.kagimacOS"
|
||||
let deviceControl = "Device Control and Data Access"
|
||||
|
||||
// The server and the app each run byre-hands, and System Settings lists them as two entries: the server's helper by its file name, the app by its own.
|
||||
let grantee: String = {
|
||||
var pid = getppid()
|
||||
while pid > 1 {
|
||||
var buf = [CChar](repeating: 0, count: Int(MAXPATHLEN))
|
||||
if proc_pidpath(pid, &buf, UInt32(buf.count)) > 0, String(cString: buf).hasSuffix(".app/Contents/MacOS/Byre") {
|
||||
return "Byre (with byre's app icon)"
|
||||
}
|
||||
var info = proc_bsdinfo()
|
||||
if proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout<proc_bsdinfo>.size)) <= 0 {
|
||||
break
|
||||
}
|
||||
pid = pid_t(info.pbi_ppid)
|
||||
}
|
||||
return "byre (with the black terminal icon)"
|
||||
}()
|
||||
|
||||
func permission(_ name: String, _ status: String, _ detail: String, _ pane: String) -> [String: String] {
|
||||
return ["name": name, "status": status, "detail": detail, "settings": "x-apple.systempreferences:com.apple.preference.security?Privacy_\(pane)"]
|
||||
@@ -14,26 +32,26 @@ func checkScreenRecording() async -> [String: String] {
|
||||
return permission("Screen Recording", "granted", "Listed the screen (displays: \(content.displays.count), windows: \(content.windows.count)).", "ScreenCapture")
|
||||
} catch {
|
||||
CGRequestScreenCaptureAccess()
|
||||
return permission("Screen Recording", "missing", "Listing the screen failed (\(error.localizedDescription)). Switch byre on in Screen Recording.", "ScreenCapture")
|
||||
return permission("Screen Recording", "missing", "Listing the screen failed (\(error.localizedDescription)). Switch \(grantee) on in Screen Recording.", "ScreenCapture")
|
||||
}
|
||||
}
|
||||
|
||||
func checkAccessibility() -> [String: String] {
|
||||
if !AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary) {
|
||||
return permission("Accessibility", "missing", "Switch byre on in Accessibility.", "Accessibility")
|
||||
return permission(deviceControl, "missing", "Switch \(grantee) on in \(deviceControl).", "Accessibility")
|
||||
}
|
||||
guard let finder = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first else {
|
||||
return permission("Accessibility", "unchecked", "Granted, but Finder isn't running to read its menu bar.", "Accessibility")
|
||||
return permission(deviceControl, "unchecked", "Granted, but Finder isn't running to read its menu bar.", "Accessibility")
|
||||
}
|
||||
do {
|
||||
guard let bar = try attr(appElement(finder.processIdentifier), kAXMenuBarAttribute) else {
|
||||
return permission("Accessibility", "missing", "Granted, but reading Finder's menu bar returned nothing. Switch byre off and on again in Accessibility.", "Accessibility")
|
||||
return permission(deviceControl, "missing", "Granted, but reading Finder's menu bar returned nothing. Switch \(grantee) off and on again in \(deviceControl).", "Accessibility")
|
||||
}
|
||||
return permission("Accessibility", "granted", "Read Finder's menu bar (menus: \(try children(bar as! AXUIElement).count)).", "Accessibility")
|
||||
return permission(deviceControl, "granted", "Read Finder's menu bar (menus: \(try children(bar as! AXUIElement).count)).", "Accessibility")
|
||||
} catch let e as ToolError {
|
||||
return permission("Accessibility", "missing", e.message, "Accessibility")
|
||||
return permission(deviceControl, "missing", e.message, "Accessibility")
|
||||
} catch {
|
||||
return permission("Accessibility", "missing", "\(error)", "Accessibility")
|
||||
return permission(deviceControl, "missing", "\(error)", "Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +62,7 @@ func checkOrion() -> [String: String] {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: orionBundle)
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
|
||||
if status != noErr {
|
||||
return permission("Automation of Orion", "missing", "macOS refused Apple events to Orion (\(status)). Switch Orion on under byre in Automation.", "Automation")
|
||||
return permission("Automation of Orion", "missing", "macOS refused Apple events to Orion (\(status)). Switch Orion on under \(grantee) in Automation.", "Automation")
|
||||
}
|
||||
do {
|
||||
let windows = try osa("tell application \"Orion\" to count windows").int32Value
|
||||
@@ -65,7 +83,7 @@ func checkFolder(_ name: String, _ path: String) -> [String: String] {
|
||||
let entries = try FileManager.default.contentsOfDirectory(atPath: dir)
|
||||
return permission(name, "granted", "Listed \(dir) (entries: \(entries.count)).", "FilesAndFolders")
|
||||
} catch {
|
||||
return permission(name, "missing", "Listing \(dir) failed (\(error.localizedDescription)). Switch \(name) on under byre in Files & Folders.", "FilesAndFolders")
|
||||
return permission(name, "missing", "Listing \(dir) failed (\(error.localizedDescription)). Switch \(name) on under \(grantee) in Files & Folders.", "FilesAndFolders")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,20 @@ let keyCodes: [String: CGKeyCode] = [
|
||||
"forwarddelete": 117, "end": 119, "pagedown": 121, "left": 123, "right": 124, "down": 125, "up": 126,
|
||||
]
|
||||
|
||||
let modifierFlags: [String: CGEventFlags] = [
|
||||
"cmd": .maskCommand, "command": .maskCommand, "shift": .maskShift, "alt": .maskAlternate,
|
||||
"opt": .maskAlternate, "option": .maskAlternate, "ctrl": .maskControl, "control": .maskControl,
|
||||
"fn": .maskSecondaryFn,
|
||||
struct Modifier: Equatable {
|
||||
let code: CGKeyCode
|
||||
let flag: CGEventFlags
|
||||
}
|
||||
|
||||
// Each left-hand key's flag also carries its device-dependent bit (NX_DEVICEL*KEYMASK), as a real keyboard's does.
|
||||
let shiftKey = Modifier(code: 56, flag: CGEventFlags(rawValue: CGEventFlags.maskShift.rawValue | 0x2))
|
||||
let commandKey = Modifier(code: 55, flag: CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x8))
|
||||
let optionKey = Modifier(code: 58, flag: CGEventFlags(rawValue: CGEventFlags.maskAlternate.rawValue | 0x20))
|
||||
let controlKey = Modifier(code: 59, flag: CGEventFlags(rawValue: CGEventFlags.maskControl.rawValue | 0x1))
|
||||
|
||||
let modifiers: [String: Modifier] = [
|
||||
"cmd": commandKey, "command": commandKey, "shift": shiftKey, "alt": optionKey, "opt": optionKey,
|
||||
"option": optionKey, "ctrl": controlKey, "control": controlKey, "fn": Modifier(code: 63, flag: .maskSecondaryFn),
|
||||
]
|
||||
|
||||
func pause(_ ms: Int) async throws {
|
||||
@@ -29,15 +39,15 @@ let shifted: [Character: String] = [
|
||||
|
||||
let named: [Character: String] = [" ": "space", "\n": "return", "\t": "tab"]
|
||||
|
||||
func keystroke(_ ch: Character) -> (CGKeyCode, CGEventFlags)? {
|
||||
func keystroke(_ ch: Character) -> (CGKeyCode, [Modifier])? {
|
||||
if let name = named[ch], let c = keyCodes[name] {
|
||||
return (c, [])
|
||||
}
|
||||
if let base = shifted[ch], let c = keyCodes[base] {
|
||||
return (c, .maskShift)
|
||||
return (c, [shiftKey])
|
||||
}
|
||||
if ch.isUppercase, let c = keyCodes[ch.lowercased()] {
|
||||
return (c, .maskShift)
|
||||
return (c, [shiftKey])
|
||||
}
|
||||
if let c = keyCodes[String(ch)] {
|
||||
return (c, [])
|
||||
@@ -45,6 +55,30 @@ func keystroke(_ ch: Character) -> (CGKeyCode, CGEventFlags)? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func postModifier(_ m: Modifier, down: Bool, _ flags: CGEventFlags) {
|
||||
let e = CGEvent(keyboardEventSource: nil, virtualKey: m.code, keyDown: down)
|
||||
e?.type = .flagsChanged
|
||||
e?.flags = flags
|
||||
post(e)
|
||||
}
|
||||
|
||||
func press(_ code: CGKeyCode, _ mods: [Modifier]) {
|
||||
var flags: CGEventFlags = []
|
||||
for m in mods {
|
||||
flags.insert(m.flag)
|
||||
postModifier(m, down: true, flags)
|
||||
}
|
||||
for down in [true, false] {
|
||||
let e = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down)
|
||||
e?.flags = flags
|
||||
post(e)
|
||||
}
|
||||
for m in mods.reversed() {
|
||||
flags.remove(m.flag)
|
||||
postModifier(m, down: false, flags)
|
||||
}
|
||||
}
|
||||
|
||||
func lockInput() async throws -> Int32 {
|
||||
let fd = open(NSTemporaryDirectory() + "byre-hands-input.lock", O_CREAT | O_RDWR, 0o600)
|
||||
if fd < 0 {
|
||||
@@ -125,20 +159,25 @@ func drag(_ from: CGPoint, _ to: CGPoint) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
// Marks the deltas as inverted from the device, as natural scrolling does: VM windows undo that to recover the device's
|
||||
// direction and the guest applies its own natural scrolling, so without it a scroll lands reversed there.
|
||||
let scrollDirectionInverted = CGEventField(rawValue: 137)!
|
||||
|
||||
func scroll(_ p: CGPoint, dx: Int32, dy: Int32) {
|
||||
post(CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: p, mouseButton: .left))
|
||||
post(CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: dy, wheel2: dx, wheel3: 0))
|
||||
let e = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: dy, wheel2: dx, wheel3: 0)
|
||||
e?.setIntegerValueField(scrollDirectionInverted, value: 1)
|
||||
post(e)
|
||||
}
|
||||
|
||||
func type(_ s: String) async throws {
|
||||
for ch in s {
|
||||
if let (code, mods) = keystroke(ch) {
|
||||
press(code, mods)
|
||||
try await pause(15)
|
||||
continue
|
||||
}
|
||||
for down in [true, false] {
|
||||
if let (code, flags) = keystroke(ch) {
|
||||
let e = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down)
|
||||
e?.flags = flags
|
||||
post(e)
|
||||
continue
|
||||
}
|
||||
let units = Array(String(ch).utf16)
|
||||
let e = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: down)
|
||||
e?.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units)
|
||||
@@ -149,11 +188,13 @@ func type(_ s: String) async throws {
|
||||
}
|
||||
|
||||
func key(_ combo: String) throws {
|
||||
var flags: CGEventFlags = []
|
||||
var mods: [Modifier] = []
|
||||
var code: CGKeyCode?
|
||||
for part in combo.lowercased().split(separator: "+").map(String.init) {
|
||||
if let f = modifierFlags[part] {
|
||||
flags.insert(f)
|
||||
if let m = modifiers[part] {
|
||||
if !mods.contains(m) {
|
||||
mods.append(m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard let c = keyCodes[part] else {
|
||||
@@ -164,9 +205,5 @@ func key(_ combo: String) throws {
|
||||
guard let code else {
|
||||
throw ToolError("no key in \(combo.debugDescription)")
|
||||
}
|
||||
for down in [true, false] {
|
||||
let e = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down)
|
||||
e?.flags = flags
|
||||
post(e)
|
||||
}
|
||||
press(code, mods)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"g.fc.run/ai/byre/internal/antigravity"
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
)
|
||||
|
||||
type agySteps struct {
|
||||
@@ -192,7 +193,7 @@ func agyUsage(groups []antigravity.UsageGroup) api.Usage {
|
||||
return u
|
||||
}
|
||||
|
||||
func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
|
||||
func (m *Manager) agyBeforeModel(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
in := struct {
|
||||
InvocationNum int `json:"invocationNum"`
|
||||
@@ -204,6 +205,7 @@ func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
|
||||
if in.InvocationNum == 0 || l.reviewID != "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
m.holdWhilePaused(ctx, l, "")
|
||||
mt := m.deliverMidTurn(ctx, l)
|
||||
if mt.text == "" {
|
||||
return map[string]any{}, nil
|
||||
@@ -211,3 +213,12 @@ func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
|
||||
return map[string]any{"injectSteps": []map[string]any{{"userMessage": mt.text}}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) agyBeforeTool(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
if !m.heldBeforeTool(ctx, l, "") || !m.midTurnWaiting(ctx, l) {
|
||||
return map[string]any{"decision": "allow"}, nil
|
||||
}
|
||||
return map[string]any{"decision": "deny", "reason": pausedCallNotice}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
Description: "Call one of your hands tools (screen_list, capture, ax_tree, act, browser, chrome) on a client Mac instead of the Mac byre runs on: " +
|
||||
"tool is the tool's name and arguments exactly what that tool takes. Window ids, refs, displays and Chrome tabs are that Mac's, so start with screen_list (or chrome tabs) there. " +
|
||||
"Every agent using that Mac shares one set of refs, so read the tree again if another agent may have. " +
|
||||
"A call fails until the human has given byre's app Screen Recording and Accessibility (and Automation of Orion, for browser) on that Mac; say so when it does. " +
|
||||
"A call fails until the human has given byre's app Screen Recording and Device Control and Data Access (and Automation of Orion, for browser) on that Mac; say so when it does. " +
|
||||
"chrome needs Google Chrome installed on that Mac; its trace files are saved there, so fetch one with client_download.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
|
||||
+57
-13
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/codex"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
)
|
||||
|
||||
var codexToolItems = map[string]bool{
|
||||
@@ -169,8 +170,11 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
return m.emit(ctx, l, agentActor, "agent.vendor", payload)
|
||||
}
|
||||
|
||||
content, isError := codexToolResult(item)
|
||||
err := m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{"tool_use_id": item.ID, "is_error": isError, "content": content})
|
||||
content, isError, err := codexToolResult(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{"tool_use_id": item.ID, "is_error": isError, "content": content})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -178,15 +182,20 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
if l.reviewID != "" {
|
||||
return nil
|
||||
}
|
||||
m.steerMidTurn(ctx, l, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) steerMidTurn(ctx context.Context, l *live, s *codex.Session) {
|
||||
mt := m.deliverMidTurn(ctx, l)
|
||||
if mt.text == "" {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
reply, err := s.Steer(mt.text)
|
||||
if err != nil {
|
||||
slog.Error("steer mid-turn delivery", "session", l.id, "err", err)
|
||||
m.restoreMidTurn(ctx, l, mt)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
@@ -196,7 +205,15 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
m.restoreMidTurn(ctx, l, mt)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) codexAfterTool(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
if m.holdWhilePaused(ctx, l, "") && ctx.Err() == nil {
|
||||
m.steerMidTurn(context.WithoutCancel(ctx), l, l.sess.(codexProcess).s)
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func imageAlt(prompt string) string {
|
||||
@@ -210,24 +227,51 @@ func imageAlt(prompt string) string {
|
||||
return alt
|
||||
}
|
||||
|
||||
func codexToolResult(item codex.ThreadItem) (string, bool) {
|
||||
func codexToolResult(item codex.ThreadItem) (any, bool, error) {
|
||||
status := codexStatus(item)
|
||||
switch item.Type {
|
||||
case codex.ThreadItemTypeCommandExecution:
|
||||
return item.AggregatedOutput, status != "completed" || item.ExitCode != 0
|
||||
return item.AggregatedOutput, status != "completed" || item.ExitCode != 0, nil
|
||||
case codex.ThreadItemTypeDynamicToolCall:
|
||||
texts := []string{}
|
||||
blocks := []map[string]any{}
|
||||
for _, c := range item.ContentItems {
|
||||
texts = append(texts, c.Text)
|
||||
if c.Type != codex.DynamicToolCallOutputContentItemTypeInputImage {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": c.Text})
|
||||
continue
|
||||
}
|
||||
mime, data, ok := strings.Cut(strings.TrimPrefix(c.ImageURL, "data:"), ";base64,")
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("dynamic tool image is not a base64 data url")
|
||||
}
|
||||
blocks = append(blocks, imageBlock(mime, data))
|
||||
}
|
||||
return strings.Join(texts, "\n"), !item.Success
|
||||
return blocks, !item.Success, nil
|
||||
case codex.ThreadItemTypeMcpToolCall:
|
||||
if item.Error != nil {
|
||||
return item.Error.Message, true
|
||||
return item.Error.Message, true, nil
|
||||
}
|
||||
return string(item.Result), false
|
||||
res := struct {
|
||||
Content []map[string]any `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}{}
|
||||
err := json.Unmarshal(item.Result, &res)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("decode mcp tool result: %w", err)
|
||||
}
|
||||
for i, b := range res.Content {
|
||||
if b["type"] == "image" {
|
||||
mime, _ := b["mimeType"].(string)
|
||||
data, _ := b["data"].(string)
|
||||
res.Content[i] = imageBlock(mime, data)
|
||||
}
|
||||
}
|
||||
return res.Content, res.IsError, nil
|
||||
}
|
||||
return status, status != "completed"
|
||||
return status, status != "completed", nil
|
||||
}
|
||||
|
||||
func imageBlock(mime, data string) map[string]any {
|
||||
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": mime, "data": data}}
|
||||
}
|
||||
|
||||
func (m *Manager) readCodexRateLimit() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -33,7 +34,13 @@ func (m *Manager) Compact(ctx context.Context, sessionID, model string) (api.Age
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
unlock := m.lockDelivery(sessionID)
|
||||
if m.isPaused(sessionID) {
|
||||
unlock()
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s is paused; resume it first", ErrConflict, sessionID)
|
||||
}
|
||||
err = m.startRunning(ctx, sess, "byre")
|
||||
unlock()
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
@@ -72,6 +79,7 @@ func isContinuation(actor, text string) bool {
|
||||
|
||||
func (m *Manager) continueFromHandoff(sessionID, handoff string) {
|
||||
ctx := context.Background()
|
||||
m.waitResumed(sessionID)
|
||||
err := m.handoff(ctx, sessionID, handoff)
|
||||
if err == nil {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/claude"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/store"
|
||||
"g.fc.run/ai/byre/internal/todo"
|
||||
)
|
||||
@@ -49,7 +49,7 @@ func asksQuestion(text string) bool {
|
||||
return endsQuestion.MatchString(paras[len(paras)-1])
|
||||
}
|
||||
|
||||
func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
||||
func (m *Manager) bounceProseQuestion(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
in := struct {
|
||||
LastAssistantMessage string `json:"last_assistant_message"`
|
||||
@@ -68,7 +68,7 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
||||
}
|
||||
|
||||
func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool, error) {
|
||||
if !asksQuestion(final) {
|
||||
if !asksQuestion(final) || m.isPaused(l.id) {
|
||||
return false, nil
|
||||
}
|
||||
first, err := m.store.FirstPrompt(ctx, l.taskID)
|
||||
@@ -159,7 +159,7 @@ func forkGuidance(task, src api.Task) string {
|
||||
src.Title, *task.ForkedAt, task.Branch, task.WorktreePath, src.WorktreePath, task.WorktreePath)
|
||||
}
|
||||
|
||||
func remindGuidance(text string) claude.HookFunc {
|
||||
func remindGuidance(text string) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
|
||||
@@ -304,6 +304,11 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
unlock := m.lockDelivery(sessionID)
|
||||
defer unlock()
|
||||
|
||||
if m.isPaused(sessionID) {
|
||||
slog.Info("deliver held: session paused", "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -400,6 +405,9 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn {
|
||||
unlock := m.lockDelivery(l.id)
|
||||
defer unlock()
|
||||
|
||||
if m.isPaused(l.id) {
|
||||
return midTurn{}
|
||||
}
|
||||
discard, err := m.store.GetDiscard(ctx, l.taskID)
|
||||
if err != nil {
|
||||
slog.Error("read discard state", "task", l.taskID, "err", err)
|
||||
@@ -428,6 +436,23 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn {
|
||||
return mt
|
||||
}
|
||||
|
||||
func (m *Manager) midTurnWaiting(ctx context.Context, l *live) bool {
|
||||
inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member)
|
||||
if err != nil {
|
||||
slog.Error("list queued messages", "session", l.id, "err", err)
|
||||
return false
|
||||
}
|
||||
if len(inputs) > 0 {
|
||||
return true
|
||||
}
|
||||
pending, err := m.store.Undelivered(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("list undelivered responses", "session", l.id, "err", err)
|
||||
return false
|
||||
}
|
||||
return len(pending) > 0
|
||||
}
|
||||
|
||||
func (m *Manager) restoreMidTurn(ctx context.Context, l *live, mt midTurn) {
|
||||
if len(mt.taskInputs) > 0 {
|
||||
err := m.store.RestoreTaskInputs(ctx, mt.taskInputs)
|
||||
|
||||
@@ -11,14 +11,15 @@ Go one step at a time: check, say in a line or two what is missing and what it i
|
||||
Agents run on the Mac byre runs on, the one your own Bash runs on. Check its tools with:
|
||||
|
||||
```
|
||||
"$SHELL" -l -c '/Applications/Byre.app/Contents/Helpers/byre check'
|
||||
"$SHELL" -l -i -c '/Applications/Byre.app/Contents/Helpers/byre check'
|
||||
```
|
||||
|
||||
It prints one JSON line: `ready` (false while something blocking is missing) and `checks`, each with `name`, `need` (`required`; `agent`, where one of claude, codex and agy is enough; or `optional`), `for` (what it is used for), `ok`, `command` (what installs it), `fix` (what to do after) and `blocking`.
|
||||
It prints one JSON line: `ready` (false while something blocking is missing) and `checks`, each with `name`, `need` (`required`; `agent`, where one of claude, codex and agy is enough; or `optional`), `for` (what it is used for), `ok`, `tool` (what installs it: `byre install-tool <tool>`; empty while Homebrew, which it needs, is missing), `fix` (what to do instead) and `blocking`.
|
||||
|
||||
- Run each missing tool's `command` yourself through the login shell, `"$SHELL" -l -c '<command>'`. `brew` must be there first; installing Homebrew asks for the human's password, so ask them to run its installer in a terminal on that Mac (the app's Server tab there lists each missing tool with a Run in Terminal button). `xcode-select --install` opens a macOS dialog on that Mac's screen for the human to click Install.
|
||||
- Sign-in needs the human at a terminal on that Mac: `claude`, `codex login` or `agy`, each run once. `claude auth status` (JSON, `loggedIn`) and `codex login status` show whether one is signed in; agy has no status command, so ask.
|
||||
- The service's `PATH` is fixed when it is installed, and your own shell has the same one. When a tool is found through the login shell but not by `/Applications/Byre.app/Contents/Helpers/byre check` run directly, reinstall the service from the login shell so it picks the new `PATH` up: `"$SHELL" -l -c '/Applications/Byre.app/Contents/Helpers/byre install'`. That restarts byre and cuts your turn off; you resume with a restart message, so say before running it that you will carry on afterwards.
|
||||
- `byre install-tool <tool>` installs one tool: `brew` (Homebrew, and its `shellenv` line in `~/.zprofile`), `git` (`xcode-select --install`, which opens a macOS dialog for the human to click Install), `claude` (its installer, and `~/.local/bin` on `PATH` in `~/.zshrc`), and `codex`, `agy`, `node` and `chrome` through Homebrew. Homebrew's installer asks for the human's password, and `claude`, `codex` and `agy` end by starting the CLI for the human to sign in, so these need the human at a terminal on that Mac: type the command into the task's terminal without the newline and ask them to press Enter, or point them at the app's Server tab there, which lists each missing tool with a Run in Terminal button. `node` and `chrome` you can run yourself through `"$SHELL" -l -c '<command>'`.
|
||||
- Sign-in, when a CLI is installed but not signed in: `claude`, `codex login` or `agy`, each run once by the human. `claude auth status` (JSON, `loggedIn`) and `codex login status` show whether one is signed in; agy has no status command, so ask.
|
||||
- The service's `PATH` is fixed when it is installed, and your own shell has the same one. It comes from an interactive login shell (`-l -i`), since claude's installer has the human add `~/.local/bin` to `.zshrc`. When a tool is found through that shell but not by `/Applications/Byre.app/Contents/Helpers/byre check` run directly, reinstall the service from it so it picks the new `PATH` up: `"$SHELL" -l -i -c '/Applications/Byre.app/Contents/Helpers/byre install'`. That restarts byre and cuts your turn off; you resume with a restart message, so say before running it that you will carry on afterwards. When you resume, check with `byre check` before reinstalling again: the restart usually took.
|
||||
- `byre install` and the app's Server tab (Turn off, Turn on) are the only ways to restart byre's service. Never run `launchctl` on it yourself, and never tell the human to.
|
||||
- Google Chrome, for agents' browser tools, asks "downloaded from the Internet" on its first launch, and can't be driven until the human answers Open.
|
||||
|
||||
## Permissions on the server's Mac
|
||||
@@ -32,14 +33,14 @@ d=$(mktemp -d) && cp /Applications/Byre.app/Contents/Helpers/byre-hands "$d/" &&
|
||||
It prints a JSON array, one entry per grant: `name`, `status` (`granted`, `missing`, or `unchecked` when the test couldn't run, such as Automation of Orion while Orion isn't running), `detail` (what to switch on) and `settings`, the `x-apple.systempreferences` URL of its System Settings pane.
|
||||
|
||||
- A missing folder grant makes macOS show a prompt on that Mac's screen during the check, and the check waits until it is answered, so tell the human to be at that Mac before you run it, and give the call a timeout of a few minutes.
|
||||
- For Screen Recording, Accessibility and Automation, open the pane for the human with `open '<settings>'` and tell them which switch to turn on. Accessibility can list byre switched off; only the switch being on counts.
|
||||
- For Screen Recording, Device Control and Data Access (what macOS 27 calls Accessibility) and Automation, open the pane for the human with `open '<settings>'` and tell them which switch to turn on. On a Mac that runs a server, the panes list two entries: `byre` with a black terminal icon is the server, whose grants agents on that Mac use, and `Byre` with byre's app icon is the app, whose grants client tools on that Mac use. The check's `detail` names the one it checked. Device Control and Data Access can list byre switched off; only the switch being on counts.
|
||||
- Check again after the human says they've done it.
|
||||
|
||||
## Client Macs
|
||||
|
||||
`list_clients` lists the human's Macs with the app open (and their iPhone, which needs nothing). Agents use a client Mac through `client_hands` and the client file tools, with grants that belong to byre's app there. Check them with `client_bash` on that Mac, running the same `byre-hands` command as above, and open a missing grant's pane there the same way with `open '<settings>'`. Ask before opening anything on a client Mac's screen, and only when the human is at it. The human can also run the check from the app's Settings tab on that Mac.
|
||||
|
||||
Client Macs need no agent CLIs. Google Chrome is optional there, for agents' `chrome` tool on that Mac; `"$SHELL" -l -c 'brew install --cask google-chrome'` through `client_bash` installs it when `brew` is there.
|
||||
Client Macs need no agent CLIs. Google Chrome is optional there, for agents' `chrome` tool on that Mac; `"$SHELL" -l -c '/Applications/Byre.app/Contents/Helpers/byre install-tool chrome'` through `client_bash` installs it when Homebrew is there.
|
||||
|
||||
## What to leave alone
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
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.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended) or suggesting the human discard it (with its reasoning), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that canceled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created or started merging never wake you on their own; they arrive only alongside another signal. A task merged or discarded always wakes you, whoever closed it. The states are running, idle, finished (a workflow stage's task once its finish_stage has taken), review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle or finished. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
|
||||
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors in the task's transcript, then finished turns, then tasks ready to merge or suggested for discarding), with one line saying what it is. If nothing needs them, show that project with no task, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/prj3f2a9C1b7dQ/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. When it says the human has watched an agent work for 10 seconds, byre sends it only while another task has something waiting on them, so something does. Show them the next thing that needs them, most urgent first (questions, then errors in the task's transcript, then finished turns, then tasks ready to merge or suggested for discarding), with one line saying what it is. If nothing needs them, show that project with no task, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/prj3f2a9C1b7dQ/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
|
||||
- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/prj3f2a9C1b7dQ/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that.
|
||||
- Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task's transcript, or a project's dashboard when it shows no task; point it with show. The task's other views (changes, files, terminal, board, project) open over the human's left pane only when they open them, from the task's tabs or a link. By default, keep the pane on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up:
|
||||
- Discussing, picking or starting TODO items, or a project's tasks as a whole: the project with no task (show with project_id).
|
||||
@@ -31,7 +31,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left
|
||||
- Reviews: after each new snapshot of a task, the stages of its project's review workflow (a saved workflow whose stages are single agents, set per project with set_review_workflow) review the change, each a reviewer agent on its stage's model; a stage after others gets their reviews. A project with no review workflow isn't reviewed. Once every stage has reviewed the snapshot, the task's agent gets all their findings in one message. The results are in the task's transcript, where its agent answers each finding with respond_to_finding (fixing, or ignoring with a reason), and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding.
|
||||
- Dig into context with list_tasks, read_transcript, read_inbox and read_status (vendor rate limits and agent health), and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message.
|
||||
- You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it.
|
||||
- Setup: when the human asks you to set byre up, or about the tools or macOS permissions its agents need, read the setup knowledge-base article and walk them through it, running its checks and fixes yourself with your own Bash on this Mac and with list_clients and client_bash on their Macs.
|
||||
- Setup: when the human asks you to set byre up, or about the tools or macOS permissions its agents need, read the setup knowledge-base article and walk them through it, running its checks and fixes yourself with your own Bash on this Mac and with list_clients and client_bash on their Macs. Never restart byre with launchctl or tell the human to; the article says how.
|
||||
- Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine.
|
||||
- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and the workflow finishes once every stage has; byre then discards the run's other stage tasks, leaving the task of each last stage (one per branch when it ends in a parallel) to merge. Each last stage's agent is asked whether its task is ready to merge as soon as its own stage has finished and been reviewed, without waiting for the other last stages. Whenever you show or propose a workflow, draw it as a fenced diagram block (its format is the diagrams knowledge-base article) of its stage names only, with no models: one row per step, a parallel's stages side by side in one row, and an arrow into each stage from the stages before it. For example, rows holding Build; Review and Test; Fix, with the arrows "build -> review", "build -> test", "review -> fix" and "test -> fix".
|
||||
- Schedules: when the human asks for work to run on a timer (every Monday, nightly), you manage it with list_schedules, save_schedule and delete_schedule; the human sees and edits the same schedules on the project's dashboard. Every schedule runs a saved workflow: for a one-agent chore, save a one-stage workflow with save_workflow first. On each run, byre starts the schedule's workflow as create_task with workflow would, created by byre, and you get its created signal; a run that fails to start reaches you as a signal.
|
||||
|
||||
@@ -497,7 +497,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
"If the pane already shows that task (or project), nothing changes and you are told so. "+
|
||||
"Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+
|
||||
"Otherwise refused while the human is working on what the pane shows (focused in it or in a view of its task, or with unsent text there), unless it shows a project with no task, or what it shows is finished (a merged or discarded task), "+
|
||||
"or it shows a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+
|
||||
"or it shows a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when the first happens, and when the second happens only while another task has something waiting on the human: an open question, a final message they haven't read, an error or rate limit nobody has messaged the agent since, or a suggestion to merge or discard. "+
|
||||
"Also refused for 10 seconds after the human started a TODO item, unless they switched task or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass, and sends no signal otherwise.",
|
||||
`{"type": "object", "properties": {
|
||||
"task_id": {"type": "string"},
|
||||
@@ -642,10 +642,43 @@ func (m *Manager) endWatchHold(ctx context.Context, login string, at time.Time)
|
||||
if err != nil || !waiting {
|
||||
return err
|
||||
}
|
||||
needed, err := m.needsHumanElsewhere(ctx, login, *f.TaskId)
|
||||
if err != nil || !needed {
|
||||
return err
|
||||
}
|
||||
text := fmt.Sprintf("Your right pane is free: the human has watched %s's agent work for 10 seconds without typing.", m.taskLabel(ctx, *f.TaskId))
|
||||
return m.addLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: text})
|
||||
}
|
||||
|
||||
func (m *Manager) needsHumanElsewhere(ctx context.Context, login, shown string) (bool, error) {
|
||||
tasks, err := m.store.ActiveTasks(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
reads, err := m.store.SummaryReads(ctx, login)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Id == shown || t.Background {
|
||||
continue
|
||||
}
|
||||
switch t.State {
|
||||
case api.TaskStateAsking, api.TaskStateReadyToMerge, api.TaskStateDiscardSuggested:
|
||||
return true, nil
|
||||
case api.TaskStateIdle, api.TaskStateNoAgent:
|
||||
if t.WorkflowStage == nil && t.SummarySeq > reads[t.Id] {
|
||||
return true, nil
|
||||
}
|
||||
errored, err := m.store.ErrorUnanswered(ctx, t.Id)
|
||||
if err != nil || errored {
|
||||
return errored, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func sameView(a, b api.Focus) bool {
|
||||
at, bt := deref(a.TaskId), deref(b.TaskId)
|
||||
if at != "" || bt != "" {
|
||||
|
||||
+84
-62
@@ -62,6 +62,7 @@ type Manager struct {
|
||||
startHolds map[string]showHold
|
||||
handoffModel map[string]string
|
||||
mergeTurns map[string]mergeTurn
|
||||
paused map[string]chan struct{}
|
||||
|
||||
deliverMu sync.Mutex
|
||||
deliverLocks map[string]*sessionLock
|
||||
@@ -165,6 +166,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav
|
||||
startHolds: map[string]showHold{},
|
||||
handoffModel: map[string]string{},
|
||||
mergeTurns: map[string]mergeTurn{},
|
||||
paused: map[string]chan struct{}{},
|
||||
signals: make(chan []api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
pokes: make(chan struct{}, 1),
|
||||
@@ -326,7 +328,9 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
if sess.State == api.AgentSessionStateCompleted {
|
||||
continue
|
||||
}
|
||||
unlock := m.lockDelivery(sess.Id)
|
||||
err = m.retire(ctx, sess, actor)
|
||||
unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -337,7 +341,9 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
|
||||
func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor string) error {
|
||||
m.stopLive(sess.Id)
|
||||
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
err := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
m.dropPause(sess.Id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) stopLive(sessionID string) {
|
||||
@@ -357,12 +363,79 @@ func (m *Manager) stopLive(sessionID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) recoverSession(ctx context.Context, sess api.AgentSession) error {
|
||||
task, err := m.taskFor(ctx, sess)
|
||||
if err != nil {
|
||||
slog.Error("resume session: load task", "session", sess.Id, "task", sess.TaskId, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
pending, err := m.store.SessionPending(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started, err := m.store.SessionStarted(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finished, err := m.store.StageFinished(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if finished || !pending && !started {
|
||||
m.endMergeTurn(sess.Id, true)
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("session left idle at startup: its stage finished or it never got its first message", "session", sess.Id, "task", task.Id)
|
||||
return nil
|
||||
}
|
||||
if pending {
|
||||
found, err := m.deliverRecorded(ctx, sess, task)
|
||||
if err != nil {
|
||||
slog.Error("resume session: deliver its recorded message", "session", sess.Id, "task", task.Id, "err", err)
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
l, err := m.ensureLive(ctx, sess, task)
|
||||
if err != nil {
|
||||
return m.failResume(ctx, sess, task, err)
|
||||
}
|
||||
|
||||
notice := restartNotice
|
||||
if sess.Role == api.Implementer {
|
||||
discard, err := m.store.GetDiscard(ctx, task.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discard.State == store.DiscardRunning && discard.Session == sess.Id {
|
||||
notice = discardRestartPrompt
|
||||
}
|
||||
}
|
||||
err = m.message(ctx, sess, task, l, "byre", notice, nil)
|
||||
if err != nil {
|
||||
return m.failResume(ctx, sess, task, err)
|
||||
}
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
|
||||
m.logUndelivered(ctx, sess.Id, "resumed with restart notice")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Recover(ctx context.Context) error {
|
||||
err := m.store.ClearMainPushes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.loadPauses(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.recoverPreviews(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -390,72 +463,18 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
task, err := m.taskFor(ctx, sess)
|
||||
if err != nil {
|
||||
slog.Error("resume session: load task", "session", sess.Id, "task", sess.TaskId, "err", err)
|
||||
m.mu.Lock()
|
||||
resumed := m.paused[sess.Id]
|
||||
m.mu.Unlock()
|
||||
if resumed != nil {
|
||||
slog.Info("session resume held: paused", "session", sess.Id, "task", sess.TaskId)
|
||||
go m.recoverAfterPause(sess.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
pending, err := m.store.SessionPending(ctx, sess.Id)
|
||||
err = m.recoverSession(ctx, sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started, err := m.store.SessionStarted(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finished, err := m.store.StageFinished(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if finished || !pending && !started {
|
||||
m.endMergeTurn(sess.Id, true)
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("session left idle at startup: its stage finished or it never got its first message", "session", sess.Id, "task", task.Id)
|
||||
continue
|
||||
}
|
||||
if pending {
|
||||
found, err := m.deliverRecorded(ctx, sess, task)
|
||||
if err != nil {
|
||||
slog.Error("resume session: deliver its recorded message", "session", sess.Id, "task", task.Id, "err", err)
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
l, err := m.ensureLive(ctx, sess, task)
|
||||
if err != nil {
|
||||
ferr := m.failResume(ctx, sess, task, err)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
notice := restartNotice
|
||||
if sess.Role == api.Implementer {
|
||||
discard, err := m.store.GetDiscard(ctx, task.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discard.State == store.DiscardRunning && discard.Session == sess.Id {
|
||||
notice = discardRestartPrompt
|
||||
}
|
||||
}
|
||||
err = m.message(ctx, sess, task, l, "byre", notice, nil)
|
||||
if err != nil {
|
||||
ferr := m.failResume(ctx, sess, task, err)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
continue
|
||||
}
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
|
||||
m.logUndelivered(ctx, sess.Id, "resumed with restart notice")
|
||||
}
|
||||
|
||||
idle, err := m.store.SessionsInState(ctx, api.AgentSessionStateIdle)
|
||||
@@ -576,6 +595,9 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID, client string, images []string) (api.AgentSession, error) {
|
||||
if m.isPaused(sess.Id) {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s is paused; resume it first", ErrConflict, sess.Id)
|
||||
}
|
||||
err := m.startRunning(ctx, sess, actor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
)
|
||||
|
||||
const pausedCallNotice = "Not run: the human paused you just before this call and sent you a message while you were paused. Read it, then carry on, making this call again only if it still fits."
|
||||
|
||||
func (m *Manager) Pause(ctx context.Context, sessionID, actor string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
if sess.Role != api.Implementer || sess.State == api.AgentSessionStateCompleted {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s is not an active task agent", ErrConflict, sess.Id)
|
||||
}
|
||||
|
||||
unlock := m.lockDelivery(sessionID)
|
||||
defer unlock()
|
||||
if m.isPaused(sessionID) {
|
||||
return sess, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.paused[sessionID] = make(chan struct{})
|
||||
m.mu.Unlock()
|
||||
err = m.store.SetSessionPaused(context.WithoutCancel(ctx), sessionID, true, actor)
|
||||
if err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.paused, sessionID)
|
||||
m.mu.Unlock()
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
slog.Info("session paused", "session", sessionID, "task", sess.TaskId, "actor", actor)
|
||||
return m.store.GetSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *Manager) Resume(ctx context.Context, sessionID, actor string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
unlock := m.lockDelivery(sessionID)
|
||||
if !m.isPaused(sessionID) {
|
||||
unlock()
|
||||
return sess, nil
|
||||
}
|
||||
err = m.store.SetSessionPaused(context.WithoutCancel(ctx), sessionID, false, actor)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
m.dropPause(sessionID)
|
||||
unlock()
|
||||
slog.Info("session resumed", "session", sessionID, "task", sess.TaskId, "actor", actor)
|
||||
|
||||
go func() {
|
||||
err := m.Deliver(context.Background(), sessionID)
|
||||
if err != nil {
|
||||
slog.Error("deliver after resume", "session", sessionID, "err", err)
|
||||
}
|
||||
}()
|
||||
return m.store.GetSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *Manager) loadPauses(ctx context.Context) error {
|
||||
sessions, err := m.store.PausedSessions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, sess := range sessions {
|
||||
m.paused[sess.Id] = make(chan struct{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) dropPause(sessionID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch := m.paused[sessionID]
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
delete(m.paused, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) waitResumed(sessionID string) {
|
||||
for {
|
||||
m.mu.Lock()
|
||||
ch := m.paused[sessionID]
|
||||
m.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
<-ch
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) recoverAfterPause(sessionID string) {
|
||||
ctx := context.Background()
|
||||
for {
|
||||
m.waitResumed(sessionID)
|
||||
unlock := m.lockDelivery(sessionID)
|
||||
if m.isPaused(sessionID) {
|
||||
unlock()
|
||||
continue
|
||||
}
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err == nil && sess.State == api.AgentSessionStateRunning {
|
||||
err = m.recoverSession(ctx, sess)
|
||||
}
|
||||
unlock()
|
||||
if err != nil {
|
||||
slog.Error("resume session after pause", "session", sessionID, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) isPaused(sessionID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.paused[sessionID] != nil
|
||||
}
|
||||
|
||||
func hookToolUseID(input json.RawMessage) string {
|
||||
in := struct {
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
}{}
|
||||
json.Unmarshal(input, &in)
|
||||
return in.ToolUseID
|
||||
}
|
||||
|
||||
func (m *Manager) holdWhilePaused(ctx context.Context, l *live, toolUseID string) bool {
|
||||
held := false
|
||||
for {
|
||||
m.mu.Lock()
|
||||
ch := m.paused[l.id]
|
||||
m.mu.Unlock()
|
||||
if ch == nil {
|
||||
return held
|
||||
}
|
||||
if !held {
|
||||
slog.Info("agent held by pause", "session", l.id, "tool_use_id", toolUseID)
|
||||
if toolUseID != "" {
|
||||
err := m.emit(context.WithoutCancel(ctx), l, agentActor, "agent.held", map[string]any{"tool_use_id": toolUseID})
|
||||
if err != nil {
|
||||
slog.Error("record held tool call", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
held = true
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
return held
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) heldBeforeTool(ctx context.Context, l *live, toolUseID string) bool {
|
||||
return m.holdWhilePaused(ctx, l, toolUseID) && ctx.Err() == nil
|
||||
}
|
||||
|
||||
func (m *Manager) beforeTool(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
if !m.heldBeforeTool(ctx, l, hookToolUseID(input)) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
mt := m.deliverMidTurn(ctx, l)
|
||||
if mt.text == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": pausedCallNotice,
|
||||
"additionalContext": mt.text,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/claude"
|
||||
"g.fc.run/ai/byre/internal/codex"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
)
|
||||
|
||||
@@ -74,8 +75,12 @@ func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *liv
|
||||
hooks := map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
}
|
||||
if l.implementer() {
|
||||
hooks["PreToolUse"] = []claude.HookMatcher{{Func: m.beforeTool(l)}}
|
||||
}
|
||||
if l.reviewID == "" {
|
||||
hooks["PostToolUse"] = []claude.HookMatcher{{Func: m.afterTool(l)}}
|
||||
hooks["PostToolUseFailure"] = []claude.HookMatcher{{Func: m.afterTool(l)}}
|
||||
if l.owner == "" {
|
||||
hooks["Stop"] = []claude.HookMatcher{{Func: m.bounceProseQuestion(l)}}
|
||||
}
|
||||
@@ -166,6 +171,9 @@ func (m *Manager) startCodex(ctx context.Context, sess api.AgentSession, l *live
|
||||
StdioMCP: stdio,
|
||||
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
|
||||
}
|
||||
if l.implementer() {
|
||||
opts.Hooks = map[string]hookcmd.Func{"PreToolUse": m.beforeTool(l), "PostToolUse": m.codexAfterTool(l)}
|
||||
}
|
||||
switch {
|
||||
case sess.VendorSessionId != "":
|
||||
opts.Resume = sess.VendorSessionId
|
||||
@@ -237,7 +245,7 @@ func (m *Manager) startAntigravity(ctx context.Context, sess api.AgentSession, l
|
||||
Reminder: g,
|
||||
MCPServers: servers,
|
||||
StdioMCP: stdio,
|
||||
Hooks: map[string]antigravity.HookFunc{"PreInvocation": m.agyBeforeModel(l)},
|
||||
Hooks: map[string]hookcmd.Func{"PreInvocation": m.agyBeforeModel(l), "PreToolUse": m.agyBeforeTool(l)},
|
||||
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, se
|
||||
if err != nil {
|
||||
return api.AgentSession{}, false, err
|
||||
}
|
||||
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || landing)
|
||||
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || landing || m.isPaused(sess.Id))
|
||||
if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) {
|
||||
sess, err = m.Send(ctx, sessionID, actor, text, model, sendID, client, images...)
|
||||
return sess, false, err
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/claude"
|
||||
"g.fc.run/ai/byre/internal/git"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/ids"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
"g.fc.run/ai/byre/internal/store"
|
||||
@@ -282,15 +282,23 @@ func (m *Manager) dropSnapshots(repo, taskID string) error {
|
||||
return git.DeleteSnapshotRefs(context.Background(), repo, taskID)
|
||||
}
|
||||
|
||||
func (m *Manager) afterTool(l *live) claude.HookFunc {
|
||||
func (m *Manager) afterTool(l *live) hookcmd.Func {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
in := struct {
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
}{}
|
||||
err := json.Unmarshal(input, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.holdWhilePaused(ctx, l, hookToolUseID(input))
|
||||
mt := m.deliverMidTurn(ctx, l)
|
||||
if mt.text == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "PostToolUse",
|
||||
"hookEventName": in.HookEventName,
|
||||
"additionalContext": mt.text,
|
||||
},
|
||||
}, nil
|
||||
|
||||
@@ -110,7 +110,8 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
"When the input line still holds unsent text after keys, text is refused, so that it isn't joined onto someone else's command: clear it with ctrl-u first, or set append to add to it on purpose. " +
|
||||
"Replies from this tool and terminal_read end with the shell's current input line when zsh is at its prompt. " +
|
||||
"The human sees everything you type and its output. " +
|
||||
"To hand the human a command to run (one needing their password, say), type it without the newline and tell them to press Enter in the Terminal tab. In a zsh terminal, the reply then says byre will message you when the shell next returns to its prompt, with the exit status and output, starting a turn if you are idle, so end your turn rather than asking whether they ran it. " +
|
||||
"In a zsh terminal, whenever a call leaves a command running or a line unsent, the reply says byre will message you when the shell next returns to its prompt, with the exit status and the output since the reply, starting a turn if you are idle: so after starting a long command, end your turn rather than polling it. " +
|
||||
"To hand the human a command to run (one needing their password, say), type it without the newline and tell them to press Enter in the Terminal tab, then end your turn rather than asking whether they ran it. " +
|
||||
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
@@ -156,7 +157,6 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
keys += b
|
||||
}
|
||||
term.CancelWake()
|
||||
startLine, _ := term.InputLine()
|
||||
sent := keys
|
||||
out := ""
|
||||
if keys != "" || args.Text == "" {
|
||||
@@ -188,7 +188,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if term.WakeOnPrompt(m.wakeOnPrompt(l), !submitted && (startLine != "" || args.Text != "")) {
|
||||
if term.WakeOnPrompt(m.wakeOnPrompt(l)) {
|
||||
out += "\n[byre messages you when the shell is next back at its prompt]"
|
||||
}
|
||||
return out, nil
|
||||
@@ -198,7 +198,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
|
||||
func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) {
|
||||
return func(exit string, out []byte) {
|
||||
text := fmt.Sprintf("The line you left unsent in the terminal was run, and the shell is back at its prompt with exit status %s (130 means it was canceled with Ctrl-C). The terminal's output since you typed it:\n\n%s", exit, terminal.Text(out))
|
||||
text := fmt.Sprintf("The terminal you last typed into is back at its prompt: the command there finished with exit status %s (130 means it was canceled with Ctrl-C). The terminal's output since your last terminal_type reply:\n\n%s", exit, terminal.Text(out))
|
||||
_, _, err := m.Message(context.Background(), l.id, "byre", text, "", "", "")
|
||||
if err != nil {
|
||||
slog.Error("wake agent on terminal prompt", "session", l.id, "task", l.taskID, "err", err)
|
||||
|
||||
@@ -21,14 +21,13 @@ import (
|
||||
"time"
|
||||
|
||||
"g.fc.run/ai/byre/internal/agentinput"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
"g.fc.run/ai/byre/internal/procgroup"
|
||||
)
|
||||
|
||||
const maxRuleBytes = 24000
|
||||
|
||||
type HookFunc func(ctx context.Context, input json.RawMessage) (map[string]any, error)
|
||||
|
||||
type Options struct {
|
||||
Dir string
|
||||
Cwd string
|
||||
@@ -40,7 +39,7 @@ type Options struct {
|
||||
Reminder string
|
||||
MCPServers []*mcp.Server
|
||||
StdioMCP []mcp.Stdio
|
||||
Hooks map[string]HookFunc
|
||||
Hooks map[string]hookcmd.Func
|
||||
OnBadLine func(line []byte, reason string)
|
||||
}
|
||||
|
||||
@@ -167,7 +166,7 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /"+token+"/mcp/{server}", s.serveMCP)
|
||||
mux.HandleFunc("POST /"+token+"/hook/{event}", s.serveHook)
|
||||
mux.HandleFunc("POST /"+token+"/hook/{event}", hookcmd.Handler(opts.Hooks))
|
||||
s.srv = &http.Server{Handler: mux}
|
||||
go s.srv.Serve(ln)
|
||||
|
||||
@@ -217,11 +216,16 @@ func (s *Session) writePlugin() error {
|
||||
|
||||
hooks := map[string]any{}
|
||||
for event := range s.opts.Hooks {
|
||||
hooks[event] = []map[string]any{{
|
||||
handlers := []map[string]any{{
|
||||
"type": "command",
|
||||
"command": fmt.Sprintf("curl -sS --max-time 25 --data-binary @- %s/hook/%s", s.base, event),
|
||||
"timeout": 30,
|
||||
"command": hookcmd.Command(s.base + "/hook/" + event),
|
||||
"timeout": hookcmd.Timeout,
|
||||
}}
|
||||
if event == "PreToolUse" || event == "PostToolUse" {
|
||||
hooks[event] = []map[string]any{{"matcher": "*", "hooks": handlers}}
|
||||
continue
|
||||
}
|
||||
hooks[event] = handlers
|
||||
}
|
||||
|
||||
files := map[string]any{
|
||||
@@ -282,27 +286,6 @@ func (s *Session) serveMCP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(resp)
|
||||
}
|
||||
|
||||
func (s *Session) serveHook(w http.ResponseWriter, r *http.Request) {
|
||||
event := r.PathValue("event")
|
||||
fn := s.opts.Hooks[event]
|
||||
if fn == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out, err := fn(r.Context(), body)
|
||||
if err != nil {
|
||||
slog.Error("agy hook failed", "event", event, "err", err)
|
||||
out = map[string]any{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func (s *Session) spawnLocked() error {
|
||||
args := []string{
|
||||
"--input-format", "stream-json",
|
||||
|
||||
+412
-345
@@ -694,6 +694,9 @@ type AgentSession struct {
|
||||
// Owner The login whose lead agent this is; empty for a task's session.
|
||||
Owner string `json:"owner"`
|
||||
|
||||
// Paused A task agent's pause: mid-turn it holds at its next step (after the tool call or model call in progress finishes); idle, no turn starts and messages queue until resumed.
|
||||
Paused bool `json:"paused"`
|
||||
|
||||
// Role reviewer: a review's session (see Review); a task's sessions list leaves these out.
|
||||
Role AgentSessionRole `json:"role"`
|
||||
State AgentSessionState `json:"state"`
|
||||
@@ -2241,6 +2244,12 @@ type ServerInterface interface {
|
||||
// (POST /api/sessions/{sessionId}/messages)
|
||||
SendMessage(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (POST /api/sessions/{sessionId}/pause)
|
||||
PauseSession(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (POST /api/sessions/{sessionId}/resume)
|
||||
ResumeSession(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (GET /api/tailnet)
|
||||
GetTailnet(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
@@ -3698,6 +3707,58 @@ func (siw *ServerInterfaceWrapper) SendMessage(w http.ResponseWriter, r *http.Re
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// PauseSession operation middleware
|
||||
func (siw *ServerInterfaceWrapper) PauseSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "sessionId" -------------
|
||||
var sessionId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.PauseSession(w, r, sessionId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ResumeSession operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ResumeSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "sessionId" -------------
|
||||
var sessionId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ResumeSession(w, r, sessionId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTailnet operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTailnet(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -4980,6 +5041,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/pause", wrapper.PauseSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/resume", wrapper.ResumeSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/blobs", wrapper.UploadBlob)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
|
||||
@@ -5024,351 +5087,355 @@ var swaggerSpec = []string{
|
||||
"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",
|
||||
"/3sA",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -18,15 +18,14 @@ import (
|
||||
"time"
|
||||
|
||||
"g.fc.run/ai/byre/internal/agentinput"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
"g.fc.run/ai/byre/internal/procgroup"
|
||||
)
|
||||
|
||||
type HookFunc func(ctx context.Context, input json.RawMessage) (map[string]any, error)
|
||||
|
||||
type HookMatcher struct {
|
||||
Matcher string
|
||||
Func HookFunc
|
||||
Func hookcmd.Func
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -56,6 +55,7 @@ var knownTypes = map[string]bool{
|
||||
"control_response": true,
|
||||
"control_cancel_request": true,
|
||||
"keep_alive": true,
|
||||
"command_lifecycle": true,
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -206,7 +206,7 @@ type Session struct {
|
||||
inflightMu sync.Mutex
|
||||
inflight map[string]context.CancelFunc
|
||||
|
||||
hooks map[string]HookFunc
|
||||
hooks map[string]hookcmd.Func
|
||||
servers map[string]*mcp.Server
|
||||
|
||||
idMu sync.Mutex
|
||||
@@ -236,7 +236,7 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
done: make(chan struct{}),
|
||||
pending: map[string]chan controlResult{},
|
||||
inflight: map[string]context.CancelFunc{},
|
||||
hooks: map[string]HookFunc{},
|
||||
hooks: map[string]hookcmd.Func{},
|
||||
servers: map[string]*mcp.Server{},
|
||||
id: initialID(opts),
|
||||
onBadLine: opts.OnBadLine,
|
||||
@@ -385,6 +385,7 @@ func (s *Session) hookConfig(hooks map[string][]HookMatcher) map[string]any {
|
||||
list = append(list, map[string]any{
|
||||
"matcher": m.Matcher,
|
||||
"hookCallbackIds": []string{id},
|
||||
"timeout": hookcmd.Timeout,
|
||||
})
|
||||
}
|
||||
cfg[event] = list
|
||||
@@ -585,7 +586,7 @@ func (s *Session) handleLine(line []byte) {
|
||||
}
|
||||
return
|
||||
|
||||
case "keep_alive":
|
||||
case "keep_alive", "command_lifecycle":
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -17,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"g.fc.run/ai/byre/internal/agentinput"
|
||||
"g.fc.run/ai/byre/internal/hookcmd"
|
||||
"g.fc.run/ai/byre/internal/mcp"
|
||||
"g.fc.run/ai/byre/internal/procgroup"
|
||||
)
|
||||
@@ -32,6 +36,7 @@ type Options struct {
|
||||
Reminder string
|
||||
MCPServers []*mcp.Server
|
||||
StdioMCP []mcp.Stdio
|
||||
Hooks map[string]hookcmd.Func
|
||||
OnBadLine func(line []byte, reason string)
|
||||
}
|
||||
|
||||
@@ -83,6 +88,7 @@ type Session struct {
|
||||
|
||||
servers map[string]*mcp.Server
|
||||
reminder string
|
||||
hookSrv *http.Server
|
||||
|
||||
onBadLine func(line []byte, reason string)
|
||||
lastLine atomic.Int64
|
||||
@@ -94,7 +100,10 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.open(ctx, opts)
|
||||
config, err := s.serveHooks(opts)
|
||||
if err == nil {
|
||||
err = s.open(ctx, opts, config)
|
||||
}
|
||||
if err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
@@ -102,6 +111,38 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) serveHooks(opts Options) (map[string]any, error) {
|
||||
config := map[string]any{}
|
||||
if len(opts.StdioMCP) > 0 {
|
||||
config["mcp_servers"] = stdioConfig(opts.StdioMCP)
|
||||
}
|
||||
if len(opts.Hooks) == 0 {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := rand.Text()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /"+token+"/hook/{event}", hookcmd.Handler(opts.Hooks))
|
||||
s.hookSrv = &http.Server{Handler: mux}
|
||||
go s.hookSrv.Serve(ln)
|
||||
|
||||
hooks := map[string]any{}
|
||||
for event := range opts.Hooks {
|
||||
hooks[event] = []map[string]any{{"hooks": []map[string]any{{
|
||||
"type": "command",
|
||||
"command": hookcmd.Command("http://" + ln.Addr().String() + "/" + token + "/hook/" + event),
|
||||
"timeout": hookcmd.Timeout,
|
||||
}}}}
|
||||
}
|
||||
config["hooks"] = hooks
|
||||
config["bypass_hook_trust"] = true
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func ReadRateLimits(ctx context.Context, cwd string, env []string) (RateLimitSnapshot, error) {
|
||||
s, err := spawn(Options{Cwd: cwd, Env: env})
|
||||
if err != nil {
|
||||
@@ -177,7 +218,7 @@ func newSession(opts Options, stdin *os.File, stdout io.Reader) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) open(ctx context.Context, opts Options) error {
|
||||
func (s *Session) open(ctx context.Context, opts Options, config map[string]any) error {
|
||||
err := s.initialize(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +233,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
|
||||
ApprovalPolicy: json.RawMessage(`"never"`),
|
||||
Sandbox: SandboxModeDangerFullAccess,
|
||||
DeveloperInstructions: opts.Guidance,
|
||||
Config: stdioConfig(opts.StdioMCP),
|
||||
Config: config,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resume thread: %w", err)
|
||||
@@ -211,7 +252,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
|
||||
ApprovalPolicy: json.RawMessage(`"never"`),
|
||||
Sandbox: SandboxModeDangerFullAccess,
|
||||
DeveloperInstructions: opts.Guidance,
|
||||
Config: stdioConfig(opts.StdioMCP),
|
||||
Config: config,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fork thread: %w", err)
|
||||
@@ -228,7 +269,7 @@ func (s *Session) open(ctx context.Context, opts Options) error {
|
||||
Sandbox: SandboxModeDangerFullAccess,
|
||||
DeveloperInstructions: opts.Guidance,
|
||||
DynamicTools: dynamicTools(opts.MCPServers),
|
||||
Config: stdioConfig(opts.StdioMCP),
|
||||
Config: config,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start thread: %w", err)
|
||||
@@ -251,14 +292,11 @@ func (s *Session) initialize(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func stdioConfig(servers []mcp.Stdio) map[string]any {
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg := map[string]any{}
|
||||
for _, srv := range servers {
|
||||
cfg[srv.Name] = map[string]any{"command": srv.Command, "args": srv.Args}
|
||||
}
|
||||
return map[string]any{"mcp_servers": cfg}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func dynamicTools(servers []*mcp.Server) []DynamicToolSpec {
|
||||
@@ -466,7 +504,14 @@ func (s *Session) notify(method string, params any) error {
|
||||
return s.write(m, time.Now().Add(rpcTimeout))
|
||||
}
|
||||
|
||||
func (s *Session) closeHooks() {
|
||||
if s.hookSrv != nil {
|
||||
s.hookSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Close() error {
|
||||
defer s.closeHooks()
|
||||
s.writeLock <- struct{}{}
|
||||
s.stdin.Close()
|
||||
<-s.writeLock
|
||||
@@ -482,6 +527,7 @@ func (s *Session) Close() error {
|
||||
}
|
||||
|
||||
func (s *Session) Kill() {
|
||||
defer s.closeHooks()
|
||||
s.cmd.Kill()
|
||||
<-s.done
|
||||
s.cmd.Wait()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package hookcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Timeout = 7 * 24 * 60 * 60
|
||||
|
||||
type Func func(ctx context.Context, input json.RawMessage) (map[string]any, error)
|
||||
|
||||
func Command(url string) string {
|
||||
return "curl -sS --data-binary @- " + url
|
||||
}
|
||||
|
||||
func Handler(hooks map[string]Func) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
event := r.PathValue("event")
|
||||
fn := hooks[event]
|
||||
if fn == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out, err := fn(r.Context(), body)
|
||||
if err != nil {
|
||||
slog.Error("hook failed", "event", event, "err", err)
|
||||
out = map[string]any{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1034,24 @@ func (s *Server) InterruptSession(w http.ResponseWriter, r *http.Request, sessio
|
||||
writeJSON(w, http.StatusAccepted, sess)
|
||||
}
|
||||
|
||||
func (s *Server) PauseSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
sess, err := s.agents.Pause(r.Context(), sessionID, actor(r))
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sess)
|
||||
}
|
||||
|
||||
func (s *Server) ResumeSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
sess, err := s.agents.Resume(r.Context(), sessionID, actor(r))
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sess)
|
||||
}
|
||||
|
||||
func writeAgentError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX events_attention;
|
||||
CREATE INDEX events_attention ON events(task_id) WHERE kind IN ('inbox.created', 'task.ready', 'session.error', 'session.rate_limited', 'task.discard_suggested');
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent_sessions ADD COLUMN paused INTEGER NOT NULL DEFAULT 0;
|
||||
+25
-30
@@ -189,57 +189,53 @@ func pendingStages(ctx context.Context, tx *sql.Tx) ([]StageStart, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type defined struct {
|
||||
review api.Review
|
||||
defID string
|
||||
type reviewRun struct {
|
||||
defID string
|
||||
byStage map[string]api.Review
|
||||
}
|
||||
found := []defined{}
|
||||
runs := map[SnapshotRef]reviewRun{}
|
||||
for rows.Next() {
|
||||
d := defined{}
|
||||
d.review, err = scanReview(trailingScanner{rows, []any{&d.defID}})
|
||||
defID := ""
|
||||
r, err := scanReview(trailingScanner{rows, []any{&defID}})
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, d)
|
||||
if defID == "" {
|
||||
continue
|
||||
}
|
||||
ref := SnapshotRef{TaskID: r.TaskId, N: r.SnapshotN}
|
||||
run, found := runs[ref]
|
||||
if !found {
|
||||
run = reviewRun{defID: defID, byStage: map[string]api.Review{}}
|
||||
runs[ref] = run
|
||||
}
|
||||
run.byStage[r.Stage] = r
|
||||
}
|
||||
err = errors.Join(rows.Err(), rows.Close())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byTask := map[string]map[string]api.Review{}
|
||||
for _, d := range found {
|
||||
if byTask[d.review.TaskId] == nil {
|
||||
byTask[d.review.TaskId] = map[string]api.Review{}
|
||||
}
|
||||
byTask[d.review.TaskId][d.review.Stage] = d.review
|
||||
}
|
||||
defs := map[string]api.WorkflowDefinition{}
|
||||
starts := []StageStart{}
|
||||
seen := map[string]bool{}
|
||||
for _, d := range found {
|
||||
if d.review.State != api.ReviewStateDone || d.defID == "" {
|
||||
continue
|
||||
}
|
||||
def, loaded := defs[d.defID]
|
||||
for ref, run := range runs {
|
||||
def, loaded := defs[run.defID]
|
||||
if !loaded {
|
||||
def, err = loadDefinition(ctx, tx, d.defID)
|
||||
def, err = loadDefinition(ctx, tx, run.defID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs[d.defID] = def
|
||||
defs[run.defID] = def
|
||||
}
|
||||
byStage := byTask[d.review.TaskId]
|
||||
for _, next := range workflow.Next(def, d.review.Stage) {
|
||||
key := d.review.TaskId + "\x00" + next
|
||||
if _, started := byStage[next]; started || seen[key] {
|
||||
for _, stage := range workflow.Stages(def) {
|
||||
if _, started := run.byStage[stage.Stage]; started {
|
||||
continue
|
||||
}
|
||||
inputs := []api.Review{}
|
||||
after := workflow.After(def, next)
|
||||
after := workflow.After(def, stage.Stage)
|
||||
for _, a := range after {
|
||||
in := byStage[a]
|
||||
in := run.byStage[a]
|
||||
if in.State != api.ReviewStateDone {
|
||||
break
|
||||
}
|
||||
@@ -248,8 +244,7 @@ func pendingStages(ctx context.Context, tx *sql.Tx) ([]StageStart, error) {
|
||||
if len(inputs) != len(after) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
starts = append(starts, StageStart{TaskID: d.review.TaskId, SnapshotN: d.review.SnapshotN, DefinitionID: d.defID, Definition: def, Stage: next, Inputs: inputs})
|
||||
starts = append(starts, StageStart{TaskID: ref.TaskID, SnapshotN: ref.N, DefinitionID: run.defID, Definition: def, Stage: stage.Stage, Inputs: inputs})
|
||||
}
|
||||
}
|
||||
return starts, nil
|
||||
|
||||
+39
-3
@@ -552,6 +552,15 @@ func (s *Store) ActiveTasks(ctx context.Context) ([]api.Task, error) {
|
||||
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE status = ? ORDER BY created_at`, string(api.TaskStatusActive))
|
||||
}
|
||||
|
||||
func (s *Store) ErrorUnanswered(ctx context.Context, taskID string) (bool, error) {
|
||||
unanswered := false
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT coalesce(max(CASE WHEN kind != 'user.message' THEN seq END), 0) > coalesce(max(CASE WHEN kind = 'user.message' THEN seq END), 0)
|
||||
FROM events WHERE task_id = ? AND kind IN ('session.error', 'session.rate_limited', 'user.message')
|
||||
`, taskID).Scan(&unanswered)
|
||||
return unanswered, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveSubtasks(ctx context.Context, parentID string) ([]api.Task, error) {
|
||||
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE parent_task_id = ? AND status = ? ORDER BY created_at`, parentID, string(api.TaskStatusActive))
|
||||
}
|
||||
@@ -1367,6 +1376,32 @@ func (s *Store) SessionsWithSubagents(ctx context.Context) ([]api.AgentSession,
|
||||
return s.querySessions(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE subagents > 0`)
|
||||
}
|
||||
|
||||
func (s *Store) PausedSessions(ctx context.Context) ([]api.AgentSession, error) {
|
||||
return s.querySessions(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE paused AND state != ?`, string(api.AgentSessionStateCompleted))
|
||||
}
|
||||
|
||||
func (s *Store) SetSessionPaused(ctx context.Context, id string, paused bool, actor string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
taskID := ""
|
||||
projectID := ""
|
||||
owner := ""
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
|
||||
`, id).Scan(&taskID, &projectID, &owner)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET paused = ? WHERE id = ?`, paused, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(sessionEvent("session.paused", projectID, taskID, owner, id, actor, map[string]any{"paused": paused}))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetSubagents(ctx context.Context, id string, n int, actor string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
taskID := ""
|
||||
@@ -1688,10 +1723,11 @@ const taskColumns = `id, project_id, title, merge_title, branch, worktree_path,
|
||||
// Keep the child lookup separate from the task itself so SQLite can use tasks_parent.
|
||||
const backgroundExpr = `(audit != '' OR EXISTS (SELECT 1 FROM tasks r WHERE r.id = tasks.parent_task_id AND tasks.workflow_stage != '' AND r.audit != ''))
|
||||
AND NOT EXISTS (SELECT 1 FROM events WHERE events.task_id IN (SELECT tasks.id UNION ALL SELECT c.id FROM tasks c WHERE c.parent_task_id = tasks.id AND c.parent_task_id != '' AND tasks.workflow_name != '')
|
||||
AND (kind IN ('inbox.created', 'task.ready', 'session.error', 'session.rate_limited')
|
||||
AND kind IN ('inbox.created', 'task.ready', 'session.error', 'session.rate_limited', 'task.discard_suggested')
|
||||
AND (kind != 'task.discard_suggested'
|
||||
OR (kind = 'task.discard_suggested' AND NOT EXISTS (SELECT 1 FROM tasks r JOIN workflow_definitions d ON d.id = r.workflow_definition_id WHERE r.id IN (tasks.id, tasks.parent_task_id) AND d.auto_discard))))`
|
||||
|
||||
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at, subagents`
|
||||
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at, subagents, paused`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
@@ -1799,7 +1835,7 @@ func scanSession(row scanner) (api.AgentSession, error) {
|
||||
createdAt := ""
|
||||
forkedFrom := ""
|
||||
forkedAt := ""
|
||||
err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.Member, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents)
|
||||
err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.Member, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents, &sess.Paused)
|
||||
if err != nil {
|
||||
return sess, err
|
||||
}
|
||||
|
||||
@@ -366,10 +366,10 @@ func (term *Terminal) InputLine() (string, bool) {
|
||||
return term.line, term.atPrompt
|
||||
}
|
||||
|
||||
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte), mayRun bool) bool {
|
||||
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte)) bool {
|
||||
term.mu.Lock()
|
||||
defer term.mu.Unlock()
|
||||
if term.atPrompt && term.line == "" || !term.atPrompt && !mayRun {
|
||||
if term.atPrompt && term.line == "" {
|
||||
return false
|
||||
}
|
||||
term.wake = &wake{fn: fn}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CapturesView: View {
|
||||
let captures: [Capture]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
ForEach(captures.reversed()) { c in
|
||||
CaptureItem(capture: c)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Palette.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CaptureItem: View {
|
||||
let capture: Capture
|
||||
@ViewState private var image: PlatformImage?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(capture.tool)
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
Text(clockText(capture.when))
|
||||
.foregroundStyle(Palette.muted)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if !capture.note.isEmpty {
|
||||
Text(verbatim: capture.note)
|
||||
.font(Fonts.font(12, mono: true))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let image {
|
||||
Image(platformImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: image.size.width, alignment: .leading)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
||||
}
|
||||
}
|
||||
.task(id: capture.id) {
|
||||
image = Data(base64Encoded: capture.base64).flatMap { PlatformImage(data: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ final class ChatModel {
|
||||
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var started = false
|
||||
@ObservationIgnored private var opened = 0
|
||||
@ObservationIgnored private var settled = false
|
||||
@ObservationIgnored private var cursors: [String: Int] = [:]
|
||||
|
||||
init(_ kind: ChatKind, _ store: Store) {
|
||||
@@ -172,6 +174,10 @@ final class ChatModel {
|
||||
builder.open && session?.state == .running
|
||||
}
|
||||
|
||||
var paused: Bool {
|
||||
liveSession?.paused ?? false
|
||||
}
|
||||
|
||||
var reviewing: Bool {
|
||||
let state = taskId.flatMap { store.findTask($0) }?.state
|
||||
return state == .reviewing || state == .reviewPending || state == .mergeAfterReview
|
||||
@@ -185,6 +191,10 @@ final class ChatModel {
|
||||
row.pending && (running || subagents && row.inActive || row.inReview) || row.holdsActive && (running || subagents)
|
||||
}
|
||||
|
||||
func fades(_ row: Row) -> Bool {
|
||||
row.firstId > opened
|
||||
}
|
||||
|
||||
var hasOlder: Bool {
|
||||
kind == .lead && store.leadHasOlder()
|
||||
}
|
||||
@@ -246,6 +256,10 @@ final class ChatModel {
|
||||
|
||||
private func appended(_ all: [ByreEvent]) {
|
||||
_ = builder.append(all.filter(shows))
|
||||
if !settled {
|
||||
opened = Row.newest
|
||||
settled = feed?.ready == true
|
||||
}
|
||||
jump()
|
||||
}
|
||||
|
||||
@@ -549,6 +563,20 @@ final class ChatModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPaused(_ sessionId: String, _ paused: Bool, _ overlays: Overlays) {
|
||||
Task {
|
||||
do {
|
||||
if paused {
|
||||
_ = try await store.api.client.pauseSession(path: .init(sessionId: sessionId)).ok
|
||||
} else {
|
||||
_ = try await store.api.client.resumeSession(path: .init(sessionId: sessionId)).ok
|
||||
}
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
@@ -815,6 +843,24 @@ private struct ScrollMetrics: Equatable {
|
||||
let offset: CGFloat
|
||||
}
|
||||
|
||||
private struct PausedLine: View {
|
||||
let running: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "pause.fill")
|
||||
.font(.system(size: 10))
|
||||
Text(running ? "Paused: finishes its current step, then holds until you resume" : "Paused: no turn starts until you resume")
|
||||
.font(Fonts.font(13))
|
||||
}
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TranscriptScroll: View {
|
||||
let model: ChatModel
|
||||
@Environment(Store.self) private var store
|
||||
@@ -845,6 +891,10 @@ private struct TranscriptScroll: View {
|
||||
RowView(row: row, model: model, nested: false)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
if model.paused {
|
||||
PausedLine(running: running)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
ForEach(model.builder.pending) { p in
|
||||
PendingView(pending: p, model: model)
|
||||
.padding(.top, 12)
|
||||
@@ -1117,6 +1167,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
|
||||
var boxed = true
|
||||
var nested = false
|
||||
var working = false
|
||||
var fade = false
|
||||
var status: (text: String, color: Color)? = nil
|
||||
@ViewBuilder let summary: Summary
|
||||
@ViewBuilder let content: Content
|
||||
@@ -1153,8 +1204,9 @@ struct DetailsBox<Summary: View, Content: View>: View {
|
||||
.background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
if boxed {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(working && shown ? accent : Palette.border)
|
||||
.animation(.easeInOut(duration: 0.6), value: working && shown)
|
||||
let lit = working && (shown || !fade)
|
||||
RoundedRectangle(cornerRadius: 8).stroke(lit ? accent : Palette.border)
|
||||
.animation(.easeInOut(duration: 0.6), value: lit)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -1276,19 +1328,19 @@ struct RowView: View {
|
||||
}
|
||||
|
||||
case .tool(let name, let brief, let input):
|
||||
DetailsBox(nested: nested, working: model.working(row)) {
|
||||
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) {
|
||||
SubagentTag(show: row.tag)
|
||||
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
|
||||
} content: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PreBox(text: input)
|
||||
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
|
||||
ToolResult(result: row.result, unfinished: row.unfinished, held: row.held, started: row.started)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
case .subagent(let title, let description, let prompt):
|
||||
DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
|
||||
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
|
||||
SubagentTag(show: row.tag)
|
||||
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
|
||||
} content: {
|
||||
@@ -1298,12 +1350,12 @@ struct RowView: View {
|
||||
ForEach(row.body!.rows) { child in
|
||||
RowView(row: child, model: model, nested: true)
|
||||
}
|
||||
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
|
||||
ToolResult(result: row.result, unfinished: row.unfinished, held: row.held, started: row.started)
|
||||
}
|
||||
}
|
||||
|
||||
case .reviewer(let agentModel):
|
||||
DetailsBox(nested: nested, working: row.active, status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
|
||||
DetailsBox(nested: nested, working: row.active, fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
|
||||
Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]))
|
||||
.lineLimit(1)
|
||||
} content: {
|
||||
@@ -1317,7 +1369,7 @@ struct RowView: View {
|
||||
}
|
||||
|
||||
case .group:
|
||||
DetailsBox(nested: nested, working: model.working(row)) {
|
||||
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) {
|
||||
SubagentTag(show: row.tag)
|
||||
RunSummary(calls: row.calls)
|
||||
} content: {
|
||||
@@ -1651,11 +1703,14 @@ private struct DiscussItem: View {
|
||||
private struct ToolResult: View {
|
||||
let result: String?
|
||||
let unfinished: Bool
|
||||
let held: Bool
|
||||
let started: Date?
|
||||
|
||||
var body: some View {
|
||||
if let result {
|
||||
PreBox(text: result)
|
||||
} else if held {
|
||||
label("held by pause")
|
||||
} else if unfinished || started == nil {
|
||||
label(unfinished ? "no result" : "running…")
|
||||
} else {
|
||||
|
||||
@@ -527,7 +527,7 @@ struct ComposerView: View {
|
||||
controls(m)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
status
|
||||
actions(live?.id, running: sessionState == .running)
|
||||
actions(live?.id, running: sessionState == .running, paused: live?.paused ?? false)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
@@ -622,16 +622,32 @@ struct ComposerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func actions(_ sessionId: String?, running: Bool) -> some View {
|
||||
private func actions(_ sessionId: String?, running: Bool, paused: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button("Interrupt") {
|
||||
Button {
|
||||
if let sessionId {
|
||||
chat.interrupt(sessionId, overlays)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "stop.fill")
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(danger: true))
|
||||
.disabled(!running)
|
||||
.fixedSize()
|
||||
.help("Interrupt the agent's turn")
|
||||
if case .task = chat.kind, !chat.team, !chat.closed {
|
||||
Button {
|
||||
if let sessionId {
|
||||
chat.setPaused(sessionId, !paused, overlays)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: paused ? "play.fill" : "pause.fill")
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(sessionId == nil)
|
||||
.help(paused ? "Resume the agent" : "Pause the agent after its current step")
|
||||
}
|
||||
Button(chat.closed ? "Continue" : "Send") {
|
||||
submit(modelState())
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ final class Overlays {
|
||||
var leftTab = LeftTab.lead
|
||||
var leadFocus = 0
|
||||
var tabFocus = 0
|
||||
var projectFocus = false
|
||||
var listKeys = false
|
||||
var listCursor: String?
|
||||
var projectTab = ProjectTab.tasks
|
||||
var taskList = false
|
||||
@ObservationIgnored var pickerFrame: CGRect = .zero
|
||||
@@ -70,6 +71,7 @@ final class Overlays {
|
||||
@ObservationIgnored private var holders = 0
|
||||
@ObservationIgnored private var ctrl: @MainActor (String) -> Bool = { _ in false }
|
||||
@ObservationIgnored private var escape: @MainActor () -> Bool = { false }
|
||||
@ObservationIgnored private var list: @MainActor (UInt16) -> Bool = { _ in false }
|
||||
#endif
|
||||
|
||||
var isOpen: Bool {
|
||||
@@ -138,6 +140,14 @@ final class Overlays {
|
||||
switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame)
|
||||
}
|
||||
|
||||
func takeListKeys(_ cursor: String?) {
|
||||
listKeys = true
|
||||
listCursor = cursor
|
||||
#if os(macOS)
|
||||
window?.makeFirstResponder(nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
func closeTop() {
|
||||
if !alerts.isEmpty {
|
||||
alerts.removeLast()
|
||||
@@ -151,10 +161,11 @@ final class Overlays {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func install(_ ctrl: @escaping @MainActor (String) -> Bool, escape: @escaping @MainActor () -> Bool) {
|
||||
func install(_ ctrl: @escaping @MainActor (String) -> Bool, escape: @escaping @MainActor () -> Bool, list: @escaping @MainActor (UInt16) -> Bool) {
|
||||
holders += 1
|
||||
self.ctrl = ctrl
|
||||
self.escape = escape
|
||||
self.list = list
|
||||
if monitor != nil {
|
||||
return
|
||||
}
|
||||
@@ -174,7 +185,11 @@ final class Overlays {
|
||||
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
|
||||
let win = event.window.map(ObjectIdentifier.init)
|
||||
MainActor.assumeIsolated {
|
||||
if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover {
|
||||
guard let self, let window = self.window, win == ObjectIdentifier(window) else {
|
||||
return
|
||||
}
|
||||
self.listKeys = false
|
||||
if self.menu != nil, !self.menuHover {
|
||||
self.menu = nil
|
||||
}
|
||||
}
|
||||
@@ -198,10 +213,13 @@ final class Overlays {
|
||||
_ = escape()
|
||||
return true
|
||||
}
|
||||
let enter = code == 36 || code == 76
|
||||
if !isOpen {
|
||||
if listKeys && (enter || code == 125 || code == 126) && flags.isDisjoint(with: [.command, .control, .option, .shift]) {
|
||||
return list(code)
|
||||
}
|
||||
return false
|
||||
}
|
||||
let enter = code == 36 || code == 76
|
||||
if !alerts.isEmpty {
|
||||
if enter {
|
||||
alerts.removeLast()
|
||||
@@ -248,6 +266,7 @@ final class Overlays {
|
||||
}
|
||||
ctrl = { _ in false }
|
||||
escape = { false }
|
||||
list = { _ in false }
|
||||
if let monitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,19 @@ final class Listeners<T> {
|
||||
}
|
||||
}
|
||||
|
||||
struct Capture: Identifiable {
|
||||
let id: String
|
||||
let tool: String
|
||||
let note: String
|
||||
let when: Date
|
||||
let base64: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Feed {
|
||||
private(set) var events: [ByreEvent] = []
|
||||
private(set) var captures: [Capture] = []
|
||||
private(set) var ready = false
|
||||
private(set) var inherited = 0
|
||||
let appended = Listeners<[ByreEvent]>()
|
||||
@@ -31,6 +40,7 @@ final class Feed {
|
||||
@ObservationIgnored private var last: Int64 = 0
|
||||
@ObservationIgnored private var buffer: [ByreEvent] = []
|
||||
@ObservationIgnored private var scheduled = false
|
||||
@ObservationIgnored private var toolNames: [String: String] = [:]
|
||||
@ObservationIgnored private let shown: () -> Bool
|
||||
|
||||
init(shown: @escaping () -> Bool) {
|
||||
@@ -66,6 +76,30 @@ final class Feed {
|
||||
|
||||
func prepend(_ older: [ByreEvent]) {
|
||||
events.insert(contentsOf: older, at: 0)
|
||||
captures.insert(contentsOf: findCaptures(older), at: 0)
|
||||
}
|
||||
|
||||
private func findCaptures(_ batch: [ByreEvent]) -> [Capture] {
|
||||
var found: [Capture] = []
|
||||
for ev in batch {
|
||||
let p = ev.payload.value
|
||||
if ev.kind == "agent.tool_call" {
|
||||
toolNames[jsString(p["id"])] = toolLabel(jsString(p["name"]))
|
||||
continue
|
||||
}
|
||||
guard ev.kind == "agent.tool_result", let list = p["content"] as? [(any Sendable)?] else {
|
||||
continue
|
||||
}
|
||||
let blocks = list.compactMap { $0 as? JSONDict }
|
||||
let note = blocks.compactMap { str($0, "type") == "text" ? str($0, "text") : nil }.first ?? ""
|
||||
for (i, b) in blocks.enumerated() {
|
||||
guard str(b, "type") == "image", let source = dict(b, "source"), let data = str(source, "data") else {
|
||||
continue
|
||||
}
|
||||
found.append(Capture(id: "\(ev.seq)/\(i)", tool: toolNames[jsString(p["tool_use_id"])] ?? "", note: note, when: ev.createdAt, base64: data))
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func flush() {
|
||||
@@ -97,6 +131,10 @@ final class Feed {
|
||||
return
|
||||
}
|
||||
events.append(contentsOf: fresh)
|
||||
let found = findCaptures(fresh)
|
||||
if !found.isEmpty {
|
||||
captures.append(contentsOf: found)
|
||||
}
|
||||
if shown() {
|
||||
appended.send(fresh)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,12 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp
|
||||
|
||||
@MainActor
|
||||
private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _ leave: @escaping Leave) -> [MergeWarning] {
|
||||
let task = store.findTask(taskId)
|
||||
let reviewing = !(store.projects.first { $0.id == task?.projectId }?.reviewWorkflow.isEmpty ?? true)
|
||||
if f.reviewSnapshot == 0 {
|
||||
if !reviewing {
|
||||
return []
|
||||
}
|
||||
return [MergeWarning(text: "These changes haven't been reviewed")]
|
||||
}
|
||||
var items: [MergeWarning] = []
|
||||
@@ -119,7 +124,7 @@ private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _
|
||||
store.jumpTo(taskId, .review(f.reviewSnapshot))
|
||||
}
|
||||
}
|
||||
if f.reviewCount == 0 {
|
||||
if f.reviewCount == 0 && reviewing {
|
||||
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
|
||||
}
|
||||
if f.reviewRunning {
|
||||
@@ -131,7 +136,7 @@ private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _
|
||||
if f.reviewFindings > 0 {
|
||||
items.append(MergeWarning(text: "The review of the latest snapshot has \(f.reviewFindings) open finding\(f.reviewFindings == 1 ? "" : "s")", open: open))
|
||||
}
|
||||
if f.unreviewedChanges {
|
||||
if f.unreviewedChanges && reviewing {
|
||||
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -643,6 +643,7 @@ private struct WebPage {
|
||||
|
||||
final class Coordinator {
|
||||
var reloads = 0
|
||||
var shown = false
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
@@ -659,11 +660,23 @@ private struct WebPage {
|
||||
|
||||
private func update(_ view: WKWebView, _ c: Coordinator) {
|
||||
view.isHidden = !shown
|
||||
if shown, !c.shown {
|
||||
focus(view)
|
||||
}
|
||||
c.shown = shown
|
||||
if c.reloads != reloads {
|
||||
c.reloads = reloads
|
||||
view.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func focus(_ view: WKWebView) {
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
unsafe view.window?.makeFirstResponder(view)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -7,6 +7,7 @@ let tabLabels: [TaskTab: String] = [
|
||||
.changes: "Changes",
|
||||
.files: "Files",
|
||||
.page: "Page",
|
||||
.captures: "Captures",
|
||||
.terminal: "Terminal",
|
||||
.workflow: "Workflow",
|
||||
]
|
||||
@@ -60,6 +61,8 @@ struct MainView: View {
|
||||
ctrlGo(key, store, overlays)
|
||||
} escape: {
|
||||
store.closeSheet()
|
||||
} list: { code in
|
||||
listKey(code, store, overlays)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -249,6 +252,8 @@ struct TaskTabBody: View {
|
||||
if let shown = store.selectedTaskId.flatMap({ store.shownPages[$0] }) {
|
||||
PageTabs(shown: shown)
|
||||
}
|
||||
case .captures:
|
||||
CapturesView(captures: store.selectedTaskId.map { store.captures($0) } ?? [])
|
||||
case .terminal:
|
||||
TerminalTab()
|
||||
case .workflow:
|
||||
@@ -299,6 +304,18 @@ struct TaskBar: View {
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
Button {
|
||||
overlays.openSwitcher(store)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(Fonts.font(13, weight: 600))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New task (⌘K)")
|
||||
.accessibilityLabel("New task")
|
||||
Button {
|
||||
overlays.taskList.toggle()
|
||||
} label: {
|
||||
@@ -417,6 +434,7 @@ struct TabStrip: View {
|
||||
switch t {
|
||||
case .board: return team
|
||||
case .page: return store.shownPages[taskId] != nil
|
||||
case .captures: return !store.captures(taskId).isEmpty
|
||||
case .workflow: return false
|
||||
default: return true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ struct ProjectView: View {
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
|
||||
@ViewState private var off: (() -> Void)?
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -46,20 +45,10 @@ struct ProjectView: View {
|
||||
Muted(text: "No project selected.")
|
||||
}
|
||||
}
|
||||
.followCursor()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focused($focused)
|
||||
.onChange(of: overlays.projectFocus, initial: true) {
|
||||
if overlays.projectFocus {
|
||||
overlays.projectFocus = false
|
||||
DispatchQueue.main.async {
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
off = store.events.add { ev in
|
||||
if refreshKinds.contains(ev.kind), let projectId = ev.projectId {
|
||||
@@ -238,6 +227,77 @@ private func rowIndent(_ task: ByreTask, _ depth: Int) -> CGFloat {
|
||||
task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func listedTasks(_ store: Store, _ overlays: Overlays) -> [ByreTask]? {
|
||||
let active = { (p: Project) in nested(store, (store.tasks[p.id] ?? []).filter { $0.status == .active }).map(\.task) }
|
||||
if overlays.taskList {
|
||||
return store.projects.flatMap(active)
|
||||
}
|
||||
if store.selectedTaskId == nil, overlays.projectTab == .tasks, let p = store.focusedProject() {
|
||||
return active(p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cursorTask(_ store: Store, _ overlays: Overlays) -> String? {
|
||||
guard overlays.listKeys, let tasks = listedTasks(store, overlays) else {
|
||||
return nil
|
||||
}
|
||||
return tasks.first { $0.id == overlays.listCursor }?.id ?? tasks.first?.id
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
func listKey(_ code: UInt16, _ store: Store, _ overlays: Overlays) -> Bool {
|
||||
if NSApp.keyWindow?.firstResponder is NSTextView || terminalFocused() {
|
||||
overlays.listKeys = false
|
||||
return false
|
||||
}
|
||||
guard let tasks = listedTasks(store, overlays), !tasks.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let i = tasks.firstIndex { $0.id == cursorTask(store, overlays) } ?? 0
|
||||
if code == 125 || code == 126 {
|
||||
overlays.listCursor = tasks[max(0, min(i + (code == 125 ? 1 : -1), tasks.count - 1))].id
|
||||
return true
|
||||
}
|
||||
overlays.listKeys = false
|
||||
store.focusTask(tasks[i].id)
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var taskCursor: String?
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func followCursor() -> some View {
|
||||
modifier(FollowCursor())
|
||||
}
|
||||
}
|
||||
|
||||
private struct FollowCursor: ViewModifier {
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let cursor = cursorTask(store, overlays)
|
||||
ScrollViewReader { proxy in
|
||||
content
|
||||
.environment(\.taskCursor, cursor)
|
||||
.onChange(of: cursor, initial: true) {
|
||||
if let cursor {
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskList: View {
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@@ -280,6 +340,7 @@ struct TaskList: View {
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.followCursor()
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
@@ -328,6 +389,7 @@ struct TaskRows: View {
|
||||
|
||||
struct HoverRow<Content: View>: View {
|
||||
var selected = false
|
||||
var cursor = false
|
||||
var indent: CGFloat = 0
|
||||
let action: () -> Void
|
||||
@ViewBuilder let content: Content
|
||||
@@ -343,6 +405,7 @@ struct HoverRow<Content: View>: View {
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selected ? Palette.accentTint : hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(cursor ? Palette.accent : .clear))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -354,12 +417,13 @@ private struct TaskRow: View {
|
||||
let task: ByreTask
|
||||
let depth: Int
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(\.taskCursor) private var cursor
|
||||
|
||||
var body: some View {
|
||||
let ts = store.taskState(task)
|
||||
let color = taskPalette[task.color]
|
||||
let count = store.inbox.filter { $0.taskId == task.id }.count
|
||||
HoverRow(selected: task.id == store.selectedTaskId, indent: rowIndent(task, depth)) {
|
||||
HoverRow(selected: task.id == store.selectedTaskId, cursor: task.id == cursor, indent: rowIndent(task, depth)) {
|
||||
store.focusTask(task.id)
|
||||
} content: {
|
||||
SubtaskMark(task: task)
|
||||
@@ -405,10 +469,11 @@ private struct BackgroundRow: View {
|
||||
let task: ByreTask
|
||||
let depth: Int
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(\.taskCursor) private var cursor
|
||||
|
||||
var body: some View {
|
||||
let ts = store.taskState(task)
|
||||
HoverRow(selected: task.id == store.selectedTaskId, indent: rowIndent(task, depth)) {
|
||||
HoverRow(selected: task.id == store.selectedTaskId, cursor: task.id == cursor, indent: rowIndent(task, depth)) {
|
||||
store.focusTask(task.id)
|
||||
} content: {
|
||||
SubtaskMark(task: task)
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Check: Decodable {
|
||||
let need: String
|
||||
let `for`: String
|
||||
let ok: Bool
|
||||
let command: String
|
||||
let tool: String
|
||||
let fix: String
|
||||
let blocking: Bool
|
||||
}
|
||||
@@ -57,8 +57,8 @@ struct ChecksView: View {
|
||||
}
|
||||
Text("\(need(check)), for \(check.for)").foregroundStyle(Palette.muted)
|
||||
if !check.ok {
|
||||
if !check.command.isEmpty {
|
||||
CommandBox(command: check.command)
|
||||
if !check.tool.isEmpty {
|
||||
CommandBox(tool: check.tool)
|
||||
}
|
||||
if !check.fix.isEmpty {
|
||||
Text(check.fix)
|
||||
@@ -91,7 +91,7 @@ struct ChecksView: View {
|
||||
}
|
||||
|
||||
private struct CommandBox: View {
|
||||
let command: String
|
||||
let tool: String
|
||||
@ViewState private var copied = false
|
||||
@ViewState private var failure: String?
|
||||
|
||||
@@ -107,7 +107,7 @@ private struct CommandBox: View {
|
||||
|
||||
private var box: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(verbatim: command)
|
||||
Text(verbatim: "byre install-tool \(tool)")
|
||||
.font(Fonts.font(12, mono: true))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -125,6 +125,10 @@ private struct CommandBox: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var command: String {
|
||||
"'\(serverHelper.replacingOccurrences(of: "'", with: "'\\''"))' install-tool \(tool)"
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
copyToClipboard(command)
|
||||
copied = true
|
||||
@@ -343,10 +347,11 @@ enum Server {
|
||||
return try JSONDecoder().decode(TailnetInfo.self, from: data)
|
||||
}
|
||||
|
||||
// byre install copies its PATH into the service, which needs the login shell's to find claude, codex, agy and npx.
|
||||
// byre install copies its PATH into the service, which needs the interactive login shell's to find claude, codex, agy and npx:
|
||||
// installers such as claude's have the user add their directory to .zshrc, which only an interactive shell reads.
|
||||
private static func helper(_ command: String) async throws -> (status: Int32, output: String) {
|
||||
let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell)
|
||||
return try await execute(shell, ["-l", "-c", "exec \"$BYRE_HELPER\" \(command)"], env: ["BYRE_HELPER": serverHelper])
|
||||
return try await execute(shell, ["-l", "-i", "-c","exec \"$BYRE_HELPER\" \(command)"], env: ["BYRE_HELPER": serverHelper])
|
||||
}
|
||||
|
||||
static func checks() async throws -> Checks {
|
||||
|
||||
@@ -347,8 +347,8 @@ final class ConnectForm {
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(15))
|
||||
throw ProcessFailure(errorDescription: "byre's Tailscale node is signed in but \(origin.absoluteString) didn't answer within 15 s.")
|
||||
try await Task.sleep(for: .seconds(120))
|
||||
throw ProcessFailure(errorDescription: "byre's Tailscale node is signed in but \(origin.absoluteString) didn't answer within 2 minutes.")
|
||||
}
|
||||
defer {
|
||||
group.cancelAll()
|
||||
|
||||
@@ -3,10 +3,10 @@ import Observation
|
||||
import SwiftData
|
||||
|
||||
enum TaskTab: String {
|
||||
case transcript, board, changes, files, page, terminal, workflow
|
||||
case transcript, board, changes, files, page, captures, terminal, workflow
|
||||
}
|
||||
|
||||
let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .page, .terminal, .workflow]
|
||||
let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .page, .captures, .terminal, .workflow]
|
||||
|
||||
enum JumpTarget: Equatable {
|
||||
case question(String)
|
||||
@@ -215,6 +215,10 @@ final class Store {
|
||||
selectedTaskId.flatMap { taskFeeds[$0] }
|
||||
}
|
||||
|
||||
func captures(_ taskId: String) -> [Capture] {
|
||||
taskFeeds[taskId]?.captures ?? []
|
||||
}
|
||||
|
||||
func activeTasks() -> [(project: Project, task: ByreTask)] {
|
||||
projects.flatMap { project in
|
||||
(tasks[project.id] ?? []).filter { $0.status == .active }.map { (project, $0) }
|
||||
@@ -854,7 +858,7 @@ final class Store {
|
||||
}
|
||||
notifyTask(self, ev)
|
||||
reloadTask(ev)
|
||||
case "session.created", "session.state", "session.subagents", "session.model":
|
||||
case "session.created", "session.state", "session.subagents", "session.model", "session.paused":
|
||||
if ev.owner != nil {
|
||||
reloadSessions(nil)
|
||||
} else if let taskId = ev.taskId {
|
||||
|
||||
@@ -19,11 +19,13 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
|
||||
overlays.switcher?.close()
|
||||
store.sheet = nil
|
||||
overlays.taskList = true
|
||||
overlays.takeListKeys(store.selectedTaskId)
|
||||
case "p":
|
||||
overlays.switcher?.close()
|
||||
store.sheet = nil
|
||||
if let project = store.focusedProject() ?? store.projects.first {
|
||||
overlays.projectFocus = true
|
||||
overlays.projectTab = .tasks
|
||||
overlays.takeListKeys(nil)
|
||||
store.focusProject(project.id)
|
||||
}
|
||||
case "l":
|
||||
@@ -94,6 +96,10 @@ private let shortcuts: [(String, [(String, String)])] = [
|
||||
("name: message", "Start the task in the project whose name starts with name"),
|
||||
("workflow title", "Start the workflow named workflow, titled title"),
|
||||
]),
|
||||
("Task lists, after ⌘T or ⌘P", [
|
||||
("↑ ↓", "Move the highlight"),
|
||||
("Enter", "Open the highlighted task"),
|
||||
]),
|
||||
("Composer and answers", [
|
||||
("Enter", "Send"),
|
||||
("Shift+Enter", "New line"),
|
||||
@@ -285,7 +291,8 @@ final class SwitcherModel {
|
||||
go(task.id)
|
||||
case .project(let p):
|
||||
close()
|
||||
overlays.projectFocus = true
|
||||
overlays.projectTab = .tasks
|
||||
overlays.takeListKeys(nil)
|
||||
store.focusProject(p.id)
|
||||
case .newTask(let p):
|
||||
create(p.id, .init())
|
||||
|
||||
@@ -78,6 +78,7 @@ final class Row: Identifiable {
|
||||
var tag = false
|
||||
var result: String?
|
||||
var unfinished = false
|
||||
var held = false
|
||||
var started: Date?
|
||||
var status = "starting…"
|
||||
var statusState = SubagentState.running
|
||||
@@ -97,7 +98,7 @@ final class Row: Identifiable {
|
||||
var pending: Bool {
|
||||
switch kind {
|
||||
case .tool, .subagent:
|
||||
return result == nil && !unfinished
|
||||
return result == nil && !unfinished && !held
|
||||
case .group:
|
||||
return body!.rows.contains { $0.pending }
|
||||
default:
|
||||
@@ -145,6 +146,17 @@ final class Row: Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
static var newest: Int {
|
||||
rowIds
|
||||
}
|
||||
|
||||
var firstId: Int {
|
||||
if case .group = kind {
|
||||
return body!.rows.first?.id ?? id
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
init(_ kind: RowKind, taskId: String?, body: Bool = false) {
|
||||
rowIds += 1
|
||||
id = rowIds
|
||||
@@ -579,6 +591,18 @@ final class TranscriptBuilder {
|
||||
ctx.questions[item.id]?.question = item
|
||||
continue
|
||||
}
|
||||
if ev.kind == "agent.held" {
|
||||
ctx.tools[jsString(p["tool_use_id"])]?.held = true
|
||||
continue
|
||||
}
|
||||
if ev.kind == "session.paused" {
|
||||
if !bool(p, "paused") {
|
||||
for tool in ctx.tools.values {
|
||||
tool.held = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
let sub = ctx.subagents[parent]
|
||||
if ev.kind == "agent.tool_call" && isSubagentCall(p) {
|
||||
let row = renderSubagent(p, parent)
|
||||
|
||||
Reference in New Issue
Block a user