Add web UI and worker capture/approval command channel
Introduces the browser-facing surface and the worker-side protocol that backs it: - internal/ui: joined read model plus per-task lifecycle and approval controls, kept separate from the raw endpoints workers and harnesses depend on. - internal/webui + web/: Vite/React app, build output embedded via go:embed and served as an SPA fallback. - federation: per-(worker, task) captures with a monotonic revision that advances only when pane text actually changes, and a command queue restricted to grant_approval / deny_approval, each bound to the capture revision the operator acted on. - orchestra-worker: publishes captures and executes commands only after re-reading the pane and confirming the revision still matches. Sends keystrokes only for a visible y/n prompt or OpenCode's fully labelled selector, and refuses to deny through that selector rather than guess at unobservable navigation. This is the ownership boundary AUDIT.md's B14 and B17 call for: approval becomes an explicit, revision-bound operation executed by the worker that owns the pane, instead of a side effect of prompting over a coordinator-driven remote socket. Also ignores the web build inputs and outputs. node_modules ships vendored Go packages, so go build and go test walk into it if it is merely untracked; both node_modules and .node_modules are excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
This commit is contained in:
@@ -234,6 +234,78 @@ func (w *worker) releaseReady(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// publishCaptures makes remote panes observable without allowing the
|
||||
// coordinator to touch their unix herdr socket.
|
||||
func (w *worker) publishCaptures(ctx context.Context) {
|
||||
for taskID, session := range w.sessions {
|
||||
text, err := (herdr.CLIAdapter{Client: w.herdr, Harness: w.harness}).PaneCapture(ctx, session, "recent")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := w.api.PublishCapture(ctx, federation.Capture{TaskID: taskID, PaneID: session.PaneID, Text: text}); err != nil {
|
||||
log.Printf("publish capture %s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvalResponse(text, kind string) (string, bool) {
|
||||
low := strings.ToLower(text)
|
||||
// Never invent a keystroke. y/n prompts label both decisions directly.
|
||||
if strings.Contains(low, "[y/n]") || strings.Contains(low, "(y/n)") {
|
||||
if kind == "grant_approval" {
|
||||
return "y\n", true
|
||||
}
|
||||
return "n\n", true
|
||||
}
|
||||
// OpenCode's explicit selector states "Allow once Allow always Reject"
|
||||
// and "enter confirm". Enter is consequently a bounded one-time grant;
|
||||
// rejection would require unobservable selector navigation, so refuse it.
|
||||
if kind == "grant_approval" && strings.Contains(low, "allow once") && strings.Contains(low, "allow always") && strings.Contains(low, "reject") && strings.Contains(low, "enter confirm") {
|
||||
return "\n", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
func (w *worker) runCommands(ctx context.Context) {
|
||||
commands, err := w.api.Commands(ctx)
|
||||
if err != nil {
|
||||
log.Printf("poll controls: %v", err)
|
||||
return
|
||||
}
|
||||
for _, command := range commands {
|
||||
session, ok := w.sessions[command.TaskID]
|
||||
if !ok || session.PaneID != command.PaneID {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "stale", "session or pane changed")
|
||||
continue
|
||||
}
|
||||
text, err := (herdr.CLIAdapter{Client: w.herdr, Harness: w.harness}).PaneCapture(ctx, session, "recent")
|
||||
if err != nil {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "capture unavailable: "+err.Error())
|
||||
continue
|
||||
}
|
||||
capture, err := w.api.PublishCapture(ctx, federation.Capture{TaskID: command.TaskID, PaneID: session.PaneID, Text: text})
|
||||
if err != nil {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "cannot publish capture: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if capture.Revision != command.CaptureRevision {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "stale", "capture revision changed")
|
||||
continue
|
||||
}
|
||||
input, ok := approvalResponse(text, command.Kind)
|
||||
if !ok {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "prompt does not expose an executable approval control")
|
||||
continue
|
||||
}
|
||||
if err := w.herdr.Call(ctx, "pane.send_text", map[string]any{"pane_id": session.PaneID, "text": input}, nil); err != nil {
|
||||
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "herdr did not acknowledge input: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if err := w.api.ResolveCommand(ctx, command.ID, "acknowledged", ""); err != nil {
|
||||
log.Printf("ack command %s: %v", command.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *worker) once(ctx context.Context) error {
|
||||
es, _, err := w.api.Events(ctx, w.cursor)
|
||||
if err != nil {
|
||||
@@ -297,6 +369,13 @@ func (w *worker) once(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unit/replay-only workers intentionally have no herdr connection. A
|
||||
// production worker always does, and only then participates in the live
|
||||
// capture/control protocol.
|
||||
if w.herdr != nil {
|
||||
w.publishCaptures(ctx)
|
||||
w.runCommands(ctx)
|
||||
}
|
||||
w.releaseReady(ctx)
|
||||
if err := w.save(); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user