diff --git a/docs/reference/config.md b/docs/reference/config.md index 37240aa1..a85b3520 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -209,6 +209,7 @@ ui: | `work_args` | | its arguments before the prompt; `{key}` is replaced | | `work_create` | | the command that makes a missing worktree (`{branch} {base} {key}`); unset: herdr | | `agent_view` | `fullscreen` | `panel` attaches an agent in the panel. Terminal | +| `multiplexer` | herdr | another multiplexer speaking herdr's socket API and CLI: `command` (its CLI), `socket` (its API socket), `inside_env` (the variable it sets in its panes) | | `llm` | `claude -p` when on the PATH | the ask command: the question last, the issue on stdin (`llm`, `ollama run llama3`) | | `actions` | | your own commands on an issue, see [Actions](#actions) | diff --git a/internal/config/config.go b/internal/config/config.go index 5421df74..51c3d85e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -185,6 +185,18 @@ type RulesTest struct { Status string `yaml:"status"` } +// MultiplexerConfig points laneway at a terminal multiplexer that speaks +// herdr's socket API and CLI (` agent attach|focus `). +type MultiplexerConfig struct { + // Command is its CLI ("herdr"). + Command string `yaml:"command"` + // Socket is its API socket; empty finds herdr's. + Socket string `yaml:"socket"` + // InsideEnv is the environment variable it sets in its panes, telling + // laneway it runs inside it ("HERDR_ENV"). + InsideEnv string `yaml:"inside_env"` +} + // UIConfig tunes the app; every field is optional and "" / 0 keeps the // default (see ui.optionsFrom). type UIConfig struct { @@ -362,6 +374,9 @@ type UIConfig struct { // AgentView is where an attached agent shows: fullscreen (laneway steps // aside) or panel (in the issue panel). AgentView string `yaml:"agent_view"` + // Multiplexer is the herdr-compatible terminal multiplexer agents run in; + // unset, herdr. + Multiplexer MultiplexerConfig `yaml:"multiplexer"` // WorkArgs are arguments the agent gets before the start prompt, with // {key} replaced ([--append-system-prompt, "User is working on {key}"]). WorkArgs []string `yaml:"work_args"` diff --git a/internal/config/schema.go b/internal/config/schema.go index 373f3019..c50c1a55 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -5,14 +5,14 @@ import "github.com/cornedor/laneway/internal/i18n" // What the settings screens (terminal and web) say about each ui: option. // SettingsRestart are options read once at startup. -var SettingsRestart = map[string]bool{"images": true, "threaded_replies": true, "image_max_rows": true, "card_limit": true, "default_mode": true, "flag_value": true, "inbox_issues": true, "custom_fields": true, "language": true} +var SettingsRestart = map[string]bool{"images": true, "threaded_replies": true, "image_max_rows": true, "card_limit": true, "default_mode": true, "flag_value": true, "inbox_issues": true, "custom_fields": true, "language": true, "multiplexer": true} // SettingsTerminal are options only the terminal reads; the browser has // its own for some (theme, under Appearance). var SettingsTerminal = map[string]bool{"theme": true, "icons": true, "mouse": true, "double_click": true, "images": true, "image_max_rows": true, "code_theme": true, "full_refresh": true} // SettingsCommand are options laneway runs as commands (or passes to one). -var SettingsCommand = map[string]bool{"actions": true, "llm": true, "activity": true, "open": true, "clipboard_image": true, "work_agent": true, "work_args": true, "work_create": true} +var SettingsCommand = map[string]bool{"actions": true, "llm": true, "activity": true, "open": true, "clipboard_image": true, "work_agent": true, "work_args": true, "work_create": true, "multiplexer": true} // SettingsBasic are the options for making laneway your own: looks, layouts, // views. The rest are specifics and tuning, which the browser's settings @@ -95,6 +95,7 @@ var SettingDefaults = map[string]string{ "flag_value": "Impediment", "work_agent": "claude", "agent_view": "fullscreen", + "multiplexer": "herdr", "work_args": "none", "work_create": "herdr creates the worktree", "code_theme": "the preset's, else monokai", @@ -117,7 +118,7 @@ var SettingGroups = []SettingGroup{ {i18n.N("Panel"), []string{"panel_width", "empty_fields", "date_format", "images", "image_max_rows", "templates", "code_theme", "threaded_replies", "comment_order", "comment_layout"}}, {i18n.N("Refresh"), []string{"auto_refresh", "stale_after", "full_refresh"}}, {i18n.N("Time and worklogs"), []string{"timer_round", "timer_on_start", "workday_start", "workdays", "capacity", "activity", "calendar", "meeting_key"}}, - {i18n.N("Start work and agents"), []string{"start_assigns", "start_status", "branch_template", "work_branch_template", "work_agent", "work_args", "work_create", "agent_view", "llm", "actions"}}, + {i18n.N("Start work and agents"), []string{"start_assigns", "start_status", "branch_template", "work_branch_template", "work_agent", "work_args", "work_create", "agent_view", "multiplexer", "llm", "actions"}}, {i18n.N("Planning, roadmap and charts"), []string{"velocity_sprints", "report_done", "report_backwards", "roadmap_epic_type", "roadmap_done_days"}}, {i18n.N("Inbox"), []string{"inbox_every", "inbox_lookback", "inbox_issues"}}, {i18n.N("Standup"), []string{"standup_start", "standup_lookback", "standup_length", "standup_timebox", "standup_shuffle", "standup_timer"}}, @@ -201,6 +202,7 @@ var SettingDocs = map[string]string{ "flag_value": i18n.N("the Flagged field's option flagging sets"), "work_agent": i18n.N("the herdr agent kind S launches"), "agent_view": i18n.N("where an attached agent shows: fullscreen or in the panel"), + "multiplexer": i18n.N("the herdr-compatible multiplexer agents run in: its command, socket and inside_env"), "work_args": i18n.N("the agent's arguments before the start prompt, {key} replaced"), "work_create": i18n.N("a command that makes a missing worktree: {branch}, {base}, {key}"), "code_theme": i18n.N("the chroma style code blocks use (terminal only: the browser's follow its theme)"), diff --git a/internal/herdr/herdr.go b/internal/herdr/herdr.go index 67feaa6c..3daca0be 100644 --- a/internal/herdr/herdr.go +++ b/internal/herdr/herdr.go @@ -94,6 +94,14 @@ func Default() *Client { return &Client{path: path} } +// At addresses the socket at path, nil when nothing listens there. +func At(path string) *Client { + if fi, err := os.Stat(path); err != nil || fi.Mode()&os.ModeSocket == 0 { + return nil + } + return &Client{path: path} +} + // New addresses the socket at path. func New(path string) *Client { return &Client{path: path} } diff --git a/internal/herdr/herdr_test.go b/internal/herdr/herdr_test.go index 48c925ab..c705ac78 100644 --- a/internal/herdr/herdr_test.go +++ b/internal/herdr/herdr_test.go @@ -133,3 +133,18 @@ func TestWorktree(t *testing.T) { t.Errorf("worktree = %+v", wt) } } + +func TestAtNeedsASocket(t *testing.T) { + if At(filepath.Join(t.TempDir(), "missing.sock")) != nil { + t.Fatal("At must be nil without a socket") + } + path := filepath.Join(t.TempDir(), "s.sock") + l, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + defer l.Close() + if c := At(path); c == nil || c.Path() != path { + t.Fatalf("At(%s) = %v", path, c) + } +} diff --git a/internal/i18n/nl/settings.json b/internal/i18n/nl/settings.json index a5608dd9..332b4c19 100644 --- a/internal/i18n/nl/settings.json +++ b/internal/i18n/nl/settings.json @@ -81,6 +81,7 @@ "where U opens: everyone, or the first person": "waar U opent: `everyone` of de eerste persoon", "where a lane card's fields sit: top, top_right, bottom, bottom_right around the summary": "waar de velden van een kaart in een baan staan: top, top_right, bottom, bottom_right rond de samenvatting", "where an attached agent shows: fullscreen or in the panel": "waar een gekoppelde agent verschijnt: `fullscreen` of in het paneel", + "the herdr-compatible multiplexer agents run in: its command, socket and inside_env": "de herdr-compatibele multiplexer waarin agents draaien: zijn `command`, `socket` en `inside_env`", "where attachments are saved": "waar bijlagen worden opgeslagen", "your own commands on the issue: in the palette, on a key if given": "je eigen commando's op het issue: in het palet, op een toets als die is opgegeven", "your own lanes over a board's columns: stacked, split by status, reordered, renamed, hidden; alt+l switches": "je eigen banen over de kolommen van een bord: gestapeld, per status gesplitst, herordend, hernoemd, verborgen; alt+l wisselt", diff --git a/internal/ui/agents.go b/internal/ui/agents.go index 15f96417..cc35073d 100644 --- a/internal/ui/agents.go +++ b/internal/ui/agents.go @@ -1,6 +1,7 @@ package ui import ( + "cmp" "context" "errors" "github.com/cornedor/laneway/internal/i18n" @@ -154,6 +155,23 @@ func (m *Model) agentMark(key string) string { // herdrBin is the herdr CLI; tests swap it. var herdrBin = "herdr" +// multiplexer is the API client for ui.multiplexer's socket, else herdr's; +// nil when nothing runs. +func multiplexer(o options) *herdr.Client { + if o.muxSocket != "" { + return herdr.At(expandUserPath(o.muxSocket)) + } + return herdr.Default() +} + +// muxBin is the multiplexer CLI: ui.multiplexer.command, else herdr. +func (m *Model) muxBin() string { return cmp.Or(m.opts.muxCommand, herdrBin) } + +// insideMux is whether laneway runs in one of the multiplexer's panes. +func (m *Model) insideMux() bool { + return os.Getenv(cmp.Or(m.opts.muxInsideEnv, "HERDR_ENV")) != "" +} + // statusMark is one agent's state as a mark. func statusMark(s herdr.Status) string { switch s { @@ -184,16 +202,16 @@ func (m *Model) attachAgentIn(key, pane string, panel bool) tea.Cmd { m.status = i18n.T("attach needs herdr running") return nil } - bin, err := exec.LookPath(herdrBin) + bin, err := exec.LookPath(m.muxBin()) if err != nil { m.fail(i18n.T("attach: no herdr on PATH")) return nil } done := func(err error) tea.Msg { return agentAttachedMsg{key: key, err: err} } - if os.Getenv("HERDR_ENV") != "1" && panel && m.opts.agentView == "panel" { + if !m.insideMux() && panel && m.opts.agentView == "panel" { return m.openAgentPanel(key, pane, bin) } - if os.Getenv("HERDR_ENV") == "1" { + if m.insideMux() { cmd := herdrCommand(bin, m.herdr.Path(), "agent", "focus", pane) m.status = i18n.Tf("%s: focusing its agent", key) return func() tea.Msg { return done(cmd.Run()) } diff --git a/internal/ui/agents_screen.go b/internal/ui/agents_screen.go index 42c87ebe..acb20483 100644 --- a/internal/ui/agents_screen.go +++ b/internal/ui/agents_screen.go @@ -245,7 +245,7 @@ func (m Model) handleAgentTermDue(msg agentTermDueMsg) (tea.Model, tea.Cmd) { } m.closeAgentPanel() // the one terminal, in the panel or here s.termFor, s.termErr, s.typing = r.agent.PaneID, "", false - bin, err := exec.LookPath(herdrBin) + bin, err := exec.LookPath(m.muxBin()) if err != nil { s.termErr = i18n.T("no herdr on PATH") return m, nil diff --git a/internal/ui/agents_test.go b/internal/ui/agents_test.go index 3103d3df..a5f6e585 100644 --- a/internal/ui/agents_test.go +++ b/internal/ui/agents_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net" + "os" "path/filepath" "strings" "testing" @@ -12,6 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + "github.com/cornedor/laneway/internal/config" "github.com/cornedor/laneway/internal/herdr" "github.com/cornedor/laneway/internal/jira" ) @@ -347,3 +349,35 @@ func TestAgentMarkClick(t *testing.T) { }) } } + +// TestAttachUsesConfiguredMultiplexer: with ui.multiplexer set, attaching from +// inside it runs its CLI's agent focus, not herdr's, and its inside_env counts. +func TestAttachUsesConfiguredMultiplexer(t *testing.T) { + t.Setenv("HERDR_ENV", "") + t.Setenv("OTHER_MUX_PANE", "p9") + dir := t.TempDir() + log := filepath.Join(dir, "calls") + bin := filepath.Join(dir, "othermux") + script := "#!/bin/sh\necho \"$@\" >> " + log + "\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + opts, warn := optionsFrom(config.UIConfig{Multiplexer: config.MultiplexerConfig{Command: bin, InsideEnv: "OTHER_MUX_PANE"}}) + if len(warn) > 0 { + t.Fatalf("warnings: %v", warn) + } + m := jiraTabModel(t) + m.opts.muxCommand, m.opts.muxInsideEnv = opts.muxCommand, opts.muxInsideEnv + m.herdr = herdr.New("/nowhere.sock") + cmd := m.attachAgent("ABC-1", "p1") + if cmd == nil { + t.Fatalf("attach gave no command: %q", m.status) + } + if msg, ok := cmd().(agentAttachedMsg); !ok || msg.err != nil { + t.Fatalf("attach: %#v", msg) + } + got, err := os.ReadFile(log) + if err != nil || strings.TrimSpace(string(got)) != "agent focus p1" { + t.Fatalf("multiplexer CLI got %q (%v), want agent focus p1", got, err) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 50449008..82d56538 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -609,7 +609,7 @@ func New(ctx context.Context, cfg config.JiraConfig, ui config.UIConfig, rs []ru rules: ruleSet, actions: actions, rulesLog: rulesLog, - herdr: herdr.Default(), + herdr: multiplexer(opts), refView: viewport.New(), fieldCursor: -1, started: time.Now(), diff --git a/internal/ui/options.go b/internal/ui/options.go index 8a5d5202..a1162e1c 100644 --- a/internal/ui/options.go +++ b/internal/ui/options.go @@ -55,6 +55,9 @@ type options struct { workBranch string // start work's new branch workAgent string // the herdr agent kind start work launches agentView string // where an attached agent shows: fullscreen or panel + muxCommand string // multiplexer CLI; "" is herdrBin + muxSocket string // multiplexer API socket; "" finds herdr's + muxInsideEnv string // env var the multiplexer sets in its panes; "" is HERDR_ENV workArgs []string // its arguments before the start prompt workCreate []string // the command that makes a missing worktree, nil: herdr's kanbanDoneDays int // done work older than this leaves kanban boards @@ -173,6 +176,9 @@ func optionsFrom(c config.UIConfig) (options, []string) { default: warn = append(warn, i18n.Tf("ui.agent_view: %q is not fullscreen or panel", c.AgentView)) } + o.muxCommand = strings.TrimSpace(c.Multiplexer.Command) + o.muxSocket = strings.TrimSpace(c.Multiplexer.Socket) + o.muxInsideEnv = strings.TrimSpace(c.Multiplexer.InsideEnv) switch v := strings.ToLower(strings.TrimSpace(c.CardColors)); v { case "": case "ribbon", "off": diff --git a/internal/web/api_agents.go b/internal/web/api_agents.go index 3a5390af..57a9d76a 100644 --- a/internal/web/api_agents.go +++ b/internal/web/api_agents.go @@ -1,6 +1,7 @@ package web import ( + "cmp" "context" "encoding/json" "fmt" @@ -30,6 +31,20 @@ var herdrClient = herdr.Default // herdrBin is the herdr CLI, for reading a pane and focusing it. var herdrBin = "herdr" +// muxClient is ui.multiplexer's API socket, else the running herdr server; +// nil for none. +func muxClient(s *Server) *herdr.Client { + if p := strings.TrimSpace(s.UIConfig().Multiplexer.Socket); p != "" { + return herdr.At(expandUserPath(p)) + } + return herdrClient() +} + +// muxBin is ui.multiplexer's CLI, else herdr. +func muxBin(s *Server) string { + return cmp.Or(strings.TrimSpace(s.UIConfig().Multiplexer.Command), herdrBin) +} + // AgentOut is one agent as the browser gets it. type AgentOut struct { PaneID, Name, WorkspaceID, TabID, Agent string @@ -119,9 +134,9 @@ func agentIssues(ctx context.Context, s *Server, r *http.Request) (any, error) { } func agentsStatus(ctx context.Context, s *Server, r *http.Request) (any, error) { - _, cliErr := exec.LookPath(herdrBin) + _, cliErr := exec.LookPath(muxBin(s)) return map[string]any{ - "Available": herdrClient() != nil, + "Available": muxClient(s) != nil, "CLI": cliErr == nil, "GH": cli.Have("gh"), "GLab": cli.Have("glab"), @@ -176,7 +191,7 @@ func unavailable() AgentsSnapshot { } func snapshot(ctx context.Context, s *Server) AgentsSnapshot { - c := herdrClient() + c := muxClient(s) if c == nil { return unavailable() } @@ -221,7 +236,7 @@ func agentEvents(s *Server, w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "retry: 3000\n\n") fl.Flush() for ctx.Err() == nil { - c := herdrClient() + c := muxClient(s) if c == nil { send(unavailable()) if !sleep(10 * time.Second) { @@ -307,8 +322,8 @@ func paneIDs(as []herdr.Agent) []string { return ids } -func needHerdr() (*herdr.Client, error) { - c := herdrClient() +func needHerdr(s *Server) (*herdr.Client, error) { + c := muxClient(s) if c == nil { return nil, httpError{http.StatusServiceUnavailable, i18n.T("herdr is not running")} } @@ -341,7 +356,7 @@ func paneAgent(ctx context.Context, c *herdr.Client, r *http.Request) (herdr.Age // agentOutput is the pane's recent terminal text, read through the herdr CLI. func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) { - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } @@ -353,7 +368,7 @@ func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) { if n, err := strconv.Atoi(Q(r, "lines")); err == nil && n > 0 && n <= 1000 { lines = n } - bin, err := exec.LookPath(herdrBin) + bin, err := exec.LookPath(muxBin(s)) if err != nil { return nil, httpError{http.StatusNotImplemented, i18n.T("no herdr on PATH to read the terminal")} } @@ -369,7 +384,7 @@ func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) { } func agentPrompt(ctx context.Context, s *Server, r *http.Request) (any, error) { - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } @@ -394,7 +409,7 @@ func agentPrompt(ctx context.Context, s *Server, r *http.Request) (any, error) { // agentFocus brings the pane to the front in herdr. func agentFocus(ctx context.Context, s *Server, r *http.Request) (any, error) { - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } @@ -402,7 +417,7 @@ func agentFocus(ctx context.Context, s *Server, r *http.Request) (any, error) { if err != nil { return nil, err } - bin, err := exec.LookPath(herdrBin) + bin, err := exec.LookPath(muxBin(s)) if err != nil { return nil, httpError{http.StatusNotImplemented, i18n.T("no herdr on PATH")} } @@ -416,7 +431,7 @@ func agentFocus(ctx context.Context, s *Server, r *http.Request) (any, error) { // agentStop closes the agent's tab. func agentStop(ctx context.Context, s *Server, r *http.Request) (any, error) { - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } @@ -429,7 +444,7 @@ func agentStop(ctx context.Context, s *Server, r *http.Request) (any, error) { // agentNew starts another agent in the same directory as the one in pane. func agentNew(ctx context.Context, s *Server, r *http.Request) (any, error) { - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } diff --git a/internal/web/api_agents_test.go b/internal/web/api_agents_test.go index b4250be8..7ee35782 100644 --- a/internal/web/api_agents_test.go +++ b/internal/web/api_agents_test.go @@ -165,6 +165,11 @@ func gitRepo(t *testing.T) string { } func agentsServer(t *testing.T, repo string) *httptest.Server { + t.Helper() + return agentsServerUI(t, repo, config.UIConfig{StartAssigns: "on", StartStatus: "In Progress", TimerOnStart: "on"}) +} + +func agentsServerUI(t *testing.T, repo string, ui config.UIConfig) *httptest.Server { t.Helper() base, stop, err := demo.New(time.Now()).Start() if err != nil { @@ -176,7 +181,7 @@ func agentsServer(t *testing.T, repo string) *httptest.Server { opt := Options{ Client: cl, Store: st, Site: "demo", Jira: config.JiraConfig{Repos: map[string]string{"DEMO": repo}}, - UI: config.UIConfig{StartAssigns: "on", StartStatus: "In Progress", TimerOnStart: "on"}, + UI: ui, } ts := httptest.NewServer(New(context.Background(), opt)) t.Cleanup(ts.Close) @@ -213,6 +218,30 @@ func TestAgentsUnavailable(t *testing.T) { } } +// TestAgentsConfiguredMultiplexer: ui.multiplexer's socket and CLI stand in +// for herdr's when herdr itself is not running. +func TestAgentsConfiguredMultiplexer(t *testing.T) { + _, c := newFakeHerdr(t) + old := herdrClient + herdrClient = func() *herdr.Client { return nil } + defer func() { herdrClient = old }() + bin := filepath.Join(t.TempDir(), "othermux") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + ts := agentsServerUI(t, t.TempDir(), config.UIConfig{Multiplexer: config.MultiplexerConfig{Command: bin, Socket: c.Path()}}) + var st map[string]any + workCall(t, "GET", ts.URL+"/api/agents/status", "", &st) + if st["Available"] != true || st["CLI"] != true { + t.Errorf("status = %v", st) + } + var snap AgentsSnapshot + workCall(t, "GET", ts.URL+"/api/agents", "", &snap) + if !snap.Available || len(snap.Agents) != 1 { + t.Errorf("snapshot = %+v", snap) + } +} + func TestAgentsListPromptStart(t *testing.T) { f, c := newFakeHerdr(t) old := herdrClient diff --git a/internal/web/api_agents_work.go b/internal/web/api_agents_work.go index a1a7be66..c7662dd9 100644 --- a/internal/web/api_agents_work.go +++ b/internal/web/api_agents_work.go @@ -137,7 +137,7 @@ func removeWorktree(ctx context.Context, s *Server, r *http.Request) (any, error project, _, _ := strings.Cut(key, "-") return nil, badRequest(noRepo(project)) } - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } @@ -326,7 +326,7 @@ func workForm(ctx context.Context, s *Server, r *http.Request) (any, error) { } cfg := workConfigOf(s) project, _, _ := strings.Cut(key, "-") - c := herdrClient() + c := muxClient(s) f := WorkForm{Key: key, Summary: iss.Summary, Type: iss.Type, Status: iss.Status, Herdr: c != nil, Repo: repoFor(s, key), Agent: cfg.Agent, Agents: []string{cfg.Agent}, Missing: []string{}, Existing: []string{}, Template: cfg.Branch, Actions: startActions(s, key, cfg), Problems: []string{}} @@ -416,7 +416,7 @@ func planStart(ctx context.Context, s *Server, r *http.Request) (*startPlan, err if err != nil { return nil, err } - c, err := needHerdr() + c, err := needHerdr(s) if err != nil { return nil, err } diff --git a/internal/web/api_gitlab_diff.go b/internal/web/api_gitlab_diff.go index 42d3329a..75bfb20f 100644 --- a/internal/web/api_gitlab_diff.go +++ b/internal/web/api_gitlab_diff.go @@ -253,7 +253,7 @@ func init() { if err != nil { return nil, err } - h := herdrClient() + h := muxClient(s) if h == nil { return nil, httpError{http.StatusServiceUnavailable, i18n.T("herdr is not running")} } diff --git a/internal/web/api_terminal.go b/internal/web/api_terminal.go index 58729080..0e6652b7 100644 --- a/internal/web/api_terminal.go +++ b/internal/web/api_terminal.go @@ -89,7 +89,7 @@ func agentTerminal(s *Server, w http.ResponseWriter, r *http.Request) { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - c, err := needHerdr() + c, err := needHerdr(s) var a herdr.Agent if err == nil { a, err = paneAgent(ctx, c, r) @@ -99,7 +99,7 @@ func agentTerminal(s *Server, w http.ResponseWriter, r *http.Request) { writeErr(w, err) return } - bin, err := exec.LookPath(herdrBin) + bin, err := exec.LookPath(muxBin(s)) if err != nil { writeErr(w, httpError{http.StatusNotImplemented, i18n.T("no herdr on PATH to attach to the agent")}) return diff --git a/internal/web/parity_test.go b/internal/web/parity_test.go index 834d9ec3..f1fd8491 100644 --- a/internal/web/parity_test.go +++ b/internal/web/parity_test.go @@ -44,7 +44,7 @@ var sharedOptions = []string{ "workday_start", "capacity", "saved_filters", "remember_filters", "branch_template", "work_branch_template", "kanban_done_days", "roadmap_epic_type", "my_work_jql", "roadmap_done_days", "workdays", "inbox_every", "inbox_lookback", "inbox_issues", "timer_round", "card_colors", - "empty_fields", "empty_lanes", "drop_ranks", "lane_layouts", "custom_fields", "filters", "flag_value", "work_agent", "work_args", + "empty_fields", "empty_lanes", "drop_ranks", "lane_layouts", "custom_fields", "filters", "flag_value", "work_agent", "work_args", "multiplexer", "work_create", "theme", "actions", "delight", "threaded_replies", "comment_order", "comment_layout", "skin_tone", "language", "update_check", "llm", "activity", "home", "calendar", "meeting_key", "standup_start", "standup_lookback", "standup_length", "standup_timebox", "standup_shuffle", "standup_timer",