diff --git a/README.md b/README.md index 0c715d7a0..71ef763f9 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ Supports `zsh`, `bash`, `fish`, and `powershell`. Completion-only — run-kit ha | `run-kit url` | Print the run-kit server URL (config-derived from `RK_HOST`/`RK_PORT`, default `http://127.0.0.1:3000`) — a heuristic for AI agents, not a liveness probe. | | `run-kit skill` | Print the agent skill bundle — a static usage briefing for agents operating run-kit (canonical source `docs/site/skill.md`); `run-kit skill display` prints the visual-display topic page. | | `run-kit notify` | Send a Web Push notification to your subscribed devices (see [Push notifications](#push-notifications)). Fail-silent. | +| `run-kit present` | Show a file, directory, `:port`, localhost URL, or external URL to the user as a web tile attached to the current window (`--window` spawns a standalone iframe window, `--notify` pushes). Prints the resolved URL. | | `run-kit doctor` | Check runtime dependencies. Run this first when something breaks. | | `run-kit agent-setup` | Install agent-harness hooks (v1: Claude Code) so panes report busy/waiting/idle state (see [Agent state](#agent-state--run-kit-agent-setup)), plus the tmux guard shim that blocks `tmux kill-server` without an explicit `-L`/`-S` socket. Once per machine; `--uninstall` reverses both. | | `run-kit init-conf` | Scaffold default `tmux.conf` and `tmux.d/` drop-in directory to `~/.rk/`. Optional. | diff --git a/app/backend/api/present.go b/app/backend/api/present.go new file mode 100644 index 000000000..9ececce7a --- /dev/null +++ b/app/backend/api/present.go @@ -0,0 +1,138 @@ +package api + +// The /present/{windowId}/ content route (260813-becu-rk-present-attach-verb). +// It serves files for `rk present` file/dir targets with NO registration +// state: the serve root is read from the window's @rk_present_root tmux option +// AT REQUEST TIME (Constitution II/X — derive from tmux; the root lives in +// tmux and dies with the window). A dead window or unset option is a 404. +// +// Security (Constitution I, critical): serve only when the option is present +// and absolute, and verify the symlink-RESOLVED requested file stays contained +// under the symlink-RESOLVED root (filepath.Rel on the two EvalSymlinks +// results) — a containment check, never a lexical prefix/`..` ban (the +// code-server tarball lesson: intra-tree symlinks are legitimate; escaping +// ones are not). The tmux socket is already the trust boundary — anyone who +// can set window options can run code in panes — but path traversal through +// the web server must be impossible. + +import ( + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + + "rk/internal/tmux" +) + +// presentWindowIDPattern gates the {windowId} path param before any tmux +// subprocess runs (tmux window ids are @N). +var presentWindowIDPattern = regexp.MustCompile(`^@[0-9]+$`) + +// presentRootOption is the window user option carrying the absolute serve root +// for /present/ requests, set by `rk present` for file/dir targets. +const presentRootOption = "@rk_present_root" + +// getWindowOptionFn is the handler's tmux read seam, so the containment table +// is testable without a live server. +var getWindowOptionFn = tmux.GetWindowOption + +// handlePresent serves GET /present/{windowId}/* from the window's +// request-time @rk_present_root. +func (s *Server) handlePresent(w http.ResponseWriter, r *http.Request) { + windowID := chi.URLParam(r, "windowId") + if !presentWindowIDPattern.MatchString(windowID) { + writeError(w, http.StatusBadRequest, "invalid windowId") + return + } + + prefix := "/present/" + windowID + // Redirect /present/{windowId} → /present/{windowId}/ (308, query + // preserved) — the same relative-base rule as /proxy/{port}: apps resolve + // "./x" against the trailing-slash form. + if r.URL.Path == prefix { + target := r.URL.Path + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + + server := serverFromRequest(r) + root, err := getWindowOptionFn(r.Context(), windowID, server, presentRootOption) + if err != nil || root == "" || !filepath.IsAbs(root) { + http.NotFound(w, r) + return + } + + rel := strings.TrimPrefix(r.URL.Path, prefix+"/") + file, err := resolvePresentFile(root, rel) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + http.NotFound(w, r) + return + } + // ServeContent derives MIME from the name's extension (stdlib serving). + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + +// resolvePresentFile resolves rel under root with containment: both sides are +// symlink-evaluated and the result must stay under the resolved root. A path +// resolving to a directory serves that directory's index.html (never a +// listing). Every miss, escape, or error yields an error — the handler maps +// them all to 404 without touching files outside the root. +func resolvePresentFile(root, rel string) (*os.File, error) { + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + return resolveContained(resolvedRoot, rel, 0) +} + +// resolveContained joins rel onto resolvedRoot (already symlink-evaluated), +// evaluates symlinks on the result, and verifies containment. depth bounds the +// single index.html recursion. +func resolveContained(resolvedRoot, rel string, depth int) (*os.File, error) { + candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return nil, err + } + if !containedIn(resolvedRoot, resolved) { + return nil, os.ErrNotExist + } + info, err := os.Stat(resolved) + if err != nil { + return nil, err + } + if info.IsDir() { + if depth > 0 { + return nil, os.ErrNotExist // index.html resolving to a dir: no listing + } + return resolveContained(resolved, "index.html", depth+1) + } + if !info.Mode().IsRegular() { + return nil, os.ErrNotExist + } + return os.Open(resolved) +} + +// containedIn reports whether resolved stays under resolvedRoot, comparing +// symlink-evaluated absolute paths via filepath.Rel — containment semantics, +// not a lexical prefix check. +func containedIn(resolvedRoot, resolved string) bool { + rel, err := filepath.Rel(resolvedRoot, resolved) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/app/backend/api/present_test.go b/app/backend/api/present_test.go new file mode 100644 index 000000000..2df900c15 --- /dev/null +++ b/app/backend/api/present_test.go @@ -0,0 +1,211 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// presentFixture builds a serve-root tree exercising the containment matrix: +// +// root/ +// index.html (dir default) +// mock.html +// style.css +// real.html +// link.html → ./real.html (legitimate intra-tree symlink) +// evil → / (escaping symlink — must never be served) +// sub/index.html +// noidx/ (no index.html → 404, never a listing) +// /secret.txt (escape target — must remain unread) +func presentFixture(t *testing.T) (root, outside string) { + t.Helper() + base := t.TempDir() + root = filepath.Join(base, "root") + outside = filepath.Join(base, "outside") + for _, dir := range []string{root, outside, filepath.Join(root, "sub"), filepath.Join(root, "noidx")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "index.html"): "root index", + filepath.Join(root, "mock.html"): "mock", + filepath.Join(root, "style.css"): "body{}", + filepath.Join(root, "real.html"): "real", + filepath.Join(root, "sub", "index.html"): "sub index", + filepath.Join(outside, "secret.txt"): "TOP-SECRET-OUTSIDE-ROOT", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("./real.html", filepath.Join(root, "link.html")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil { + t.Fatal(err) + } + return root, outside +} + +// stubWindowOption installs the handler's tmux read seam, returning root for +// every read, and reports the (server, option) pairs it observed plus a call +// count (the invalid-windowId case must never reach tmux). +func stubWindowOption(t *testing.T, root string) (calls *int, servers *[]string) { + t.Helper() + n := 0 + seen := []string{} + getWindowOptionFn = func(_ context.Context, _ /* windowID */, server, option string) (string, error) { + n++ + seen = append(seen, server) + if option != presentRootOption { + t.Errorf("handler read option %q, want %q", option, presentRootOption) + } + return root, nil + } + // The seam is package-global, so restore the production default. This must + // be the ONLY cleanup touching the seam — t.Cleanup runs LIFO, so a second + // (e.g. nil-ing) cleanup registered before this one would run after it and + // leave the package in a broken state for later tests. + t.Cleanup(func() { getWindowOptionFn = defaultGetWindowOption }) + return &n, &seen +} + +// defaultGetWindowOption mirrors the handler's default seam value so tests can +// restore it (the tmux import is already named in present.go; re-pointing here +// keeps the test file's restore honest even if the default changes). +var defaultGetWindowOption = getWindowOptionFn + +func getPresent(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestPresentServes(t *testing.T) { + root, _ := presentFixture(t) + stubWindowOption(t, root) + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + tests := []struct { + name string + path string + wantStatus int + wantBody string + wantMIME string + }{ + {"plain html file", "/present/@7/mock.html?server=dev", 200, "mock", "text/html"}, + {"css file", "/present/@7/style.css", 200, "body{}", "text/css"}, + {"root dir serves index.html", "/present/@7/", 200, "root index", "text/html"}, + {"subdir serves its index.html", "/present/@7/sub/", 200, "sub index", "text/html"}, + {"dir without index is 404 not a listing", "/present/@7/noidx/", 404, "", ""}, + {"missing file is 404", "/present/@7/nope.html", 404, "", ""}, + {"intra-tree symlink serves", "/present/@7/link.html", 200, "real", "text/html"}, + {"dotdot traversal is 404", "/present/@7/../outside/secret.txt", 404, "", ""}, + {"deep dotdot traversal is 404", "/present/@7/sub/../../outside/secret.txt", 404, "", ""}, + {"encoded dotdot traversal is 404", "/present/@7/%2e%2e/outside/secret.txt", 404, "", ""}, + {"escaping symlink is 404", "/present/@7/evil/secret.txt", 404, "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := getPresent(t, router, tc.path) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d (body: %q)", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantBody != "" { + if body := strings.TrimSpace(rec.Body.String()); body != tc.wantBody { + t.Errorf("body = %q, want %q", body, tc.wantBody) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.wantMIME) { + t.Errorf("Content-Type = %q, want prefix %q", ct, tc.wantMIME) + } + } + // The escape target's content must never appear, whatever the case. + if strings.Contains(rec.Body.String(), "TOP-SECRET") { + t.Error("response leaked a file outside the serve root") + } + }) + } +} + +func TestPresentBareWindowRedirects(t *testing.T) { + root, _ := presentFixture(t) + stubWindowOption(t, root) + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + rec := getPresent(t, router, "/present/@7?server=dev") + if rec.Code != http.StatusPermanentRedirect { + t.Fatalf("status = %d, want 308", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/present/@7/?server=dev" { + t.Errorf("Location = %q, want /present/@7/?server=dev (query preserved)", loc) + } +} + +func TestPresentRootGate(t *testing.T) { + root, _ := presentFixture(t) + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + tests := []struct { + name string + root string // "" simulates an unset option (dead window reads empty) + }{ + {"unset option is 404", ""}, + {"relative root is 404", "relative/dir"}, + {"nonexistent root is 404", filepath.Join(root, "ghost")}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stubWindowOption(t, tc.root) + rec := getPresent(t, router, "/present/@7/mock.html") + if rec.Code != 404 { + t.Errorf("status = %d, want 404", rec.Code) + } + }) + } +} + +func TestPresentInvalidWindowIDNeverTouchesTmux(t *testing.T) { + root, _ := presentFixture(t) + calls, _ := stubWindowOption(t, root) + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + for _, path := range []string{"/present/7/mock.html", "/present/@x/mock.html", "/present/@/mock.html"} { + rec := getPresent(t, router, path) + if rec.Code == 200 { + t.Errorf("GET %s = 200, want rejection", path) + } + } + if *calls != 0 { + t.Errorf("tmux read seam called %d times for invalid windowIds, want 0 (gate before subprocess)", *calls) + } +} + +func TestPresentServerParam(t *testing.T) { + root, _ := presentFixture(t) + _, servers := stubWindowOption(t, root) + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + getPresent(t, router, "/present/@7/mock.html?server=dev") + getPresent(t, router, "/present/@7/mock.html?server=bad%20name") + getPresent(t, router, "/present/@7/mock.html") + + got := *servers + want := []string{"dev", "default", "default"} // invalid/absent → default + if len(got) != len(want) { + t.Fatalf("servers seen = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("call %d server = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/app/backend/api/router.go b/app/backend/api/router.go index 95a2db02d..59255681f 100644 --- a/app/backend/api/router.go +++ b/app/backend/api/router.go @@ -717,6 +717,11 @@ func (s *Server) buildRouter() chi.Router { r.HandleFunc("/proxy/{port}/*", s.handleProxy) r.HandleFunc("/proxy/{port}", s.handleProxy) + // Content route for `rk present` file/dir targets — the serve root is the + // window's @rk_present_root option, read from tmux at request time. + r.HandleFunc("/present/{windowId}/*", s.handlePresent) + r.HandleFunc("/present/{windowId}", s.handlePresent) + // The stable code-server route (260811-a2bo) — same proxy machinery as // /proxy/{port} with a FIXED pathname (workspace-state identity); the port // is resolved server-side and never appears in a URL. diff --git a/app/backend/cmd/rk/present.go b/app/backend/cmd/rk/present.go new file mode 100644 index 000000000..d375af553 --- /dev/null +++ b/app/backend/cmd/rk/present.go @@ -0,0 +1,313 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "rk/internal/present" + "rk/internal/tmux" + "rk/internal/validate" + + "github.com/spf13/cobra" +) + +// rk present — the one-verb "show this to the user": resolve a +// file/dir/port/URL target, derive its @rk_url value, and attach it to the +// caller's own tmux window (or, with --window, a fresh standalone iframe +// window). It NEVER opens the viewer's tile — layout is per-viewer client +// state (docs/specs/surface-layout.md R7/L3); availability surfaces on the +// rail via the SSE option poll, and --notify is the out-of-band nudge. +// +// Exit codes follow the toolkit convention (Principle 4): 0 success, 1 +// operational failure (not in tmux, missing file, unreachable port, tmux +// failure), 2 usage error (no target, unknown flag). Only the --notify send +// deviates — fail-silent per rk notify's documented contract. Stdout carries +// exactly the resolved URL (data — printed even under --quiet); diagnostics +// go to stderr. + +// presentCmdTimeout bounds every tmux subprocess the command spawns +// (Constitution §I: 5-10s for short-lived tmux helpers). +const presentCmdTimeout = 5 * time.Second + +var ( + presentWindowFlag string + presentNotifyFlag string +) + +var presentCmd = &cobra.Command{ + Use: "present [--window[=name]] [--notify[=msg]]", + Short: "Show a file, directory, port, or URL to the user as a web tile", + Long: "Attach web content to the user's view. The target resolves to one of:\n" + + " ./mock.html a file — served live, attached to this window\n" + + " ./dist/ a directory — served live (index.html default)\n" + + " :5173 a local port already serving — attached via /proxy/5173/\n" + + " http://localhost:N/… same, rewritten to the relative /proxy/N/… form\n" + + " https://… an external URL — attached verbatim\n\n" + + "By default the content attaches to the caller's own tmux window (@rk_url),\n" + + "and the resolved URL prints to stdout (relative for /present and /proxy\n" + + "targets, absolute for external URLs). --window spawns a standalone\n" + + "iframe window instead; --notify sends a Web Push after attaching (fail-silent).\n" + + "The tile is never opened for the viewer — availability appears on the rail.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPresent(cmd, args[0]) + }, +} + +// presentFlagAuto is the NoOptDefVal sentinel for --window/--notify: cobra +// only honors "flag without a value" when NoOptDefVal is non-empty, so a bare +// `--window` parses to this sentinel (derive the default) while `--window=x` +// carries x. The value is impossible to type as a real name/message. +const presentFlagAuto = "\x00auto" + +func init() { + // NoOptDefVal sentinel + Changed() distinguishes a bare --window/--notify + // (use the derived default) from an absent flag and from --flag=value. + presentCmd.Flags().StringVar(&presentWindowFlag, "window", "", + "Spawn a standalone iframe window instead of attaching to this window (optional name; defaults from the target)") + presentCmd.Flags().Lookup("window").NoOptDefVal = presentFlagAuto + presentCmd.Flags().StringVar(&presentNotifyFlag, "notify", "", + "Send a Web Push after attaching (optional message; defaults to \"presenting \")") + presentCmd.Flags().Lookup("notify").NoOptDefVal = presentFlagAuto +} + +// present*Fn are package-level seams so runPresent can be tested without a +// live tmux server or push endpoint (the role.go pattern); the defaults +// delegate to internal/tmux / internal/present / the rk notify send path. +var ( + presentOriginalTMUXFn = func() string { return tmux.OriginalTMUX } + presentRunOutputFn = func(ctx context.Context, args []string) ([]byte, error) { + return tmux.RunOutput(ctx, args, tmux.RunOpts{}) + } + presentSetWindowOptionsFn = func(ctx context.Context, windowID, server string, ops []tmux.WindowOptionOp) error { + return tmux.SetWindowOptions(ctx, windowID, server, ops) + } + presentCreateWindowFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) error { + return tmux.CreateWindowWithOptions(session, name, cwd, server, ops) + } + presentCreateWindowIDFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) (string, error) { + return tmux.CreateWindowWithOptionsID(session, name, cwd, server, ops) + } + presentProbeFn = func(ctx context.Context, port int) error { return present.ProbePort(ctx, port) } + presentNotifyFn = sendNotify + presentNowFn = func() int64 { return time.Now().Unix() } +) + +// Window option keys this command writes. @rk_type is touched ONLY by the +// --window arm — attaching to the caller's own window must not steal its +// default view (HINT_ORDER gives a web default hint only via @rk_type=iframe). +const ( + presentURLOption = "@rk_url" + presentRootOption = "@rk_present_root" + presentTypeOption = "@rk_type" +) + +// runPresent is the testable core: parse → probe → attach (or create) → print +// → optionally notify. Every tmux call runs under one bounded context. +func runPresent(cmd *cobra.Command, arg string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve working directory: %w", err) + } + target, err := present.ParseTarget(arg, cwd) + if err != nil { + return err + } + + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, presentCmdTimeout) + defer cancel() + + // Best-effort reachability probe for port/local-URL targets only. + if target.NeedsProbe() { + if err := presentProbeFn(ctx, target.Port); err != nil { + return err + } + } + + var url string + if cmd.Flags().Changed("window") { + url, err = presentViaNewWindow(ctx, cmd, target) + } else { + url, err = presentAttach(ctx, target) + } + if err != nil { + return err + } + + sink := newSink(cmd) + sink.Dataf("%s\n", url) + + // --notify is fail-silent by contract (rk notify): after a successful + // attach, send and swallow any failure. + if cmd.Flags().Changed("notify") { + msg := presentNotifyFlag + if msg == presentFlagAuto { + msg = "presenting " + target.Name + } + presentNotifyFn(ctx, "", msg) + } + return nil +} + +// callerContext resolves the caller's tmux context: the -S socket prefix from +// the ORIGINAL $TMUX (internal/tmux's init() strips $TMUX from the process — +// see writeAgentStateImpl for the full rationale) and the server name the +// ?server= query param and the -L primitives address it by (socket basename, +// matching ListServers naming). Returns ok=false when $TMUX is unset or +// malformed — the caller decides whether that is fatal. +func callerContext() (prefix []string, serverName string, ok bool) { + tmuxEnv := presentOriginalTMUXFn() + prefix = tmuxSocketArgs(tmuxEnv) + if len(prefix) == 0 { + return nil, "", false + } + socket := tmuxEnv + if i := strings.IndexByte(socket, ','); i >= 0 { + socket = socket[:i] + } + return prefix, filepath.Base(socket), true +} + +// presentAttach implements the default arm: set @rk_url (and, for file/dir +// targets, @rk_present_root) on the caller's OWN window, located via +// $TMUX_PANE. No window creation, no API call, no layout mutation. +func presentAttach(ctx context.Context, target present.Target) (string, error) { + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return "", fmt.Errorf("not inside a tmux pane ($TMUX_PANE is unset) — use --window to spawn a standalone window") + } + prefix, serverName, ok := callerContext() + if !ok { + return "", fmt.Errorf("cannot derive this pane's tmux server socket from $TMUX (unset or malformed) — refusing to target the default server") + } + + out, err := presentRunOutputFn(ctx, append(prefix, "display-message", "-pt", pane, "#{window_id}")) + if err != nil { + return "", fmt.Errorf("resolve current window: %w", err) + } + windowID := strings.TrimSpace(string(out)) + if errMsg := validate.ValidateWindowID(windowID, "Window ID"); errMsg != "" { + return "", fmt.Errorf("resolve current window: %s", errMsg) + } + + url := target.URL(windowID, serverName, presentNowFn) + urlOp := url + ops := []tmux.WindowOptionOp{{Key: presentURLOption, Value: &urlOp}} + if target.NeedsRoot() { + root := target.Root + ops = append(ops, tmux.WindowOptionOp{Key: presentRootOption, Value: &root}) + } else { + // Clear any stale serve root left by a previous file/dir present on + // this window — otherwise /present/{windowId}/... would keep serving + // the old filesystem root after the window moved on to a port/URL + // target (nil Value = set-option -u). + ops = append(ops, tmux.WindowOptionOp{Key: presentRootOption, Value: nil}) + } + if err := presentSetWindowOptionsFn(ctx, windowID, serverName, ops); err != nil { + return "", fmt.Errorf("attach to window %s: %w", windowID, err) + } + return url, nil +} + +// presentViaNewWindow implements the --window arm: create a standalone iframe +// window in the caller's session carrying @rk_type=iframe + @rk_url (+ root +// for file/dir targets). For file/dir targets the /present/ URL embeds the +// NEW window's id, so creation runs with @rk_type alone (atomic at creation) +// and the id-dependent options follow in one SetWindowOptions batch. +func presentViaNewWindow(ctx context.Context, cmd *cobra.Command, target present.Target) (string, error) { + name := presentWindowFlag + if name == presentFlagAuto { + name = presentWindowName(target) + } else if errMsg := validate.ValidateNewName(name, "Window name"); errMsg != "" { + return "", fmt.Errorf("--window name: %s", errMsg) + } + + // Session resolution: the caller's current session when inside tmux; + // outside a pane, the default server's current session (resolvable only + // when a server is running — otherwise operational failure). + pane := os.Getenv("TMUX_PANE") + prefix, serverName, _ := callerContext() + var session string + var err error + if pane != "" { + if len(prefix) == 0 { + return "", fmt.Errorf("cannot derive this pane's tmux server socket from $TMUX (unset or malformed)") + } + session, err = presentCallerValue(ctx, append(prefix, "display-message", "-pt", pane, "#{session_name}")) + } else { + serverName = "default" + session, err = presentCallerValue(ctx, []string{"display-message", "-p", "#{session_name}"}) + } + if err != nil { + return "", fmt.Errorf("resolve target session: %w", err) + } + + iframe := "iframe" + if target.NeedsRoot() { + id, err := presentCreateWindowIDFn(session, name, "", serverName, + []tmux.WindowOptionOp{{Key: presentTypeOption, Value: &iframe}}) + if err != nil { + return "", fmt.Errorf("create window: %w", err) + } + url := target.URL(id, serverName, presentNowFn) + urlOp := url + root := target.Root + if err := presentSetWindowOptionsFn(ctx, id, serverName, []tmux.WindowOptionOp{ + {Key: presentURLOption, Value: &urlOp}, + {Key: presentRootOption, Value: &root}, + }); err != nil { + return "", fmt.Errorf("attach window %s: %w", id, err) + } + return url, nil + } + + url := target.URL("", serverName, presentNowFn) + urlOp := url + if err := presentCreateWindowFn(session, name, "", serverName, []tmux.WindowOptionOp{ + {Key: presentTypeOption, Value: &iframe}, + {Key: presentURLOption, Value: &urlOp}, + }); err != nil { + return "", fmt.Errorf("create window: %w", err) + } + return url, nil +} + +// presentCallerValue runs a display-message-style tmux read and returns the +// trimmed single-line output. +func presentCallerValue(ctx context.Context, args []string) (string, error) { + out, err := presentRunOutputFn(ctx, args) + if err != nil { + return "", err + } + v := strings.TrimSpace(string(out)) + if v == "" { + return "", fmt.Errorf("empty response from tmux") + } + return v, nil +} + +// presentWindowName derives the default standalone-window name from the +// target: basename (or host / port-), with colons, periods, and spaces +// replaced by "-" (the port- precedent — ValidateNewName forbids them). +// An unusable remainder falls back to "present". +func presentWindowName(target present.Target) string { + name := strings.Map(func(r rune) rune { + switch r { + case ':', '.', ' ': + return '-' + } + return r + }, target.Name) + if validate.ValidateNewName(name, "Window name") != "" { + return "present" + } + return name +} diff --git a/app/backend/cmd/rk/present_test.go b/app/backend/cmd/rk/present_test.go new file mode 100644 index 000000000..cf3291310 --- /dev/null +++ b/app/backend/cmd/rk/present_test.go @@ -0,0 +1,426 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "rk/internal/present" + "rk/internal/tmux" +) + +// presentTestEnv installs the present command's seams with fakes and returns +// observers for the faked tmux/notify interactions. The default fake session +// is pane %3 on window @7 of server "dev" (socket /tmp/tmux-1000/dev). +type presentFake struct { + displayArgs [][]string + setOpsWindow []string + setOpsServer []string + setOps [][]tmux.WindowOptionOp + created []presentCreated + createdID []presentCreatedID + notified []string + probed []int +} + +type presentCreated struct { + session, name, server string + ops []tmux.WindowOptionOp +} + +type presentCreatedID struct { + session, name, server string + ops []tmux.WindowOptionOp +} + +func installPresentFakes(t *testing.T) *presentFake { + t.Helper() + f := &presentFake{} + + presentOriginalTMUXFn = func() string { return "/tmp/tmux-1000/dev,123,0" } + presentRunOutputFn = func(_ context.Context, args []string) ([]byte, error) { + f.displayArgs = append(f.displayArgs, args) + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "#{window_id}"): + return []byte("@7\n"), nil + case strings.Contains(joined, "#{session_name}"): + return []byte("work\n"), nil + } + return nil, fmt.Errorf("unexpected tmux read: %s", joined) + } + presentSetWindowOptionsFn = func(_ context.Context, windowID, server string, ops []tmux.WindowOptionOp) error { + f.setOpsWindow = append(f.setOpsWindow, windowID) + f.setOpsServer = append(f.setOpsServer, server) + f.setOps = append(f.setOps, ops) + return nil + } + presentCreateWindowFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) error { + f.created = append(f.created, presentCreated{session, name, server, ops}) + return nil + } + presentCreateWindowIDFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) (string, error) { + f.createdID = append(f.createdID, presentCreatedID{session, name, server, ops}) + return "@42", nil + } + presentProbeFn = func(_ context.Context, port int) error { + f.probed = append(f.probed, port) + return nil + } + presentNotifyFn = func(_ context.Context, title, body string) { + f.notified = append(f.notified, body) + } + presentNowFn = func() int64 { return 1700000000 } + + t.Cleanup(func() { + presentOriginalTMUXFn = func() string { return tmux.OriginalTMUX } + presentRunOutputFn = func(ctx context.Context, args []string) ([]byte, error) { + return tmux.RunOutput(ctx, args, tmux.RunOpts{}) + } + presentSetWindowOptionsFn = func(ctx context.Context, windowID, server string, ops []tmux.WindowOptionOp) error { + return tmux.SetWindowOptions(ctx, windowID, server, ops) + } + presentCreateWindowFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) error { + return tmux.CreateWindowWithOptions(session, name, cwd, server, ops) + } + presentCreateWindowIDFn = func(session, name, cwd, server string, ops []tmux.WindowOptionOp) (string, error) { + return tmux.CreateWindowWithOptionsID(session, name, cwd, server, ops) + } + presentProbeFn = func(ctx context.Context, port int) error { return present.ProbePort(ctx, port) } + presentNotifyFn = sendNotify + presentNowFn = func() int64 { return time.Now().Unix() } + }) + return f +} + +// runPresentCmd drives `rk present ` through the real cobra Execute() +// seam (the skill_test.go runSkill pattern) so arg/flag validation and exit +// classification run exactly as in production. Present's local flags and the +// root persistent --quiet are reset before and after so no state bleeds. +func runPresentCmd(t *testing.T, args ...string) (string, string, error) { + t.Helper() + resetRootFlagState(t) + resetPresentFlagState(t) + var stdout, stderr bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + rootCmd.SetArgs(append([]string{"present"}, args...)) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + err := rootCmd.Execute() + return stdout.String(), stderr.String(), err +} + +func resetPresentFlagState(t *testing.T) { + t.Helper() + reset := func() { + for _, name := range []string{"window", "notify"} { + if f := presentCmd.Flags().Lookup(name); f != nil { + _ = presentCmd.Flags().Set(name, "") + f.Changed = false + } + } + presentWindowFlag, presentNotifyFlag = "", "" + if f := rootCmd.PersistentFlags().Lookup("quiet"); f != nil { + _ = rootCmd.PersistentFlags().Set("quiet", "false") + f.Changed = false + } + quiet = false + } + reset() + t.Cleanup(reset) +} + +// opValue finds a set op (non-nil Value) by key. +func opValue(ops []tmux.WindowOptionOp, key string) (string, bool) { + for _, op := range ops { + if op.Key == key && op.Value != nil { + return *op.Value, true + } + } + return "", false +} + +// opUnset reports whether ops carries an unset (nil-Value) op for key. +func opUnset(ops []tmux.WindowOptionOp, key string) bool { + for _, op := range ops { + if op.Key == key && op.Value == nil { + return true + } + } + return false +} + +func TestPresentUsageErrorsExitTwo(t *testing.T) { + installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + stdout, stderr, err := runPresentCmd(t) // no target + if err == nil || exitCode(err) != exitUsage { + t.Errorf("no target: err = %v (code %d), want usage exit 2", err, exitCode(err)) + } + if stdout != "" { + t.Errorf("no target wrote to stdout: %q", stdout) + } + if stderr == "" { + t.Error("no target wrote nothing to stderr, want usage diagnostic") + } + + if _, _, err := runPresentCmd(t, ":5173", "--bogus"); err == nil || exitCode(err) != exitUsage { + t.Errorf("unknown flag: err = %v (code %d), want usage exit 2", err, exitCode(err)) + } +} + +func TestPresentOutsideTmuxExitsOne(t *testing.T) { + installPresentFakes(t) + t.Setenv("TMUX_PANE", "") // not in a pane; no --window + + dir := t.TempDir() + file := filepath.Join(dir, "mock.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + stdout, _, err := runPresentCmd(t, file) + if err == nil || exitCode(err) != 1 { + t.Errorf("err = %v (code %d), want operational exit 1", err, exitCode(err)) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty on failure", stdout) + } +} + +func TestPresentUnreachablePortExitsOne(t *testing.T) { + f := installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + presentProbeFn = func(_ context.Context, port int) error { + f.probed = append(f.probed, port) + return fmt.Errorf("nothing is listening on port %d (127.0.0.1): connection refused", port) + } + + stdout, _, err := runPresentCmd(t, ":59999") + if err == nil || exitCode(err) != 1 { + t.Errorf("err = %v (code %d), want operational exit 1", err, exitCode(err)) + } + if !strings.Contains(err.Error(), "59999") { + t.Errorf("diagnostic %q does not name the unreachable port", err) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty on failure", stdout) + } + if len(f.setOps) != 0 { + t.Error("unreachable port still wrote window options") + } +} + +// TestPresentAttachComposition pins the default arm's option set per target +// kind: file/dir get @rk_url + @rk_present_root on the caller's OWN window; +// port/URL targets set @rk_url and UNSET @rk_present_root (clearing any stale +// serve root from a previous file/dir present), with no cache-buster. stdout +// carries exactly the URL. +func TestPresentAttachComposition(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "mock.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + arg string + wantURL string + wantRoot string // "" = no root op expected + wantProbe bool + }{ + {"file", file, "/present/@7/mock.html?server=dev&v=1700000000", dir, false}, + {"dir", dir, "/present/@7/?server=dev&v=1700000000", dir, false}, + {"port", ":5173", "/proxy/5173/", "", true}, + {"local URL", "http://localhost:8080/docs?x=1", "/proxy/8080/docs?x=1", "", true}, + {"external URL", "https://staging.example.com/app", "https://staging.example.com/app", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + stdout, _, err := runPresentCmd(t, tc.arg) + if err != nil { + t.Fatalf("runPresent: %v", err) + } + if stdout != tc.wantURL+"\n" { + t.Errorf("stdout = %q, want exactly %q", stdout, tc.wantURL+"\n") + } + if len(f.setOps) != 1 { + t.Fatalf("SetWindowOptions calls = %d, want 1", len(f.setOps)) + } + if f.setOpsWindow[0] != "@7" || f.setOpsServer[0] != "dev" { + t.Errorf("attach target = (%q, %q), want (@7, dev)", f.setOpsWindow[0], f.setOpsServer[0]) + } + ops := f.setOps[0] + if u, ok := opValue(ops, presentURLOption); !ok || u != tc.wantURL { + t.Errorf("@rk_url = %q (set=%v), want %q", u, ok, tc.wantURL) + } + root, hasRoot := opValue(ops, presentRootOption) + if tc.wantRoot == "" { + if hasRoot { + t.Errorf("unexpected @rk_present_root = %q", root) + } + if !opUnset(ops, presentRootOption) { + t.Error("non-file/dir target did not unset @rk_present_root — a stale serve root would survive") + } + } + if tc.wantRoot != "" && (!hasRoot || root != tc.wantRoot) { + t.Errorf("@rk_present_root = %q (set=%v), want %q", root, hasRoot, tc.wantRoot) + } + if _, hasType := opValue(ops, presentTypeOption); hasType { + t.Error("attach arm touched @rk_type — must not steal the window's default view") + } + if tc.wantProbe && len(f.probed) == 0 { + t.Error("expected a reachability probe, got none") + } + if !tc.wantProbe && len(f.probed) != 0 { + t.Errorf("unexpected probes: %v", f.probed) + } + // URL targets carry no cache-buster. + if tc.wantRoot == "" && strings.Contains(tc.wantURL, "v=") { + t.Errorf("URL target %q carries a buster, want none", tc.wantURL) + } + }) + } +} + +func TestPresentURLStillPrintsUnderQuiet(t *testing.T) { + installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + stdout, _, err := runPresentCmd(t, "--quiet", ":5173") + if err != nil { + t.Fatalf("runPresent: %v", err) + } + if stdout != "/proxy/5173/\n" { + t.Errorf("stdout = %q, want the URL even under --quiet (stdout is data)", stdout) + } +} + +func TestPresentNotifyDefaultMessage(t *testing.T) { + f := installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + dir := t.TempDir() + file := filepath.Join(dir, "mock.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runPresentCmd(t, "--notify", file); err != nil { + t.Fatalf("runPresent --notify: %v", err) + } + if len(f.notified) != 1 || f.notified[0] != "presenting mock.html" { + t.Errorf("notified = %v, want [presenting mock.html]", f.notified) + } + + resetPresentFlagState(t) + if _, _, err := runPresentCmd(t, file, "--notify=look at this"); err != nil { + t.Fatalf("runPresent --notify=msg: %v", err) + } + if len(f.notified) != 2 || f.notified[1] != "look at this" { + t.Errorf("notified = %v, want second message \"look at this\"", f.notified) + } + + // Without the flag, nothing is sent. + resetPresentFlagState(t) + if _, _, err := runPresentCmd(t, file); err != nil { + t.Fatalf("runPresent: %v", err) + } + if len(f.notified) != 2 { + t.Errorf("notify sent without the flag: %v", f.notified) + } +} + +func TestPresentWindowExternalURL(t *testing.T) { + f := installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + stdout, _, err := runPresentCmd(t, "--window", "https://staging.example.com") + if err != nil { + t.Fatalf("runPresent --window: %v", err) + } + if stdout != "https://staging.example.com\n" { + t.Errorf("stdout = %q, want the verbatim URL", stdout) + } + if len(f.created) != 1 { + t.Fatalf("CreateWindowWithOptions calls = %d, want 1", len(f.created)) + } + c := f.created[0] + if c.session != "work" || c.server != "dev" { + t.Errorf("created in (%q, %q), want (work, dev)", c.session, c.server) + } + if c.name != "staging-example-com" { + t.Errorf("window name = %q, want sanitized host staging-example-com", c.name) + } + if tp, ok := opValue(c.ops, presentTypeOption); !ok || tp != "iframe" { + t.Errorf("@rk_type = %q (set=%v), want iframe", tp, ok) + } + if u, ok := opValue(c.ops, presentURLOption); !ok || u != "https://staging.example.com" { + t.Errorf("@rk_url = %q (set=%v), want the verbatim URL", u, ok) + } +} + +// TestPresentWindowFileTwoStep pins the file/dir --window flow: the /present/ +// URL embeds the NEW window's id, so creation sets @rk_type alone and the +// id-dependent options land in a follow-up batch on the returned id. +func TestPresentWindowFileTwoStep(t *testing.T) { + f := installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + dir := t.TempDir() + file := filepath.Join(dir, "mock.report.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + stdout, _, err := runPresentCmd(t, "--window", file) + if err != nil { + t.Fatalf("runPresent --window file: %v", err) + } + wantURL := "/present/@42/mock.report.html?server=dev&v=1700000000" + if stdout != wantURL+"\n" { + t.Errorf("stdout = %q, want %q (new window's id in the URL)", stdout, wantURL) + } + if len(f.createdID) != 1 { + t.Fatalf("CreateWindowWithOptionsID calls = %d, want 1", len(f.createdID)) + } + c := f.createdID[0] + if c.name != "mock-report-html" { + t.Errorf("window name = %q, want mock-report-html (periods sanitized)", c.name) + } + if len(c.ops) != 1 { + t.Errorf("creation ops = %+v, want @rk_type alone (URL needs the new id)", c.ops) + } + if len(f.setOps) != 1 || f.setOpsWindow[0] != "@42" { + t.Fatalf("follow-up option set = windows %v, want [@42]", f.setOpsWindow) + } + if u, _ := opValue(f.setOps[0], presentURLOption); u != wantURL { + t.Errorf("@rk_url = %q, want %q", u, wantURL) + } + if root, ok := opValue(f.setOps[0], presentRootOption); !ok || root != dir { + t.Errorf("@rk_present_root = %q (set=%v), want %q", root, ok, dir) + } +} + +func TestPresentWindowExplicitNameAndOutsideTmux(t *testing.T) { + installPresentFakes(t) + t.Setenv("TMUX_PANE", "%3") + + if _, _, err := runPresentCmd(t, "--window=my mock", ":5173"); err == nil { + t.Fatal("--window with a space in the explicit name: err = nil, want ValidateNewName rejection") + } +} diff --git a/app/backend/cmd/rk/root.go b/app/backend/cmd/rk/root.go index f201bebff..497f253b6 100644 --- a/app/backend/cmd/rk/root.go +++ b/app/backend/cmd/rk/root.go @@ -68,6 +68,7 @@ func init() { rootCmd.AddCommand(snapshotCmd) rootCmd.AddCommand(roleCmd) rootCmd.AddCommand(codeServerCmd) + rootCmd.AddCommand(presentCmd) rootCmd.AddCommand(newShellInitCmd()) rootCmd.AddCommand(helpDumpCmd) diff --git a/app/backend/cmd/rk/skill/display.md b/app/backend/cmd/rk/skill/display.md index 263003f1c..03eeb08b8 100644 --- a/app/backend/cmd/rk/skill/display.md +++ b/app/backend/cmd/rk/skill/display.md @@ -1,6 +1,6 @@ # run-kit skill: display -Depth for one job: **putting visual content in front of the user** — a terminal window, an iframe rendering a web page, a generated HTML report — from inside a tmux pane run-kit manages. This is a static topic page (`rk skill display`); the [core bundle](../skill.md) covers when to reach for run-kit at all. Everything here is byte-identical on every invocation; live values are symbolic — resolve the server URL at use-time with `rk url`. +Depth for one job: **putting visual content in front of the user** — a generated HTML report, a diagram, a dev server — from inside a tmux pane run-kit manages. This is a static topic page (`rk skill display`); the [core bundle](../skill.md) covers when to reach for run-kit at all. Everything here is byte-identical on every invocation; live values are symbolic — resolve the server URL at use-time with `rk url`. Gate first, as always — run-kit is optional and may be absent: @@ -8,30 +8,49 @@ Gate first, as always — run-kit is optional and may be absent: command -v rk >/dev/null 2>&1 && [ -n "$TMUX_PANE" ] || exit 0 ``` -## Terminal Windows +## `rk present` — the primary recipe -Create a new terminal window in the current tmux session: +One verb resolves the target, serves it if needed, and attaches it to the web tile of YOUR OWN window: ```sh -tmux new-window -n +rk present ./mock.html # a file — served live, attached +rk present ./dist/ # a directory (index.html default) +rk present :5173 # a port already serving → /proxy/5173/ +rk present http://localhost:8080/x # same, rewritten to /proxy/8080/x +rk present https://example.com/app # external URL — attached verbatim ``` -## Iframe Windows +The resolved URL prints to stdout (relative for `/present` and `/proxy` targets, absolute for external URLs); diagnostics go to stderr. Exit codes: `0` success, `1` operational failure (not in tmux, file missing, port not listening), `2` usage. -Create a window that renders a web page in an iframe instead of a terminal: +**You cannot open the tile for the user.** Layout is per-viewer client state — `rk present` only makes content AVAILABLE (the rail's web button lights up). When the user may be away, nudge them: ```sh -tmux new-window -n -tmux set-option -w @rk_type iframe -tmux set-option -w @rk_url +rk present ./mock.html --notify # message: "presenting mock.html" +rk present ./mock.html --notify "report ready" ``` -Change the page of an existing iframe window later by re-setting `@rk_url`: +The notify send is fail-silent (like `rk notify`) — never branch on it. + +## Iteration + +- **Re-present is the refresh verb** — re-running `rk present` on the same file/dir target bumps a cache-buster in the attached URL, so an open web tile re-navigates. +- File/dir targets serve from the LIVE filesystem — a plain browser reload already sees your edits; re-present only when the tile must re-navigate. + +## Attach vs. standalone window + +Default attaches to your own window — one `@rk_url` per window, so last write wins on multi-pane windows. Use `--window` for the residual cases: + +- an **external URL with no owning pane** (you are presenting something unrelated to your work), +- a **second simultaneous mock** (your window's tile is already taken), +- content that deserves its own **board-pinnable identity**. ```sh -tmux set-option -w @rk_url +rk present --window https://staging.example.com # name from the host +rk present --window=report ./dist/ # explicit name ``` +`--window` spawns a new tmux window in your session carrying `@rk_type=iframe` — the one remaining legitimate producer of that hint. + ## Proxy Reach a local service through the run-kit server using the proxy path: @@ -40,42 +59,32 @@ Reach a local service through the run-kit server using the proxy path: /proxy/{port}/... ``` -A service on port 8080 is available at `/proxy/8080/`. The **relative** form works from the frontend against whatever origin the user is on — `localhost` directly or behind a reverse proxy — so never compose an absolute `{server_url}/proxy/...`; hand the frontend the relative path and let it resolve. - -## Visual Display Recipe - -The canonical 4-step flow to show HTML content to the user in an iframe window. Every step SHALL fail silently if its prerequisite is unavailable (run-kit missing, port in use, server fails to start) — skip the remaining steps without surfacing an error. - -1. **Generate HTML** to a known location (e.g. `~/.agent/diagrams/`, a temp directory, or the project tree). -2. **Serve it** on loopback (not exposed on the LAN): - - ```sh - python3 -m http.server --bind 127.0.0.1 -d & - ``` - -3. **Open an iframe window** pointing to the **relative** proxy path. The run-kit frontend resolves it against whatever origin the user is on, so it works identically on `localhost:3000` directly or behind a reverse proxy: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url /proxy// - ``` - -4. **Fail silently** — if any step's prerequisite is unavailable (run-kit missing, port in use, server start fails), skip the rest without error. +A service on port 8080 is available at `/proxy/8080/`. The **relative** form works from the frontend against whatever origin the user is on — `localhost` directly or behind a reverse proxy — so never compose an absolute `{server_url}/proxy/...`; hand the frontend the relative path and let it resolve. `rk present :8080` and `rk present http://localhost:8080/...` derive this form for you. ## Conventions ### Tmux user options -- `@rk_type` — window type: `terminal` (default) or `iframe`. Set via `tmux set-option -w @rk_type `. -- `@rk_url` — URL for iframe windows. Set via `tmux set-option -w @rk_url `. +- `@rk_url` — the window's attached web content (availability signal for the rail's web tile). +- `@rk_present_root` — the absolute serve root for `/present//...` file serving; set by `rk present` for file/dir targets, dies with the window. +- `@rk_type` — window type: `terminal` (default) or `iframe`. A creation-time default-view hint only — attaching `@rk_url` to a tty-led window does NOT steal its default view. -`set-option -w` targets the **current** window: create the window first, then set options from within it (or pass `-t `). +### SSE reactivity + +Changes to tmux window options are picked up automatically by the run-kit server via SSE polling — no manual refresh, no API call. ### Window lifecycle Killing a tmux window kills the backing process. No separate cleanup step is needed. -### SSE reactivity +## Appendix: the manual recipe (older rk versions) -Changes to tmux window options are picked up automatically by the run-kit server via SSE polling — no manual refresh, no API call. +On an rk too old to have `present`, spawn an iframe window by hand. Serve the content yourself (e.g. `python3 -m http.server --bind 127.0.0.1 -d &`), then: + +```sh +tmux new-window -n +tmux set-option -w @rk_type iframe +tmux set-option -w @rk_url /proxy// +``` + +Change the page later by re-setting `@rk_url`. Every step SHALL fail silently if its prerequisite is unavailable (run-kit missing, port in use, server fails to start) — skip the remaining steps without surfacing an error. diff --git a/app/backend/cmd/rk/skill/skill.md b/app/backend/cmd/rk/skill/skill.md index 5e936c056..68aff5928 100644 --- a/app/backend/cmd/rk/skill/skill.md +++ b/app/backend/cmd/rk/skill/skill.md @@ -31,16 +31,8 @@ One line each, keyed to the subcommand or tmux option that does it: - `rk notify [--title ]` — Web Push a message to every subscribed browser/device. Fail-silent by contract (see Output contracts). - `rk url` — print the run-kit **server URL** (config-derived: RK_HOST/RK_PORT, default `http://127.0.0.1:3000`). It is a heuristic, not a liveness probe. Run it at use-time; never hardcode the value. -- `rk skill display` — the visual-display topic page: panes, iframe windows, the proxy, and the canonical Visual Display Recipe, in depth. -- **Iframe windows** — a tmux window that renders a web page instead of a terminal: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url - ``` - - Change the page later by re-setting `@rk_url`. +- `rk present ` — attach web content beside your own terminal: a file, a directory, a `:port`, a localhost URL, or an external URL. Prints the resolved URL to stdout. Depth: `rk skill display`. +- `rk skill display` — the visual-display topic page: target forms, attach vs. standalone windows, the proxy, and the canonical Visual Display Recipe, in depth. - **Proxy** — reach a local service through the run-kit server: ``` @@ -48,17 +40,11 @@ One line each, keyed to the subcommand or tmux option that does it: ``` The relative form `/proxy/{port}/...` works from the frontend behind any origin or reverse proxy. -- **Visual Display Recipe** — the canonical 4-step flow to show HTML to the user: +- **Visual Display Recipe** — the canonical flow to show HTML to the user: 1. **Generate HTML** to a known location (a temp dir or the project tree). - 2. **Serve it** on loopback: `python3 -m http.server --bind 127.0.0.1 -d &` - 3. **Open an iframe window** with a relative proxy path: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url /proxy// - ``` - 4. **Fail silently** — if any step's prerequisite is unavailable (run-kit missing, port in use, server start fails), skip the rest without surfacing an error. + 2. **`rk present ./file.html`** — serves it live and attaches it to your window's web tile; re-run the same command to refresh. + 3. **Optionally `--notify`** — push the user when they may be away. + 4. **Fail silently** — if any prerequisite is unavailable (run-kit missing, not in tmux), skip the rest without surfacing an error. ## Where am I @@ -85,6 +71,7 @@ rk url # server URL (config-derived) ## Output & exit-code contracts - **`rk notify` is fail-silent by contract.** Any error — server unreachable, no subscriptions, non-2xx — exits **0** and prints nothing, so it never stalls a calling loop. Do not branch on its output. +- **`rk present` prints only the resolved URL to stdout** (data — printed even under `--quiet`); diagnostics go to stderr. Its exit codes follow the convention below; its `--notify` send stays fail-silent like `rk notify`. - **`rk skill`, `rk url`, and `rk help-dump` print data to stdout** (stdout is data; stderr is diagnostics). `rk skill` emits this bundle byte-identical with empty stderr and exit 0; `rk skill ` (e.g. `display`) prints one topic page under the same contract, and an unknown topic exits non-zero with the valid topics on stderr; `rk url` prints the server URL newline-terminated; `rk help-dump` emits the machine-readable command tree. - **Exit codes follow the toolkit convention: `0` success, `1` operational failure, `2` usage error** — usage/flag/arg-count/unknown-command errors exit `2`; operational failures (dead server, failed check) exit `1`; `rk riff` subprocess failures exit `3`. The diagnostic is on stderr. (`rk notify` is the exception above — runtime failures exit `0`.) diff --git a/app/backend/internal/present/present.go b/app/backend/internal/present/present.go new file mode 100644 index 000000000..38a2e8533 --- /dev/null +++ b/app/backend/internal/present/present.go @@ -0,0 +1,226 @@ +// Package present resolves `rk present` targets and derives the @rk_url value +// each target kind attaches to a tmux window. It is pure (no tmux, no HTTP): +// the only I/O is os.Stat for file/dir classification and a TCP dial for the +// reachability probe, so every rule is unit-testable without a live server. +// +// The five target kinds (spec: fab change 260813-becu-rk-present-attach-verb): +// +// file existing regular file → /present//?server=&v= +// dir existing directory → /present//?server=&v= +// port ":NNNN" → /proxy// +// local URL http://localhost… → /proxy// +// external URL any other http(s) URL → attached verbatim +// +// File/dir targets also carry a serve Root (the file's parent dir / the dir +// itself) which the CLI sets as the @rk_present_root window option; the +// /present/{windowId}/ route reads it back from tmux at request time. +package present + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// Kind classifies a parsed target. +type Kind int + +const ( + KindFile Kind = iota // existing regular file + KindDir // existing directory + KindPort // ":NNNN" + KindLocalURL // absolute http:// URL on a localhost host + KindExternalURL // any other absolute http(s):// URL +) + +// String names the kind, for diagnostics. +func (k Kind) String() string { + switch k { + case KindFile: + return "file" + case KindDir: + return "dir" + case KindPort: + return "port" + case KindLocalURL: + return "local URL" + case KindExternalURL: + return "external URL" + } + return "unknown" +} + +// portPattern is the ":NNNN" target form (colon + digits, nothing else). +var portPattern = regexp.MustCompile(`^:([0-9]+)$`) + +// localhostHosts is the closed set of URL hosts whose targets rewrite to the +// relative /proxy// form (Hostname() strips brackets, so "::1" covers +// the "[::1]" literal). +var localhostHosts = map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "::1": true, +} + +// Target is a resolved `rk present` argument. +type Target struct { + Kind Kind + // Root is the absolute serve root — the file's parent directory or the + // directory itself. Set for KindFile/KindDir only; the CLI attaches it as + // the @rk_present_root window option. + Root string + // Name is the display basename for the target: the file/dir base name, + // "port-" for port/local-URL targets, the hostname for external + // URLs. Window-name derivation sanitizes it at the command layer. + Name string + // Port is the TCP port for KindPort/KindLocalURL (default 80). + Port int + // PathQuery is the original path+query (leading "/", empty when the URL + // had neither) for KindLocalURL — preserved verbatim into the proxy form. + PathQuery string + // Verbatim is the original URL, attached unchanged, for KindExternalURL. + Verbatim string +} + +// ParseTarget resolves one CLI argument to a Target. cwd is the base for +// relative paths. A path that does not exist (and parses as neither a port +// nor an absolute URL) is an error. +func ParseTarget(arg, cwd string) (Target, error) { + if m := portPattern.FindStringSubmatch(arg); m != nil { + port, err := parsePort(m[1]) + if err != nil { + return Target{}, fmt.Errorf("invalid port target %q: %w", arg, err) + } + return Target{Kind: KindPort, Port: port, Name: fmt.Sprintf("port-%d", port)}, nil + } + + if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") { + u, err := url.Parse(arg) + if err != nil || u.Host == "" { + return Target{}, fmt.Errorf("invalid URL %q", arg) + } + // Only plaintext http on a localhost host rewrites to the relative + // proxy form; https (even to localhost) and any remote host attach + // verbatim — the proxy targets local http services only. + if u.Scheme == "http" && localhostHosts[u.Hostname()] { + port := 80 + if p := u.Port(); p != "" { + n, err := parsePort(p) + if err != nil { + return Target{}, fmt.Errorf("invalid port in URL %q: %w", arg, err) + } + port = n + } + return Target{ + Kind: KindLocalURL, + Port: port, + PathQuery: u.RequestURI(), + Name: fmt.Sprintf("port-%d", port), + }, nil + } + name := u.Hostname() + if name == "" { + name = "external" + } + return Target{Kind: KindExternalURL, Verbatim: arg, Name: name}, nil + } + + path := arg + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return Target{}, fmt.Errorf("resolve %q: %w", arg, err) + } + info, err := os.Stat(abs) + if err != nil { + return Target{}, fmt.Errorf("target %q does not exist", arg) + } + if info.IsDir() { + return Target{Kind: KindDir, Root: abs, Name: filepath.Base(abs)}, nil + } + if !info.Mode().IsRegular() { + return Target{}, fmt.Errorf("target %q is not a regular file or directory", arg) + } + return Target{Kind: KindFile, Root: filepath.Dir(abs), Name: filepath.Base(abs)}, nil +} + +// parsePort validates a decimal port string. +func parsePort(s string) (int, error) { + port, err := strconv.Atoi(s) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("port %q out of range 1-65535", s) + } + return port, nil +} + +// PresentURL composes the @rk_url value for a file/dir target carried by +// windowID on the named tmux server. The `?v=` cache-buster (unix seconds at +// invocation, supplied by now) makes re-presenting the same target a refresh: +// the new @rk_url differs and an open web tile re-navigates. name is the +// file basename, or empty for a directory target (serves the root's +// index.html). The form is always relative — never an absolute origin. +func PresentURL(windowID, name, server string, now func() int64) string { + path := "/present/" + windowID + "/" + if name != "" { + path += url.PathEscape(name) + } + return fmt.Sprintf("%s?server=%s&v=%d", path, url.QueryEscape(server), now()) +} + +// URL derives the @rk_url value for this target carried by windowID on the +// named server. now supplies the unix-seconds cache-buster for /present/ +// URLs only; port/URL targets re-set verbatim with no buster. +func (t Target) URL(windowID, server string, now func() int64) string { + switch t.Kind { + case KindFile: + return PresentURL(windowID, t.Name, server, now) + case KindDir: + return PresentURL(windowID, "", server, now) + case KindPort: + return fmt.Sprintf("/proxy/%d/", t.Port) + case KindLocalURL: + pq := t.PathQuery + if pq == "" || pq[0] != '/' { + pq = "/" + pq + } + return fmt.Sprintf("/proxy/%d%s", t.Port, pq) + default: // KindExternalURL + return t.Verbatim + } +} + +// NeedsRoot reports whether the target carries a serve root (@rk_present_root) +// — file and directory targets only. +func (t Target) NeedsRoot() bool { + return t.Kind == KindFile || t.Kind == KindDir +} + +// NeedsProbe reports whether the target gets a best-effort TCP reachability +// probe — port and local-URL targets only; file/dir/external never probe. +func (t Target) NeedsProbe() bool { + return t.Kind == KindPort || t.Kind == KindLocalURL +} + +// ProbeTimeout bounds the TCP reachability probe for port/local-URL targets. +const ProbeTimeout = 1 * time.Second + +// ProbePort dials 127.0.0.1: with a short timeout. Connection refused +// or timeout is an operational failure naming the port. +func ProbePort(ctx context.Context, port int) error { + d := net.Dialer{Timeout: ProbeTimeout} + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + return fmt.Errorf("nothing is listening on port %d (127.0.0.1): %w", port, err) + } + _ = conn.Close() + return nil +} diff --git a/app/backend/internal/present/present_test.go b/app/backend/internal/present/present_test.go new file mode 100644 index 000000000..003809225 --- /dev/null +++ b/app/backend/internal/present/present_test.go @@ -0,0 +1,238 @@ +package present + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +// fixedNow pins the cache-buster so URL derivations are deterministic. +func fixedNow() int64 { return 1700000000 } + +func TestParseTarget_fileAndDir(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "mock.html") + if err := os.WriteFile(file, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + arg string + cwd string + wantKind Kind + wantRoot string + wantName string + }{ + {"absolute file", file, dir, KindFile, dir, "mock.html"}, + {"relative file from cwd", "mock.html", dir, KindFile, dir, "mock.html"}, + {"dot-relative file", "./mock.html", dir, KindFile, dir, "mock.html"}, + {"absolute dir", dir, dir, KindDir, dir, filepath.Base(dir)}, + {"relative dir", ".", dir, KindDir, dir, filepath.Base(dir)}, + {"trailing-slash dir", dir + "/", dir, KindDir, dir, filepath.Base(dir)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseTarget(tc.arg, tc.cwd) + if err != nil { + t.Fatalf("ParseTarget(%q): %v", tc.arg, err) + } + if got.Kind != tc.wantKind { + t.Errorf("kind = %v, want %v", got.Kind, tc.wantKind) + } + if got.Root != tc.wantRoot { + t.Errorf("root = %q, want %q", got.Root, tc.wantRoot) + } + if got.Name != tc.wantName { + t.Errorf("name = %q, want %q", got.Name, tc.wantName) + } + }) + } +} + +func TestParseTarget_nonexistentPathErrors(t *testing.T) { + _, err := ParseTarget(filepath.Join(t.TempDir(), "nope.html"), "/") + if err == nil { + t.Fatal("ParseTarget of a nonexistent path: err = nil, want error") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("error %q does not name the missing target", err) + } +} + +func TestParseTarget_portForm(t *testing.T) { + got, err := ParseTarget(":5173", "/") + if err != nil { + t.Fatalf("ParseTarget(\":5173\"): %v", err) + } + if got.Kind != KindPort || got.Port != 5173 { + t.Errorf("got %+v, want KindPort port 5173", got) + } + if got.Name != "port-5173" { + t.Errorf("name = %q, want port-5173", got.Name) + } + if u := got.URL("@7", "dev", fixedNow); u != "/proxy/5173/" { + t.Errorf("url = %q, want /proxy/5173/", u) + } + + for _, bad := range []string{":0", ":65536", ":abc", ":"} { + if _, err := ParseTarget(bad, "/"); err == nil { + t.Errorf("ParseTarget(%q): err = nil, want error", bad) + } + } +} + +func TestParseTarget_localURLs(t *testing.T) { + tests := []struct { + arg string + wantPort int + wantPQ string + wantURL string + }{ + {"http://localhost:8080/docs?x=1", 8080, "/docs?x=1", "/proxy/8080/docs?x=1"}, + {"http://localhost:8080", 8080, "/", "/proxy/8080/"}, + {"http://localhost/app", 80, "/app", "/proxy/80/app"}, + {"http://127.0.0.1:3000", 3000, "/", "/proxy/3000/"}, + {"http://[::1]:9000/a/b?y=2&z=3", 9000, "/a/b?y=2&z=3", "/proxy/9000/a/b?y=2&z=3"}, + } + for _, tc := range tests { + t.Run(tc.arg, func(t *testing.T) { + got, err := ParseTarget(tc.arg, "/") + if err != nil { + t.Fatalf("ParseTarget(%q): %v", tc.arg, err) + } + if got.Kind != KindLocalURL { + t.Fatalf("kind = %v, want KindLocalURL", got.Kind) + } + if got.Port != tc.wantPort { + t.Errorf("port = %d, want %d", got.Port, tc.wantPort) + } + if got.PathQuery != tc.wantPQ { + t.Errorf("pathQuery = %q, want %q", got.PathQuery, tc.wantPQ) + } + if u := got.URL("@7", "dev", fixedNow); u != tc.wantURL { + t.Errorf("url = %q, want %q (relative form, never an absolute origin)", u, tc.wantURL) + } + }) + } +} + +func TestParseTarget_externalURLsVerbatim(t *testing.T) { + for _, arg := range []string{ + "https://staging.example.com/app", + "https://staging.example.com", + "http://192.168.1.20:8080/lan", // non-localhost http attaches verbatim + "https://localhost:8443/", // https-to-localhost is not proxied + } { + t.Run(arg, func(t *testing.T) { + got, err := ParseTarget(arg, "/") + if err != nil { + t.Fatalf("ParseTarget(%q): %v", arg, err) + } + if got.Kind != KindExternalURL { + t.Fatalf("kind = %v, want KindExternalURL", got.Kind) + } + if u := got.URL("@7", "dev", fixedNow); u != arg { + t.Errorf("url = %q, want verbatim %q", u, arg) + } + if got.Root != "" { + t.Errorf("root = %q, want empty (no serving for URL targets)", got.Root) + } + }) + } +} + +func TestTargetURL_presentForms(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "mock.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + ft, err := ParseTarget(file, "/") + if err != nil { + t.Fatal(err) + } + if u, want := ft.URL("@7", "dev", fixedNow), "/present/@7/mock.html?server=dev&v=1700000000"; u != want { + t.Errorf("file url = %q, want %q", u, want) + } + if !ft.NeedsRoot() { + t.Error("file target NeedsRoot = false, want true") + } + + dt, err := ParseTarget(dir, "/") + if err != nil { + t.Fatal(err) + } + if u, want := dt.URL("@12", "default", fixedNow), "/present/@12/?server=default&v=1700000000"; u != want { + t.Errorf("dir url = %q, want %q", u, want) + } + + // Port/URL targets carry no buster and no root. + pt, err := ParseTarget(":5173", "/") + if err != nil { + t.Fatal(err) + } + if pt.NeedsRoot() { + t.Error("port target NeedsRoot = true, want false") + } + if u := pt.URL("@7", "dev", fixedNow); strings.Contains(u, "v=") { + t.Errorf("port url %q carries a cache-buster, want none", u) + } +} + +// TestTargetURL_rePresentBumpsOnlyV pins the refresh-verb contract: two +// invocations of the same file target differ ONLY in the v= value. +func TestTargetURL_rePresentBumpsOnlyV(t *testing.T) { + tgt := Target{Kind: KindFile, Root: "/x", Name: "a b.html"} + first := tgt.URL("@7", "dev", func() int64 { return 100 }) + second := tgt.URL("@7", "dev", func() int64 { return 200 }) + if first == second { + t.Fatal("re-present produced identical URLs — the buster must change") + } + if strings.TrimSuffix(first, "100") != strings.TrimSuffix(second, "200") { + t.Errorf("URLs differ beyond v=: %q vs %q", first, second) + } + if !strings.Contains(first, "a%20b.html") { + t.Errorf("file basename not path-escaped: %q", first) + } +} + +func TestProbePort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + if err := ProbePort(context.Background(), port); err != nil { + t.Errorf("ProbePort on a listening port: %v", err) + } + + // 59999 is outside every ephemeral range in practice; guard against the + // freak collision by binding nothing and expecting refusal/timeout. + if err := ProbePort(context.Background(), 59999); err == nil { + t.Error("ProbePort on a dead port: err = nil, want failure") + } else if !strings.Contains(err.Error(), "59999") { + t.Errorf("probe error %q does not name the port", err) + } +} + +func TestNeedsProbe(t *testing.T) { + cases := map[Kind]bool{ + KindFile: false, + KindDir: false, + KindPort: true, + KindLocalURL: true, + KindExternalURL: false, + } + for k, want := range cases { + if got := (Target{Kind: k}).NeedsProbe(); got != want { + t.Errorf("Kind(%v).NeedsProbe() = %v, want %v", k, got, want) + } + } +} diff --git a/app/backend/internal/tmux/tmux.go b/app/backend/internal/tmux/tmux.go index f2893cf06..c82bae756 100644 --- a/app/backend/internal/tmux/tmux.go +++ b/app/backend/internal/tmux/tmux.go @@ -1617,6 +1617,20 @@ func UnsetWindowOption(ctx context.Context, windowID string, server, option stri return err } +// GetWindowOption reads a user-defined window option on the specified server — +// the read counterpart to SetWindowOption, via `show-options -w -qv`. Returns +// ("", nil) when the option is unset (tmux prints nothing with -qv). The call +// is bounded to the 5s short-tmux tier on top of the caller's context. +func GetWindowOption(ctx context.Context, windowID, server, option string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + raw, err := tmuxExecRawServer(ctx, server, "show-options", "-wqv", "-t", windowID, option) + if err != nil { + return "", err + } + return strings.TrimRight(raw, "\n"), nil +} + // roleCarriersFormat is the list-windows format for the @rk_role radio clear: // window id plus its current @rk_role value. var roleCarriersFormat = strings.Join([]string{"#{window_id}", "#{@rk_role}"}, listDelim) @@ -1750,6 +1764,30 @@ func CreateWindowWithOptions(session, name, cwd, server string, ops []WindowOpti return err } +// CreateWindowWithOptionsID is CreateWindowWithOptions plus the new window's +// id, reported via `new-window -P -F '#{window_id}'`. Callers that must embed +// the fresh @N in a follow-up write (rk present --window composing a +// /present// URL) create with the creation-time options atomically, +// then apply the id-dependent options via SetWindowOptions. +func CreateWindowWithOptionsID(session, name, cwd, server string, ops []WindowOptionOp) (string, error) { + ctx, cancel := withTimeout() + defer cancel() + + args := []string{"new-window", "-P", "-F", "#{window_id}", "-a", "-t", ExactSessionTarget(session), "-n", name} + if cwd != "" { + args = append(args, "-c", cwd) + } + args = appendOptionOps(args, "", ops) + lines, err := tmuxExecServer(ctx, server, args...) + if err != nil { + return "", err + } + if len(lines) == 0 { + return "", fmt.Errorf("new-window -P returned no window id") + } + return lines[0], nil +} + // KillWindow kills a window by its window ID on the specified server. func KillWindow(windowID string, server string) error { ctx, cancel := withTimeout() diff --git a/app/backend/internal/tmux/tmux_test.go b/app/backend/internal/tmux/tmux_test.go index 40afb0119..6614c0949 100644 --- a/app/backend/internal/tmux/tmux_test.go +++ b/app/backend/internal/tmux/tmux_test.go @@ -1709,6 +1709,35 @@ func TestSetWindowOptions_chainedSetAndUnset(t *testing.T) { } } +// TestGetWindowOption_roundTrip verifies the getter reads back what +// SetWindowOption wrote and reports ("", nil) for an unset option, against a +// real tmux server. +func TestGetWindowOption_roundTrip(t *testing.T) { + server := withSessionOrderTmux(t) + id := windowID(t, server, "boot:0") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if v, err := GetWindowOption(ctx, id, server, "@rk_present_root"); err != nil || v != "" { + t.Errorf("unset option = (%q, %v), want (\"\", nil)", v, err) + } + + if err := SetWindowOption(ctx, id, server, "@rk_present_root", "/tmp/some dir/root"); err != nil { + t.Fatalf("SetWindowOption: %v", err) + } + if v, err := GetWindowOption(ctx, id, server, "@rk_present_root"); err != nil || v != "/tmp/some dir/root" { + t.Errorf("set option = (%q, %v), want (\"/tmp/some dir/root\", nil)", v, err) + } + + if err := UnsetWindowOption(ctx, id, server, "@rk_present_root"); err != nil { + t.Fatalf("UnsetWindowOption: %v", err) + } + if v, err := GetWindowOption(ctx, id, server, "@rk_present_root"); err != nil || v != "" { + t.Errorf("after unset = (%q, %v), want (\"\", nil)", v, err) + } +} + func TestMoveWindowToSession_movesAndPreservesID(t *testing.T) { server := withSessionOrderTmux(t) diff --git a/docs/memory/run-kit/architecture.md b/docs/memory/run-kit/architecture.md index d21ab9869..51257d2d6 100644 --- a/docs/memory/run-kit/architecture.md +++ b/docs/memory/run-kit/architecture.md @@ -8,7 +8,7 @@ type: memory run-kit is a web-based agent orchestration dashboard. In production, a single Go binary runs as a daemon in a dedicated tmux session: -1. **Go backend** (`app/backend/`, default port 3000) — single binary serving REST API, SSE, WebSocket terminal relay, and SPA static files on one port. Cobra CLI with subcommands: `serve` (default, with `-d`/`--restart`/`--stop` daemon flags), `update` (alias: `upgrade`), `doctor`, `status`, `url`, `skill`, `init-conf`, `riff`, `desktop` (macOS-only install/update/status for the Electron shell), `remote` (SSH-only remote hosts — bootstrap, tunnel, connect), `code-server` (the rk-owned editor install — install/start/update). Version info via `--version`/`-v` global flag (Cobra built-in) +1. **Go backend** (`app/backend/`, default port 3000) — single binary serving REST API, SSE, WebSocket terminal relay, and SPA static files on one port. Cobra CLI with subcommands: `serve` (default, with `-d`/`--restart`/`--stop` daemon flags), `update` (alias: `upgrade`), `doctor`, `status`, `url`, `skill`, `init-conf`, `riff`, `desktop` (macOS-only install/update/status for the Electron shell), `remote` (SSH-only remote hosts — bootstrap, tunnel, connect), `code-server` (the rk-owned editor install — install/start/update), `present` (one-verb "show this to the user" — attach a file/dir/port/URL target to a window's web tile). Version info via `--version`/`-v` global flag (Cobra built-in) In development, `just dev` runs two concurrent processes: - Vite dev server (`:RK_PORT`, default 3000) — HMR, proxies `/api/*`, `/relay/*`, `/proxy/*`, and `/code/*` to Go backend @@ -39,7 +39,8 @@ app/ desktop.go # desktop subcommand group — install/update/status for the Electron shell (macOS-only at runtime) remote.go # remote subcommand group — SSH-only remote hosts (add/connect/list/status/disconnect/remove) code_server.go # code-server subcommand group — install/start/update for the rk-owned editor install - internal/ # validate, config, tmux, sessions, settings, metrics, desktop, codeserver, remote + present.go # present subcommand — attach a file/dir/port/URL target to a window's @rk_url web tile + internal/ # validate, config, tmux, sessions, settings, metrics, desktop, codeserver, remote, present api/ # HTTP handlers — one file per resource domain router.go # chi router, CORS/logger/recovery middleware, route registration health.go # GET /api/health @@ -51,6 +52,7 @@ app/ state_ws.go # WS /ws/state — the state-socket handler + envelope protocol (260716-qf3j-state-socket; retired GET /api/sessions/stream) relay.go # WS /relay/{windowId} (resolves owning session via display-message) proxy.go # /proxy/{port}/* — reverse proxy for iframe windows, and the stable /code/* code-server route (shared prefix-parameterized proxy constructor: SetXForwarded, trailing-slash redirect) + present.go # /present/{windowId}/* — serves files under the window's @rk_present_root (read from tmux at request time; symlink-resolved containment) tmux_config.go # POST /api/tmux/reload-config servers.go # GET /api/servers, POST /api/servers, POST /api/servers/kill keybindings.go # GET /api/keybindings (curated tmux keybindings via list-keys + whitelist) @@ -108,7 +110,7 @@ Packages in `app/backend/internal/`: | Package | Responsibility | |---------|---------------| -| `internal/tmux` | All tmux operations via `os/exec.CommandContext` with argument slices; the package's own call sites wrap with `context.WithTimeout` (10s) via `withTimeout()`, while the exported `Run`/`RunOutput` runner core is deliberately caller-timeout-owned (see § tmux Runner Core). Commands target the dedicated `runkit` server via `-L runkit` prefix (built by `runkitPrefix()`); config path defaults to `~/.rk/tmux.conf`, overridable via `RK_TMUX_CONF` env var, resolved to absolute path at init. `ConfigPath()` getter exposes the resolved path. `OriginalTMUX` package-level var captures `$TMUX` before `init()` strips it (via `os.Unsetenv("TMUX")`) — used by `cmd/rk/riff.go` (and `agent_hook.go`) to restore `$TMUX` in child process environments when targeting the caller's own tmux server. `DefaultConfigBytes()` returns the embedded `internal/tmux/tmux.conf` content (via `go:embed`). Read-path functions accept `context.Context` for request cancellation: `ListSessions(ctx, server)`, `ListWindows(ctx, session, server)`, `ListServers(ctx)`. `ListServers` probes sockets in parallel using a bounded goroutine pool (cap 10, semaphore channel) — N dead sockets cost ~2s instead of 2*N seconds. Mutation functions (`CreateSession`, `KillSession`, `RenameSession`, `CreateWindow`, `CreateWindowWithOptions`, `KillWindow`, `MoveWindow`, `MoveWindowToSession`, `LinkWindowToSession`, `RenameWindow`, `SendKeys`, `SelectWindowInSession`, `SplitWindow`, `KillActivePane`, `SetWindowColor`, `UnsetWindowColor`, `SetSessionColor`, `UnsetSessionColor`, `SetWindowOptions`) use `withTimeout()` / `context.Background()`. There is no `KillPane` on the interface — panes are killed via the window id through `KillActivePane` (§IV bounds the surface). `MoveWindow` now emits its full bubble-swap sequence as a **single `\;`-chained `tmuxExecServer` invocation** (resolving the source index exactly once via `resolveWindowSessionIndex`) so no other mutation can interleave mid-reorder — concurrent kill/move observes only the pre- or post-reorder layout, never a partially-swapped intermediate. Insert-before / sentinel / single-step / no-op semantics are unchanged; the destination remains a positional index. `ListSessions(ctx, server)` queries the specified tmux server, returning `SessionInfo` structs with a `Name` field plus `Color *string json:"color,omitempty"` (per-session color-value descriptor from the `@session_color` user option — field 5 of the `list-sessions` format string — normalized via `validate.NormalizeColorValue`, nil when unset/malformed; set/cleared via `SetSessionColor(session, value, server)` / `UnsetSessionColor`, a distinct option name from window `@color` to avoid tmux option inheritance, since `260615-6rnr-expand-swatch-palette-blends`) plus `Windows int json:"windows"` (the session's window count from `#{session_windows}`, field 6). The `list-sessions` format string carries **6** tab-delimited fields — `#{session_name}`, `#{session_grouped}`, `#{session_group}`, `#{session_group_size}`, `#{@session_color}`, `#{session_windows}` — parsed by `parseSessions`; `Windows` is `0` on a missing/malformed 6th field. Group-copy sessions are dropped by `parseSessions`'s session-group filter before the structs are returned, so a `Windows` sum over the kept sessions counts each shared window once. `ReloadConfig(server)` hot-reloads the tmux config via `source-file` on the specified server. `ListWindows()` includes `isActiveWindow` flag from `#{window_active}`, `PaneCommand` from `#{pane_current_command}`, and raw `ActivityTimestamp` from `#{window_activity}`. After calling `list-windows`, `ListWindows()` issues a second `list-panes -s -t ` call to populate `Panes []PaneInfo` on each `WindowInfo`; pane failure is non-fatal (windows returned with empty `Panes`). `WorktreePath` continues to be sourced from `#{pane_current_path}` in the `list-windows` format string — `list-panes` is additive. `PaneInfo` struct: `PaneID string json:"paneId"`, `PaneIndex int json:"paneIndex"`, `Cwd string json:"cwd"`, `Command string json:"command"`, `IsActive bool json:"isActive"`, `GitBranch string json:"gitBranch,omitempty"` (populated by the sessions package's `resolveGitBranches` enrichment, not by tmux), `CwdMissing bool json:"cwdMissing,omitempty"` (true when `Cwd` no longer exists on disk; populated by the sessions package's `resolveCwdMissing` enrichment — `260614-nj74-sidebar-cwd-deleted-marker`), and (since `260705-dmex-generic-agent-state-tier`) `AgentState string json:"agentState,omitempty"` (`active|waiting|idle`, empty = unknown) + `AgentStateEpoch int64 json:"agentStateEpoch,omitempty"` (0 = unknown) — parsed natively from the `@rk_agent_state` pane option (const `tmux.AgentStateOption`) — and (since `260713-nh86-chat-session-identity`) `ChatProvider string json:"chatProvider,omitempty"` + `ChatSessionRef string json:"chatSessionRef,omitempty"` — the pre-split halves of the `@rk_chat` pane option (const `tmux.ChatOption`), parsed via `parseChatRef` and zeroed by the same reconciler that governs the agent fields. Unlike the two enrichment-assigned fields these four ARE set in `tmux.go`'s `parsePanes`. Package-level `paneFormat` var joins **8** fields with `listDelim`: `#{window_index}`, `#{pane_id}`, `#{pane_index}`, `#{pane_current_path}`, `#{pane_current_command}`, `#{pane_active}`, `#{@rk_agent_state}` (added by `260705-dmex`), `#{@rk_chat}` (the 8th, added by `260713-nh86-chat-session-identity` — zero extra subprocess, both option fields ride the existing `list-panes` call). `parsePanes(lines []string) map[int][]PaneInfo` parses **8**-field tab-delimited `list-panes` output (lines with `< 8` fields skipped, up from 7), grouping by window index (field 0); it parses field 6 via the pure `parseAgentState(raw) (state string, epoch int64, pid int)` helper (splits on the last `:`, validates `state ∈ {active,waiting,idle}` via `isAgentState` + integer epoch + optional pid segment, else zeros) and field 7 via `parseChatRef(raw) (provider, ref string)` (first-colon split, `isChatProvider`/`isChatRef` validation, else `("","")` — see [agent-state](agent-state.md) § Chat Session Identity). Since `260713-nh86` both fields share a single **reconciler** decision (`stale`): a pid-carrying agent-state is stale when `!agentProcessAlive(pid)`, else the legacy **shell-command** fallback — `isShellCommand(cmd)` over `shellCommands = {bash,zsh,fish,sh,dash}` — decides; when stale, **both** the agent-state fields AND the chat fields are zeroed (a dead/killed agent auto-clears its stranded `active` — the guppi lesson — and never leaves a live-looking chat ref, plan risk #4). Malformed lines silently skipped; returns nil on empty/all-malformed input. The convention strings (`AgentStateOption`, `AgentStateActive`/`Waiting`/`Idle`) are the single source of truth, aliased by `cmd/rk/agent_setup.go`. See [agent-state](agent-state.md). `WindowInfo` struct uses `FabChange`/`FabStage` fields, plus `AgentState string json:"agentState,omitempty"` (`active|waiting|idle`) + `AgentIdleDuration string json:"agentIdleDuration,omitempty"` — these keep their JSON names but since `260705-dmex` are a **window-level rollup** over the panes' `@rk_agent_state` (`waiting > active > idle`, rk-formatted duration), computed in `internal/sessions`, NOT joined from `fab pane map`. Plus (since `260713-nh86-chat-session-identity`) `ChatProvider string json:"chatProvider,omitempty"` + `ChatSessionRef string json:"chatSessionRef,omitempty"` — the window-level chat rollup (active pane's chat if set, else the first pane carrying one) computed rk-side in `internal/sessions` by the pure `rollupChat(panes)`, NOT parsed here (`tmux.go` declares them; `internal/sessions` assigns them, mirroring the agent-state rollup). Plus `Color *string json:"color,omitempty"` (color-value descriptor — `"4"` single index or `"1+3"` blend — from the tmux `@color` user option, normalized via `validate.NormalizeColorValue`, nil when unset/malformed; was `*int` before `260615-6rnr-expand-swatch-palette-blends`), plus `Panes []PaneInfo json:"panes,omitempty"`, plus `RkType string json:"rkType,omitempty"` and `RkUrl string json:"rkUrl,omitempty"` for iframe window metadata (populated from tmux user-defined options `@rk_type` and `@rk_url` via `ListWindows` format string), plus six PR-status fields (`260610-596o-pr-status-sidebar`): `PrURL *string json:"prUrl,omitempty"` and `PrNumber *int json:"prNumber,omitempty"` (since `260705-dmex` **derived server-side from the pane's branch** via the `internal/prstatus` branch refresher, NOT from `fab pane map` — see § Branch→PR Derivation), and `PrState string json:"prState,omitempty"` / `PrChecks string json:"prChecks,omitempty"` / `PrReview string json:"prReview,omitempty"` / `PrIsDraft bool json:"prIsDraft,omitempty"` (attached at SSE-assembly time by the hub from the `internal/prstatus` viewer-wide collector snapshot, for **any** window with a derived `PrURL` since `260705-dmex` dropped the change-bound gate — see § PR-Status SSE Join). The PR/agent-rollup fields are populated outside this package; `tmux.go` declares them (and the two `PaneInfo` agent fields, which it also assigns). `EnsureConfig()` writes the embedded default config to `~/.rk/tmux.conf` if absent and creates `~/.rk/tmux.d/` (called at serve startup; directory creation runs even when config already exists). `ListKeys(server)` runs `tmux list-keys` on a server and returns raw output lines (returns nil on "no server running"). The `-f configPath` flag is scoped to `CreateSession` and `ReloadConfig` only via `configArgs()` — not passed on every command. Both `tmuxExec` and `tmuxExecDefault` capture stderr in error messages for diagnostics. Window-*targeting* functions take a `windowID string` (`@N`) passed directly as the `-t` target — `KillWindow(windowID, server)`, `RenameWindow(windowID, name, server)`, `SelectWindowInSession(session, windowID, server)`, `SplitWindow(windowID, horizontal, cwd, server)`, `SendKeys(windowID, keys, server)`, `KillActivePane(windowID, server)`, `SetWindowColor`/`UnsetWindowColor(windowID, …)`, `SetWindowOption(ctx, windowID, server, option, value)`/`UnsetWindowOption(ctx, windowID, server, option)` — no `session:index` target string is constructed (since `260529-chgz-window-id-routing`). `SelectWindowInSession` is the *session-scoped* select (`select-window -t :@N`) used by both the relay (scoped to whichever session the relay attaches — the `_rk-pin-*` pin-session when the window is pinned, else the resolved home session, since `260718-co9z`; `260718-co9z`) and, the REST `/select` handler — a bare `select-window -t @N` is ambiguous inside a tmux session group (members share window membership but keep independent active-window state) so the scoped form is mandatory for correctness. Positional ops keep an index destination: `MoveWindow(windowID, dstIndex, server)` resolves the source's current index from the ID (via `resolveWindowSessionIndex`) then chains the bubble-swap sequence to the target slot as one atomic `\;`-chained invocation; `MoveWindowToSession(windowID, dstSession, server)` runs `move-window -s -t :`; `LinkWindowToSession(windowID, dstSession, server)` runs `link-window -s -t =:` (dual membership — the window stays in its source; the board pin layer's mechanism since `260718-co9z-link-based-board-pinning`). `ResolveWindowSession(ctx, server, windowID)` resolves the window's **HOME (non-pin) session**: a targeted `display-message -t -p '#{session_name}'` fast path, re-resolved via the unexported `resolveHomeSession` (`list-windows -a`) when the naive result is a `_rk-pin-*` name (a pinned window is a member of two sessions under `260718-co9z`, so the naive lookup no longer identifies "exactly one session"; the pin-session is kept only when it is the window's sole link). `HasSession(ctx, server, session)` is the exact-match existence probe the relay's pin-session-first attach and `Unpin`'s recovery use. Used by the REST `/select`, `ProjectRoot`, `Pin`, and the relay's non-pinned fall-through. **Chained `set-option` primitive (since `260529-jad6`)**: `WindowOptionOp{Key string; Value *string}` is a single set-or-unset op (non-nil Value sets, nil unsets); `appendOptionOps(args, target, ops)` appends each op's `set-option -w [-u] [-t ] []` argv, prefixing a `;` chain separator before all but the first appended op (when `target` is empty, the `-t` qualifier is omitted — used by `CreateWindowWithOptions`, where the preceding `new-window` already scopes the chained set-options to the new window). `SetWindowOptions(ctx, windowID, server, ops []WindowOptionOp)` runs the whole batch as a single `\;`-chained `tmuxExecServer` invocation (atomic — the SSE poll never observes a half-applied state; empty ops issue no tmux call). `CreateWindowWithOptions(session, name, cwd, server, ops []WindowOptionOp)` creates a window and atomically sets its user-defined options via that same `appendOptionOps` primitive in one chained command (prevents SSE from seeing the window before metadata is set; no separate inline option-map construction path). `ListWindows` format string includes `#{@color}` as field 8, `#{@rk_type}` and `#{@rk_url}` as fields 9-10; `parseWindows` extracts them (empty string when unset). **Active-window seam helpers (since `260530-v6hm-active-window-event-derivation`)**: `ListSessionGroups(ctx, server) (map[$sid]group, error)` runs `list-sessions -F '#{session_id}#{session_name}#{session_group}'` and parses via `parseSessionGroups` into a `$sid`→group map (ungrouped sessions, which report an empty `#{session_group}`, fall back to their own name as the group key); `rk-relay-*`/`_rk-ctl` are NOT filtered here — they must resolve to their base session's group so an event fired against an ephemeral updates the right user-facing group. `ListActiveWindowsByGroup(ctx, server) (map[group]@wid, error)` runs `list-windows -a -F '#{session_group}#{session_name}#{window_id}#{window_active}'` and parses via `parseActiveWindowsByGroup` into a group→active-`@wid` snapshot for the Tier-1 re-seed; because `list-windows -a` reports `window_active=1` for *every* group member, only the leader row (`session_name == session_group`) is honored per group (ungrouped: own name; leaderless group: first active row as best-effort). Both follow the `tmuxExecServer` + `context.WithTimeout(ctx, TmuxTimeout)` + pure-parse-split convention (matching `parseWindows`/`parsePanes`), return `nil` (no error) when the server is not running, and never mutate sessions (§VI). **`CreateSession` env sanitization — direnv-diff reversal (since `260706-6mpm-direnv-diff-reversal-server-env`)**: `CreateSession` may *start* the tmux server (its `new-session -d` is the server's first client), so it runs the command under a sanitized environment AND a pinned working directory via `Run(ctx, full, RunOpts{Env: CleanEnvForServer(), Dir: ServerBirthDir()})` (the exported `CleanEnvForServer()` is shared cross-package with `tmuxctl.createAnchor`; the `dir` arg is the server-birth CWD pin — `260720-ji0k-pin-server-birth-cwd-home`, see `tmux-sessions.md` § Server-Birth CWD Pin). `CleanEnvForServer()` → `sanitizeEnv(os.Environ())` targets the semantics "as if the operator had started tmux from `$HOME`" — because an rk daemon started from inside the run-kit repo captures direnv's polluted env (`WORKTREE_INIT_SCRIPT`, `IDEAS_FILE`, `RK_PORT`, `RK_HOST`, plus `RK_DAEMON_LOG` and `DIRENV_*`) into the rk-daemon tmux server and, as the *first client* of a user server, would otherwise bake all of it into that server's global env. `sanitizeEnv` (1) **reverse-applies `DIRENV_DIFF`** via the colocated `internal/tmux/direnv.go` helper `reverseDirenvDiff` (parses direnv's env_diff — base64url decode tolerant of padded/unpadded, then `compress/zlib` inflate, then `encoding/json` unmarshal of `{"p":{...},"n":{...}}`; **Go stdlib only — no subprocess, no runtime direnv dependency**; reversal semantics: for each key in `n`, restore its `p` value if present else remove it (direnv added it), and for each key in `p` absent from `n` restore its `p` value (direnv removed it)) — this restores the operator's *true pre-direnv env, including PATH*; (2) strips **all** `RK_*`-prefixed vars (rk adds these post-direnv so reversal misses them; the whole prefix, not just `RK_DAEMON_LOG`); (3) strips **all** `DIRENV_*`-prefixed vars including `DIRENV_DIFF` itself (direnv excludes its own state vars from the diff, so reversal leaves them). The former unconditional POSIX-PATH reset is **retired** — `cleanPATH` (the POSIX default const) now survives ONLY as a last-resort guard: `PATH=cleanPATH` is injected iff no `PATH=` entry survives, never when a PATH is already present. **Fail-soft, always** (never fails `CreateSession` — `sanitizeEnv` returns `[]string` only, no error path): `DIRENV_DIFF` absent → pass-through + the `RK_*`/`DIRENV_*` strips; `DIRENV_DIFF` present-but-unparseable (bad base64/zlib/JSON) → a single `slog.Warn` then pass-through + strips. Only user servers **born by rk** (create-server API / session-create on a dead socket, both via `CreateSession`) have their **env** sanitized; the daemon's own launch (`internal/daemon.startSession`) keeps its **env** deliberately **untouched** so `config.Load()` still reads its `RK_PORT`/`RK_HOST` from process env (cleaning it would flip `RK_HOST` back to `127.0.0.1` and break Tailscale access). Its server-birth **CWD** IS pinned to home, though — a separate concern from env (`260720-ji0k-pin-server-birth-cwd-home`, see § Daemon Lifecycle): the daemon server carries rk's env but not rk's CWD. **Operational caveat**: already-running polluted servers keep their baked env — cleanup is operational (`tmux -L set-environment -gru VAR` per var, or a server restart), not code scope. **Follow-up caveat (`rk url` URL discovery)**: on rk-born servers, panes previously rode the `RK_PORT`/`RK_HOST` leak, so the `config.Load()` → `RK_HOST`/`RK_PORT` (default `127.0.0.1:3000`) server-URL derivation reported the deployment's real URL by accident; post-strip those panes fall back to the `127.0.0.1:3000` default on non-default deployments. (This derivation was carried by `rk context`'s `serverURL()` at the time; since `260718-icxz-skill-display-topic-url-retire-context` deleted `rk context`, it is now the new `rk url` subcommand — the caveat is unchanged, only the command that exposes the derivation moved.) The fix channel is a deliberate follow-up (e.g. `-e` injection at server birth, or an `@rk_url`-style tmux option), **not** re-leaking env | +| `internal/tmux` | All tmux operations via `os/exec.CommandContext` with argument slices; the package's own call sites wrap with `context.WithTimeout` (10s) via `withTimeout()`, while the exported `Run`/`RunOutput` runner core is deliberately caller-timeout-owned (see § tmux Runner Core). Commands target the dedicated `runkit` server via `-L runkit` prefix (built by `runkitPrefix()`); config path defaults to `~/.rk/tmux.conf`, overridable via `RK_TMUX_CONF` env var, resolved to absolute path at init. `ConfigPath()` getter exposes the resolved path. `OriginalTMUX` package-level var captures `$TMUX` before `init()` strips it (via `os.Unsetenv("TMUX")`) — used by `cmd/rk/riff.go` (and `agent_hook.go`) to restore `$TMUX` in child process environments when targeting the caller's own tmux server. `DefaultConfigBytes()` returns the embedded `internal/tmux/tmux.conf` content (via `go:embed`). Read-path functions accept `context.Context` for request cancellation: `ListSessions(ctx, server)`, `ListWindows(ctx, session, server)`, `ListServers(ctx)`, plus `GetWindowOption(ctx, windowID, server, option)` (`show-options -w -qv`, the read counterpart of the window-option setters, consumed by the `/present/` handler). `ListServers` probes sockets in parallel using a bounded goroutine pool (cap 10, semaphore channel) — N dead sockets cost ~2s instead of 2*N seconds. Mutation functions (`CreateSession`, `KillSession`, `RenameSession`, `CreateWindow`, `CreateWindowWithOptions`, `KillWindow`, `MoveWindow`, `MoveWindowToSession`, `LinkWindowToSession`, `RenameWindow`, `SendKeys`, `SelectWindowInSession`, `SplitWindow`, `KillActivePane`, `SetWindowColor`, `UnsetWindowColor`, `SetSessionColor`, `UnsetSessionColor`, `SetWindowOptions`, `CreateWindowWithOptionsID` — creation returning the fresh window id) use `withTimeout()` / `context.Background()`. There is no `KillPane` on the interface — panes are killed via the window id through `KillActivePane` (§IV bounds the surface). `MoveWindow` now emits its full bubble-swap sequence as a **single `\;`-chained `tmuxExecServer` invocation** (resolving the source index exactly once via `resolveWindowSessionIndex`) so no other mutation can interleave mid-reorder — concurrent kill/move observes only the pre- or post-reorder layout, never a partially-swapped intermediate. Insert-before / sentinel / single-step / no-op semantics are unchanged; the destination remains a positional index. `ListSessions(ctx, server)` queries the specified tmux server, returning `SessionInfo` structs with a `Name` field plus `Color *string json:"color,omitempty"` (per-session color-value descriptor from the `@session_color` user option — field 5 of the `list-sessions` format string — normalized via `validate.NormalizeColorValue`, nil when unset/malformed; set/cleared via `SetSessionColor(session, value, server)` / `UnsetSessionColor`, a distinct option name from window `@color` to avoid tmux option inheritance, since `260615-6rnr-expand-swatch-palette-blends`) plus `Windows int json:"windows"` (the session's window count from `#{session_windows}`, field 6). The `list-sessions` format string carries **6** tab-delimited fields — `#{session_name}`, `#{session_grouped}`, `#{session_group}`, `#{session_group_size}`, `#{@session_color}`, `#{session_windows}` — parsed by `parseSessions`; `Windows` is `0` on a missing/malformed 6th field. Group-copy sessions are dropped by `parseSessions`'s session-group filter before the structs are returned, so a `Windows` sum over the kept sessions counts each shared window once. `ReloadConfig(server)` hot-reloads the tmux config via `source-file` on the specified server. `ListWindows()` includes `isActiveWindow` flag from `#{window_active}`, `PaneCommand` from `#{pane_current_command}`, and raw `ActivityTimestamp` from `#{window_activity}`. After calling `list-windows`, `ListWindows()` issues a second `list-panes -s -t ` call to populate `Panes []PaneInfo` on each `WindowInfo`; pane failure is non-fatal (windows returned with empty `Panes`). `WorktreePath` continues to be sourced from `#{pane_current_path}` in the `list-windows` format string — `list-panes` is additive. `PaneInfo` struct: `PaneID string json:"paneId"`, `PaneIndex int json:"paneIndex"`, `Cwd string json:"cwd"`, `Command string json:"command"`, `IsActive bool json:"isActive"`, `GitBranch string json:"gitBranch,omitempty"` (populated by the sessions package's `resolveGitBranches` enrichment, not by tmux), `CwdMissing bool json:"cwdMissing,omitempty"` (true when `Cwd` no longer exists on disk; populated by the sessions package's `resolveCwdMissing` enrichment — `260614-nj74-sidebar-cwd-deleted-marker`), and (since `260705-dmex-generic-agent-state-tier`) `AgentState string json:"agentState,omitempty"` (`active|waiting|idle`, empty = unknown) + `AgentStateEpoch int64 json:"agentStateEpoch,omitempty"` (0 = unknown) — parsed natively from the `@rk_agent_state` pane option (const `tmux.AgentStateOption`) — and (since `260713-nh86-chat-session-identity`) `ChatProvider string json:"chatProvider,omitempty"` + `ChatSessionRef string json:"chatSessionRef,omitempty"` — the pre-split halves of the `@rk_chat` pane option (const `tmux.ChatOption`), parsed via `parseChatRef` and zeroed by the same reconciler that governs the agent fields. Unlike the two enrichment-assigned fields these four ARE set in `tmux.go`'s `parsePanes`. Package-level `paneFormat` var joins **8** fields with `listDelim`: `#{window_index}`, `#{pane_id}`, `#{pane_index}`, `#{pane_current_path}`, `#{pane_current_command}`, `#{pane_active}`, `#{@rk_agent_state}` (added by `260705-dmex`), `#{@rk_chat}` (the 8th, added by `260713-nh86-chat-session-identity` — zero extra subprocess, both option fields ride the existing `list-panes` call). `parsePanes(lines []string) map[int][]PaneInfo` parses **8**-field tab-delimited `list-panes` output (lines with `< 8` fields skipped, up from 7), grouping by window index (field 0); it parses field 6 via the pure `parseAgentState(raw) (state string, epoch int64, pid int)` helper (splits on the last `:`, validates `state ∈ {active,waiting,idle}` via `isAgentState` + integer epoch + optional pid segment, else zeros) and field 7 via `parseChatRef(raw) (provider, ref string)` (first-colon split, `isChatProvider`/`isChatRef` validation, else `("","")` — see [agent-state](agent-state.md) § Chat Session Identity). Since `260713-nh86` both fields share a single **reconciler** decision (`stale`): a pid-carrying agent-state is stale when `!agentProcessAlive(pid)`, else the legacy **shell-command** fallback — `isShellCommand(cmd)` over `shellCommands = {bash,zsh,fish,sh,dash}` — decides; when stale, **both** the agent-state fields AND the chat fields are zeroed (a dead/killed agent auto-clears its stranded `active` — the guppi lesson — and never leaves a live-looking chat ref, plan risk #4). Malformed lines silently skipped; returns nil on empty/all-malformed input. The convention strings (`AgentStateOption`, `AgentStateActive`/`Waiting`/`Idle`) are the single source of truth, aliased by `cmd/rk/agent_setup.go`. See [agent-state](agent-state.md). `WindowInfo` struct uses `FabChange`/`FabStage` fields, plus `AgentState string json:"agentState,omitempty"` (`active|waiting|idle`) + `AgentIdleDuration string json:"agentIdleDuration,omitempty"` — these keep their JSON names but since `260705-dmex` are a **window-level rollup** over the panes' `@rk_agent_state` (`waiting > active > idle`, rk-formatted duration), computed in `internal/sessions`, NOT joined from `fab pane map`. Plus (since `260713-nh86-chat-session-identity`) `ChatProvider string json:"chatProvider,omitempty"` + `ChatSessionRef string json:"chatSessionRef,omitempty"` — the window-level chat rollup (active pane's chat if set, else the first pane carrying one) computed rk-side in `internal/sessions` by the pure `rollupChat(panes)`, NOT parsed here (`tmux.go` declares them; `internal/sessions` assigns them, mirroring the agent-state rollup). Plus `Color *string json:"color,omitempty"` (color-value descriptor — `"4"` single index or `"1+3"` blend — from the tmux `@color` user option, normalized via `validate.NormalizeColorValue`, nil when unset/malformed; was `*int` before `260615-6rnr-expand-swatch-palette-blends`), plus `Panes []PaneInfo json:"panes,omitempty"`, plus `RkType string json:"rkType,omitempty"` and `RkUrl string json:"rkUrl,omitempty"` for iframe window metadata (populated from tmux user-defined options `@rk_type` and `@rk_url` via `ListWindows` format string), plus six PR-status fields (`260610-596o-pr-status-sidebar`): `PrURL *string json:"prUrl,omitempty"` and `PrNumber *int json:"prNumber,omitempty"` (since `260705-dmex` **derived server-side from the pane's branch** via the `internal/prstatus` branch refresher, NOT from `fab pane map` — see § Branch→PR Derivation), and `PrState string json:"prState,omitempty"` / `PrChecks string json:"prChecks,omitempty"` / `PrReview string json:"prReview,omitempty"` / `PrIsDraft bool json:"prIsDraft,omitempty"` (attached at SSE-assembly time by the hub from the `internal/prstatus` viewer-wide collector snapshot, for **any** window with a derived `PrURL` since `260705-dmex` dropped the change-bound gate — see § PR-Status SSE Join). The PR/agent-rollup fields are populated outside this package; `tmux.go` declares them (and the two `PaneInfo` agent fields, which it also assigns). `EnsureConfig()` writes the embedded default config to `~/.rk/tmux.conf` if absent and creates `~/.rk/tmux.d/` (called at serve startup; directory creation runs even when config already exists). `ListKeys(server)` runs `tmux list-keys` on a server and returns raw output lines (returns nil on "no server running"). The `-f configPath` flag is scoped to `CreateSession` and `ReloadConfig` only via `configArgs()` — not passed on every command. Both `tmuxExec` and `tmuxExecDefault` capture stderr in error messages for diagnostics. Window-*targeting* functions take a `windowID string` (`@N`) passed directly as the `-t` target — `KillWindow(windowID, server)`, `RenameWindow(windowID, name, server)`, `SelectWindowInSession(session, windowID, server)`, `SplitWindow(windowID, horizontal, cwd, server)`, `SendKeys(windowID, keys, server)`, `KillActivePane(windowID, server)`, `SetWindowColor`/`UnsetWindowColor(windowID, …)`, `SetWindowOption(ctx, windowID, server, option, value)`/`UnsetWindowOption(ctx, windowID, server, option)` — no `session:index` target string is constructed (since `260529-chgz-window-id-routing`). `SelectWindowInSession` is the *session-scoped* select (`select-window -t :@N`) used by both the relay (scoped to whichever session the relay attaches — the `_rk-pin-*` pin-session when the window is pinned, else the resolved home session, since `260718-co9z`; `260718-co9z`) and, the REST `/select` handler — a bare `select-window -t @N` is ambiguous inside a tmux session group (members share window membership but keep independent active-window state) so the scoped form is mandatory for correctness. Positional ops keep an index destination: `MoveWindow(windowID, dstIndex, server)` resolves the source's current index from the ID (via `resolveWindowSessionIndex`) then chains the bubble-swap sequence to the target slot as one atomic `\;`-chained invocation; `MoveWindowToSession(windowID, dstSession, server)` runs `move-window -s -t :`; `LinkWindowToSession(windowID, dstSession, server)` runs `link-window -s -t =:` (dual membership — the window stays in its source; the board pin layer's mechanism since `260718-co9z-link-based-board-pinning`). `ResolveWindowSession(ctx, server, windowID)` resolves the window's **HOME (non-pin) session**: a targeted `display-message -t -p '#{session_name}'` fast path, re-resolved via the unexported `resolveHomeSession` (`list-windows -a`) when the naive result is a `_rk-pin-*` name (a pinned window is a member of two sessions under `260718-co9z`, so the naive lookup no longer identifies "exactly one session"; the pin-session is kept only when it is the window's sole link). `HasSession(ctx, server, session)` is the exact-match existence probe the relay's pin-session-first attach and `Unpin`'s recovery use. Used by the REST `/select`, `ProjectRoot`, `Pin`, and the relay's non-pinned fall-through. **Chained `set-option` primitive (since `260529-jad6`)**: `WindowOptionOp{Key string; Value *string}` is a single set-or-unset op (non-nil Value sets, nil unsets); `appendOptionOps(args, target, ops)` appends each op's `set-option -w [-u] [-t ] []` argv, prefixing a `;` chain separator before all but the first appended op (when `target` is empty, the `-t` qualifier is omitted — used by `CreateWindowWithOptions`, where the preceding `new-window` already scopes the chained set-options to the new window). `SetWindowOptions(ctx, windowID, server, ops []WindowOptionOp)` runs the whole batch as a single `\;`-chained `tmuxExecServer` invocation (atomic — the SSE poll never observes a half-applied state; empty ops issue no tmux call). `CreateWindowWithOptions(session, name, cwd, server, ops []WindowOptionOp)` creates a window and atomically sets its user-defined options via that same `appendOptionOps` primitive in one chained command (prevents SSE from seeing the window before metadata is set; no separate inline option-map construction path). `ListWindows` format string includes `#{@color}` as field 8, `#{@rk_type}` and `#{@rk_url}` as fields 9-10; `parseWindows` extracts them (empty string when unset). **Active-window seam helpers (since `260530-v6hm-active-window-event-derivation`)**: `ListSessionGroups(ctx, server) (map[$sid]group, error)` runs `list-sessions -F '#{session_id}#{session_name}#{session_group}'` and parses via `parseSessionGroups` into a `$sid`→group map (ungrouped sessions, which report an empty `#{session_group}`, fall back to their own name as the group key); `rk-relay-*`/`_rk-ctl` are NOT filtered here — they must resolve to their base session's group so an event fired against an ephemeral updates the right user-facing group. `ListActiveWindowsByGroup(ctx, server) (map[group]@wid, error)` runs `list-windows -a -F '#{session_group}#{session_name}#{window_id}#{window_active}'` and parses via `parseActiveWindowsByGroup` into a group→active-`@wid` snapshot for the Tier-1 re-seed; because `list-windows -a` reports `window_active=1` for *every* group member, only the leader row (`session_name == session_group`) is honored per group (ungrouped: own name; leaderless group: first active row as best-effort). Both follow the `tmuxExecServer` + `context.WithTimeout(ctx, TmuxTimeout)` + pure-parse-split convention (matching `parseWindows`/`parsePanes`), return `nil` (no error) when the server is not running, and never mutate sessions (§VI). **`CreateSession` env sanitization — direnv-diff reversal (since `260706-6mpm-direnv-diff-reversal-server-env`)**: `CreateSession` may *start* the tmux server (its `new-session -d` is the server's first client), so it runs the command under a sanitized environment AND a pinned working directory via `Run(ctx, full, RunOpts{Env: CleanEnvForServer(), Dir: ServerBirthDir()})` (the exported `CleanEnvForServer()` is shared cross-package with `tmuxctl.createAnchor`; the `dir` arg is the server-birth CWD pin — `260720-ji0k-pin-server-birth-cwd-home`, see `tmux-sessions.md` § Server-Birth CWD Pin). `CleanEnvForServer()` → `sanitizeEnv(os.Environ())` targets the semantics "as if the operator had started tmux from `$HOME`" — because an rk daemon started from inside the run-kit repo captures direnv's polluted env (`WORKTREE_INIT_SCRIPT`, `IDEAS_FILE`, `RK_PORT`, `RK_HOST`, plus `RK_DAEMON_LOG` and `DIRENV_*`) into the rk-daemon tmux server and, as the *first client* of a user server, would otherwise bake all of it into that server's global env. `sanitizeEnv` (1) **reverse-applies `DIRENV_DIFF`** via the colocated `internal/tmux/direnv.go` helper `reverseDirenvDiff` (parses direnv's env_diff — base64url decode tolerant of padded/unpadded, then `compress/zlib` inflate, then `encoding/json` unmarshal of `{"p":{...},"n":{...}}`; **Go stdlib only — no subprocess, no runtime direnv dependency**; reversal semantics: for each key in `n`, restore its `p` value if present else remove it (direnv added it), and for each key in `p` absent from `n` restore its `p` value (direnv removed it)) — this restores the operator's *true pre-direnv env, including PATH*; (2) strips **all** `RK_*`-prefixed vars (rk adds these post-direnv so reversal misses them; the whole prefix, not just `RK_DAEMON_LOG`); (3) strips **all** `DIRENV_*`-prefixed vars including `DIRENV_DIFF` itself (direnv excludes its own state vars from the diff, so reversal leaves them). The former unconditional POSIX-PATH reset is **retired** — `cleanPATH` (the POSIX default const) now survives ONLY as a last-resort guard: `PATH=cleanPATH` is injected iff no `PATH=` entry survives, never when a PATH is already present. **Fail-soft, always** (never fails `CreateSession` — `sanitizeEnv` returns `[]string` only, no error path): `DIRENV_DIFF` absent → pass-through + the `RK_*`/`DIRENV_*` strips; `DIRENV_DIFF` present-but-unparseable (bad base64/zlib/JSON) → a single `slog.Warn` then pass-through + strips. Only user servers **born by rk** (create-server API / session-create on a dead socket, both via `CreateSession`) have their **env** sanitized; the daemon's own launch (`internal/daemon.startSession`) keeps its **env** deliberately **untouched** so `config.Load()` still reads its `RK_PORT`/`RK_HOST` from process env (cleaning it would flip `RK_HOST` back to `127.0.0.1` and break Tailscale access). Its server-birth **CWD** IS pinned to home, though — a separate concern from env (`260720-ji0k-pin-server-birth-cwd-home`, see § Daemon Lifecycle): the daemon server carries rk's env but not rk's CWD. **Operational caveat**: already-running polluted servers keep their baked env — cleanup is operational (`tmux -L set-environment -gru VAR` per var, or a server restart), not code scope. **Follow-up caveat (`rk url` URL discovery)**: on rk-born servers, panes previously rode the `RK_PORT`/`RK_HOST` leak, so the `config.Load()` → `RK_HOST`/`RK_PORT` (default `127.0.0.1:3000`) server-URL derivation reported the deployment's real URL by accident; post-strip those panes fall back to the `127.0.0.1:3000` default on non-default deployments. (This derivation was carried by `rk context`'s `serverURL()` at the time; since `260718-icxz-skill-display-topic-url-retire-context` deleted `rk context`, it is now the new `rk url` subcommand — the caveat is unchanged, only the command that exposes the derivation moved.) The fix channel is a deliberate follow-up (e.g. `-e` injection at server birth, or an `@rk_url`-style tmux option), **not** re-leaking env | | `internal/sessions` | `FetchSessions(ctx, server, provider)` lists sessions and fetches windows for all of them in parallel for the requested tmux server `server` (passing `server` to `ListWindows`), then enriches with fab state and applies the two-tier active-window derivation. The `provider` (`ActiveWindowProvider` interface, satisfied by the tmuxctl `Supervisor`; injected via `prodSessionFetcher` in `api/router.go`) supplies the event-tracked active `@wid` per group — a `nil` provider degrades to exactly today's base-pointer behavior. The pure helper `applyActiveWindow(windows, trackedWid)` enforces the single-highlight invariant. Full design in § Active-Window Event Derivation (since `260530-v6hm-active-window-event-derivation`). `ProjectSession` struct has `Name`, `SessionColor *string json:"sessionColor,omitempty"`, and `Windows` fields. `SessionColor` is simply `sd.info.Color` — the per-session color-value descriptor parsed from the tmux `@session_color` option by `tmux.ListSessions`/`parseSessions` (NOT read from `run-kit.yaml`; the `config.ReadSessionColor` path is dead, see § Data Model). Per-window enrichment model: pane-map returns per-pane **fab tier proper** (change/stage/display_state). `keyPaneEntries` builds a fetch-time map keyed by the stable tmux **pane ID** (`paneMapEntry.Pane`, e.g. `"%12"`), one entry per pane and no fetch-time window dedup, then the pure `joinPaneMapByWindow` attributes each window of the fresh `WindowInfo` snapshot by walking its panes and looking each `PaneID` up in that map (Change > first-seen among a window's candidate panes), landing the winner in `enrichByWindowID[WindowID]` — the pane ID travels with its window across reorder/move, so a stale cached map never misattributes fab state (since `260713-d07t-pane-map-join-by-pane-id` (`260713-d07t-pane-map-join-by-pane-id`). `ProjectRoot(ctx, windowID, server)` likewise identifies the target window by `WindowID` (resolving the owning session via `tmux.ResolveWindowSession` first). Since `260705-dmex-generic-agent-state-tier` `paneMapEntry` carries only `Session`/`WindowIndex`/`Pane`/`Tab`/`Worktree` plus the nullable `*string` fab fields `Change`/`Stage`/`DisplayState` — the `agent_state`/`agent_idle_duration`/`pr_url`/`pr_number` fields were **dropped** (agent state now comes from the `@rk_agent_state` pane option; PR links are derived from the branch — see below and § Branch→PR Derivation), and the candidate-pane selection simplified to **Change > first-seen** (this selection runs in `joinPaneMapByWindow` among a window's fresh candidate panes, not in a fetch-time dedup). **Window-level agent-state rollup** (`260705-dmex`): the enrichment loop calls the pure `rollupAgentState(panes, nowUnix) (state, duration)` per window — precedence `waiting > active > idle` via `agentStatePrecedence`, with the idle/waiting duration computed rk-side from the winning pane's `AgentStateEpoch` via `formatAgentDuration` (fab's `Ns`/`Nm`/`Nh` floor-division style, byte-compatible) — and assigns `WindowInfo.AgentState`/`AgentIdleDuration`. **Window-level chat rollup** (`260713-nh86-chat-session-identity`): the same enrichment loop calls the pure `rollupChat(panes) (provider, ref string)` per window — the active pane's reconciled `@rk_chat` if set, else the first pane (in tmux order) carrying one, `("","")` when none — and assigns `WindowInfo.ChatProvider`/`ChatSessionRef` beside the agent rollup; per-pane chat truth is preserved on the `PaneInfo` entries, and both ride the existing `ProjectSession` marshal to `GET /api/sessions` + SSE `event: sessions` (no new endpoint/event — see [agent-state](agent-state.md) § Chat Session Identity). **Branch→PR enrichment** (`260705-dmex`): `enrichWindowPR(&w)` picks the window's `(repoDir, branch)` via `windowBranchRepo` (the active pane's cwd/branch, else the first pane with a resolved branch), then does ZERO network work on this hot path — it `prstatus.Register(repoDir, branch)`s the pair with the background refresher and `prstatus.SnapshotBranchPR(repoDir, branch)`s the last-good derived PR from the in-memory snapshot, assigning `WindowInfo.PrURL`/`PrNumber` (nil when no branch / unresolved / no open PR / gh absent). **Per-pane cwd-existence enrichment** (`260614-nj74-sidebar-cwd-deleted-marker`): the same per-pane loop that joins git branches (`resolveGitBranches`) also calls `resolveCwdMissing(allCwds) map[string]bool` and sets `PaneInfo.CwdMissing = true` when a pane's cwd no longer exists on disk — the orphan-worktree case where an archived change's worktree was deleted out from under a still-live tmux pane. `resolveCwdMissing` follows the same general TTL-cache pattern as `resolveGitBranches` (it does not replicate that function's per-call resolve limit or ctx-cancellation checks — an `os.Stat` is cheaper than git resolution and the loop is bounded by the distinct pane cwds): a package-level TTL cache (`cwdExistsCache`/`cwdExistsCacheMu`, `cwdExistsTTL = 10s`, keyed by cwd) fronts a cheap `os.Stat` so the SSE tick doesn't stat every pane every poll. It flags **only** the unambiguous `errors.Is(err, fs.ErrNotExist)` case — any other stat error (permissions, races) is treated as present to avoid false "(deleted)" markers — and skips empty cwds. The marker is self-healing: once the shell's cwd recovers to a real directory and tmux reports it, the next stat succeeds and the flag clears. `DisplayState *string` (`json:"display_state"`, populated by fab ≥ 2.1.7; values `active`/`ready`/`done`/`failed`/`pending`/`skipped`; nil on JSON `null` or absent key) joins to `WindowInfo.FabDisplayState string` (`json:"fabDisplayState,omitempty"`) via `derefStr` alongside `FabStage` in the same join — consumed by the sidebar's quiet parked-row policy (stage text suppressed when `done`; `260612-epqk-display-state-quiet-rows`). `fetchPaneMapCached(server)` wraps `fetchPaneMap(server)` with a 5s TTL cache keyed per tmux server label (package-level `sync.RWMutex`, double-check pattern). On cache miss, calls `fetchPaneMap(server)` which invokes the `fab` router on PATH (`fab pane map --json --all-sessions`, prefixed with `-L ` when server is non-empty) under a 10s context timeout with `cmd.Dir` set to a freshly-created private (0700) `MkdirTemp` dir (removed after the call; creation failure degrades to the inherited CWD) — a deliberately project-free CWD so the router falls back to the globally-installed fab rather than any one project's pinned `fab_version`, because `pane map --all-sessions` is a cross-project query and a single project pinned to an older fab would silently strip newer pane-map fields (e.g. `display_state`) from EVERY window on the server. The `keyPaneEntries(entries) map[string]paneMapEntry` helper keys the fetch map by stable pane ID (one entry per pane, empty-`Pane` entries dropped — the legacy The Change > first-seen selection runs in `joinPaneMapByWindow` at join time (`260713-d07t-pane-map-join-by-pane-id`). Stale cache entry preserved (per server) on fetch error for graceful degradation; if pane-map fails on cold start, all windows get empty fab fields | | `internal/validate` | Input validation for names/paths + tilde expansion with `$HOME` security boundary + filename sanitization for uploads. **Three closed-set validators**, the single backend source of truth for the accepted vocabularies (shared by the window/session/server color handlers and the `@rk_marker`/`@rk_role` option writes): (1) **marker** — `MarkerValues = {"", "dotted", "dashed", "solid", "double", "thick"}`; `ValidateMarkerValue` accepts exactly those (case-sensitive, no whitespace tolerance — `"Dashed"`, `"THICK"`, `" thick "` all reject) with error copy `"Marker must be one of: dotted, dashed, solid, double, thick (or empty to clear)"`. (2) **role** — `RoleValues = {"", "operator"}`; `ValidateRoleValue` accepts exactly those (empty = unset) with error copy `"Role must be one of: operator (or empty to clear)"` — the single shared role-value rule reused by the window-option handler and the `rk role` CLI. (3) **color** — `ValidateColorValue` / `NormalizeColorValue` accept THREE coexisting vocabularies: the legacy **numeric/blend** grammar (single ANSI index `0–15`, or `a+b` two-hue blend each `0–15` — valid forever, both read and write, zero migration), the 10 owned-family **names** (`red`…`slate`), and their **`-dark`** variants (`blue-dark`, the only dark-shade storage form — normal shades still write the legacy descriptor). Family-name values normalize to their trimmed verbatim form; out-of-vocabulary tokens (`"blue-light"`, `"bluish"`) reject. This is the single validation source for the frontend picker's family-name write vocabulary — a `"{family}"` / `"{family}-dark"` write validates here (see ui-patterns.md § Color Tinting). **Two SSH-remote validators** back `rk remote` (see [remote-hosts](/run-kit/remote-hosts.md)): `ValidateRemoteName` layers a leading-`-` and `/` rejection over the shared tmux-safe `ValidateNewName` (the name becomes a tmux window name and a `-t` target segment), and `ValidateRemoteTarget` applies the `ValidateSSHHost` rules plus the same leading-`-` rejection so a hostile target can never be parsed as an ssh option. Both run on the store's WRITE path (`rk remote add`) and again on its READ path (`remote.Load`) | | `internal/config` | Server config (port, host) — reads `RK_PORT` and `RK_HOST` env vars with defaults (3000, 127.0.0.1), plus the optional `RK_CODE_SERVER_PORT` override → `Config.CodeServerPort` (validated numeric 1–65535; unset/invalid ⇒ 0 = unset — never read directly) and the shared resolution rule `Config.ResolvedCodeServerPort()` (a valid preset wins, else the `RK_PORT+2` convention, else 0 on a degenerate `RK_PORT` — the ONE rule consumed by the daemon's code-server spawn, the `/code` proxy, the SSE probe, and doctor; the port is a private detail behind the stable `/code/` pathname — § System Overview, § Daemon Lifecycle). Also provides `run-kit.yaml` operations via `internal/config/runkit_yaml.go`: `FindGitRoot(dir string) string` walks up from dir to find `.git` (returns `""` if not found) — live callers in `internal/sessions` + `ProjectRoot`. The session-color helpers `ReadSessionColor(projectRoot string) *int` / `WriteSessionColor(projectRoot string, color *int) error` / `parseSessionColor` are **DEAD** (zero non-test callers): session color moved to the tmux `@session_color` option, and `260615-6rnr-expand-swatch-palette-blends` left them at `*int` rather than reviving them (flagged as deletion candidates). Uses simple line-by-line YAML parsing via `splitYAMLLine` — no yaml.v3 dependency | @@ -127,6 +129,7 @@ Packages in `app/backend/internal/`: | `internal/remote` | **SSH-only remote-host subsystem** backing the `rk remote` command family — full contract in [remote-hosts](/run-kit/remote-hosts.md). `store.go` owns `~/.config/rk/remotes.yaml` (schema `version: 1`, entries `{name, target, local_port}`, tolerant `Load` with read-path re-validation of every entry via `internal/validate`, `Save` through the shared `internal/fsatomic.WriteFile`, name/target lookups); `ports.go` assigns the immutable local port from the reserved 3100–3199 range against the store plus `ports.ListeningNow`; `name.go` derives a default name from the target's host token offline; `ssh.go` holds the fixed-literal remote commands (each prefixed with a brew/linuxbrew PATH augmentation), the BatchMode+ConnectTimeout probe args, exit-255/exit-127 classification, version parse, and the `updatecheck.AnyIncrease`-backed skew decision; `tunnel.go` owns the byte-exact `ssh -N -L` argv and the tunnel-window lifecycle on `daemon.ServerSocket` (see § Daemon Lifecycle → the `rk-remotes` sibling session); `connect.go`/`status.go` orchestrate the idempotent connect and the derived per-remote state for `list`/`status`. Subprocess work is isolated behind package-level seam vars (`runCmdFn`, `tmuxRunFn`/`tmuxOutputFn`, `dialFn` — the `findPortOwner`/`innerServePIDFn` idiom) so the whole orchestration unit-tests with no ssh, no tmux, and no sockets | | `internal/snapshot` | **Per-tmux-server layout snapshots + restore** — full contract in [layout-snapshots](/run-kit/layout-snapshots.md). `snapshot.go` defines the JSON schema (`Snapshot`/`Session`/`Window`/`Pane`) and `CaptureServer(ctx, server)` (assembled from the `internal/tmux` layout reads via injectable var seams); `store.go` owns `$XDG_STATE_HOME/rk/snapshots` (atomic latest, 10-entry rolling history, content-dedup ignoring `takenAt`, zero-session write guard, `{server}.died-{ts}.json` tombstones); `snapshotter.go` is the serve-process writer over a `ServerSource` (the tmuxctl Supervisor's `Sockets()`/`Generation()`); `restore.go` is the restore engine behind a `restoreOps` seam. Snapshots are **write-only** at runtime (Constitution §II) — the only reader is `cmd/rk/snapshot.go` | | `internal/fsatomic` | **Crash-safe file writes** — one exported `WriteFile(path, data, perm)`: temp file in the same directory, write, atomic rename, temp removed on any failure, so readers see either the old contents or the complete new contents. `perm` applies at file **creation** (`O_CREATE\|O_EXCL`, with a bounded same-pid collision retry) so the process umask is respected exactly like `os.WriteFile` — an explicit `Chmod` would silently widen permissions on hardened-umask hosts. Three consumers: `internal/push` (VAPID keypair + subscriptions, 0600), `internal/remote` (`remotes.yaml`), `internal/snapshot` (latest/history/tombstones). Constitution §II keeps state in plain files rather than a database, which makes torn writes a real corruption vector — this is the one implementation of the guarantee | +| `internal/present` | **`rk present` target resolution, pure and tmux-free** (`260813-becu-rk-present-attach-verb`) — `ParseTarget(arg, cwd string) (Target, error)` classifies the positional target into five kinds: `file` (existing regular file → root = its absolute parent dir), `dir` (existing directory → root = the absolute dir), `port` (`:NNNN`), `local URL` (absolute `http://` on `localhost`/`127.0.0.1`/`[::1]`, explicit port else 80), `external URL` (any other absolute `http(s)://`, attached verbatim — including `https://localhost`, which never rewrites). A path that does not exist (and is not a port/URL) is an error. `Target.URL(windowID, server, now)` derives the `@rk_url` value: file/dir → `/present//?server=&v=` (dir target's basename slot empty, serving `index.html`); port → `/proxy//`; local URL → `/proxy//` (relative form only, never an absolute origin); external → verbatim. The `?v=` cache-buster applies to `/present/` URLs only. `Target.NeedsRoot()`/`NeedsProbe()` classify; `ProbePort(ctx, port)` is the best-effort ~1s TCP reachability probe for port/local-URL kinds | ### tmux Runner Core @@ -228,6 +231,7 @@ All endpoints served by the single Go binary on one port. POST-only mutations wi | `/api/boards/{name}/unpin` | POST | Unpin a window from a board. Body `{"server":"...","windowId":"@1234"}`. Returns `200 {"ok":true}`. Tolerant of "entry not present" (also returns `200`). Empty board cannot exist — when this removes the last entry, the board vanishes from `GET /api/boards`. Broadcasts `event: board-changed { change: "unpin" }` | | `/api/boards/{name}/reorder` | POST | Reorder a pin within a board. Body `{"server":"...","windowId":"@1234","before":"@5678"\|null,"after":"@9abc"\|null}`. The new `orderKey` is computed server-side via `tmux.ComputeOrderKey(beforeKey, afterKey)`; the frontend never generates keys. Returns `200 {"ok":true,"newOrderKey":"bm"}` and broadcasts `event: board-changed { change: "reorder", orderKey }` | | `/proxy/{port}/*` | * | Reverse proxy — forwards to `http://127.0.0.1:{port}/{path}`. Port validated (1-65535, `400` on invalid). A request for the bare `/proxy/{port}` (no trailing slash) **308-redirects to `/proxy/{port}/`** with the query string preserved BEFORE proxying (slashed paths proxy directly, no redirect loop) — relative-base apps (code-server) resolve `./x` against `/proxy/` without the trailing slash, and the redirect makes the proxy safe for any client, not only ones that always append a path. Per-port `ReverseProxy` instances cached via `sync.Map`. Handles WebSocket upgrade transparently. The `Rewrite` hook calls **`r.SetXForwarded()`** beside `r.Out.Host = target.Host` — code-server's `authenticateOrigin` (its src/node/http.ts) compares the browser's `Origin` host against `Forwarded` → `X-Forwarded-Host` → `Host` and **403s every WebSocket handshake and POST** without it; browsers omit `Origin` on same-origin GETs, so the symptom is "editor loads, then sits disconnected forever", not an obvious error. `ModifyResponse` rewrites `localhost:{port}` and `127.0.0.1:{port}` references in HTML responses (`text/html` content-type) to `/proxy/{port}` paths; handles gzip-compressed responses. Every branch that swaps in a rewritten body updates the `Content-Length` **header** (`resp.Header.Set`) together with the `resp.ContentLength` field, to the byte length of the final wire body — see § Proxy Content-Length header sync. Non-HTML responses pass through unchanged | +| `/present/{windowId}/*` | GET | **Content route for `rk present` file/dir targets** (`260813-becu-rk-present-attach-verb`), registered beside the proxy routes (`api/router.go`, handler `api/present.go` `handlePresent`). `windowId` is gated on `^@[0-9]+$` BEFORE any subprocess (invalid → 400, no tmux call); the tmux server resolves via `serverFromRequest` (`?server=` query, `default` fallback) — the tmux server identity rides the URL because `@N` window ids are unique only per server. The handler reads the window's `@rk_present_root` option from tmux **at request time** (via the `getWindowOptionFn` package-var seam over `tmux.GetWindowOption` — derive-from-tmux, Constitution II: no cache, no registration state, no disk store; an unset option or dead window is a 404) and serves the requested file from under it, MIME by extension via stdlib `http.ServeContent` semantics. Serving is refused unless the option is present and **absolute**; containment is **symlink-resolved, never lexical** (`filepath.EvalSymlinks` on both root and requested file, then `filepath.Rel` — intra-tree symlinks serve, escaping symlinks and `..` traversal are 404 without touching files outside the root; see § Design Decisions → Tmux-option-derived serving). A directory request serves that directory's `index.html` or 404s — never a listing. Bare `/present/{windowId}` redirects to the trailing-slash form, mirroring the proxy routes. GET-only — an API-plane content route like `/proxy/*`, not a UI route (no §IV route-set change), and no mutating endpoint (§IX untouched) | | `/code/*` | * | The stable code-server route (`260811-a2bo-daemon-code-server-stable-route`) — reverse-proxies to `http://127.0.0.1:{resolved port}/{path}` via the SAME machinery as `/proxy/{port}`, extracted into a shared prefix-parameterized constructor `newPrefixProxy(port, stripPrefix, pathFor)` (strip prefix, `SetXForwarded`, WebSocket passthrough, HTML localhost-rewrite targeting the route's own path, `sync.Map` cache keyed by route prefix); `getOrCreateProxy`/`getOrCreateCodeProxy` are the two call sites. The port is resolved PER REQUEST via `config.Load().ResolvedCodeServerPort()` (Constitution II — env is process-lifetime stable, so this is four getenvs, not a config re-read); 0 (degenerate `RK_PORT`) ⇒ `503`. Bare `/code` (no trailing slash) **308-redirects to `/code/`** with the query string preserved before proxying (slashed paths proxy directly, no loop) — the same relative-base rule as `/proxy/{port}`. The path is FIXED because code-server keys browser-side workspace state by the proxy pathname — the port is a private implementation detail and never appears in a URL the frontend builds. GET-and-WS only; an API-plane path like `/proxy/*`, not a UI route (no §IV route-set change) | **`broadcastServerOrder` — server-global fan-out (vs. per-server `broadcastSessionOrder`)** (since `260705-bpnr-server-tiles-drag-reorder`): the SSE hub gains `broadcastServerOrder(order []string)` (`api/sse.go`) alongside the existing `broadcastSessionOrder(server, order)`. The distinction is load-bearing and deliberate: **session** order is a per-server concern (which server's sidebar reorders), so `broadcastSessionOrder` fans only to that server's clients and caches per-server in `previousOrderJSON[server]`. **Server** rank order is a HOST-global concern — a client viewing one server, or NONE (the bare `/` Host page on the `?metrics=1` sentinel stream), still needs to re-sort its whole server list — so `broadcastServerOrder` fans to EVERY client across every `h.clients` key (exactly like the `metrics`/`services` broadcasts) and caches in a SINGLE `cachedServerOrderJSON` slot replayed to every new client in `addClient` (NOT gated on `c.server`). It normalizes a `nil` order to `[]` so the cached JSON is always `"[]"`, never `"null"`. This is why the design chose a fresh server-global event rather than reusing the per-server `session-order` shape — the latter would neither reach `?metrics=1` clients nor fit a host-global cache. @@ -700,7 +704,7 @@ Dual-mode SPA serving in `app/backend/api/spa.go`. `hasEmbeddedAssets()` checks - **Production** (`mountEmbeddedSPA`): serves from `embed.FS` through the `embeddedSPASub` package-var seam (`fs.Sub(build.Frontend, "frontend")` in production; overridden by tests to inject an `fstest.MapFS`, since the test-build embed.FS holds only `.gitkeep`) + `http.FS`. SPA fallback rewrites to `index.html`. - **Development** (`mountFilesystemSPA`): serves from `app/frontend/dist/` on the local filesystem. Path traversal prevented (resolved path must stay within SPA directory). -Both modes: any request not matching `/api/*`, `/relay/*`, or `/proxy/*` serves `index.html` for client-side routing. In development, Vite handles SPA fallback natively. +Both modes: any request not matching `/api/*`, `/relay/*`, `/proxy/*`, or `/present/*` serves `index.html` for client-side routing. In development, Vite handles SPA fallback natively. ### Two-tier cache policy @@ -842,6 +846,7 @@ Single-view model: there are no page transitions or per-page chrome injection. T | `reaper` | `reaper.go` | Operator-invoked janitor: scans `/tmp/tmux-{uid}/` and reaps every artifact matching `--prefix` (default `rk-test`) — live servers killed, dead sockets/`*.lock` removed; dry-run by default (`--yes`/`--force` to act; `--force` also bypasses the dangerous-prefix guard for an empty/≤3-char prefix; `_rk-ctl`/`rk-daemon` skipped unconditionally). Output all via `cmd.OutOrStdout()` (data — a dry-run list is the requested result, an act summary is the record of a destructive mutation), so **`--quiet` legitimately changes nothing** (no sink conversion). **Default 10-entry-per-list display cap + `--all`** (`260717-f8yv-cli-output-volume-controls`, toolkit Principle 9, mirroring `shll changelog`'s 10-release cap): `renderDryRun`'s candidate list caps at `reaperListCap` (10), and `renderReapSummary`'s `killed` and `removed` lists cap at 10 **each** (`renderCappedNames`, per-list); a **stated truncation notice** `… and N more; pass --all to list all` (`renderTruncationNotice`) prints whenever a list is truncated (silent truncation reads as completeness). The **`--all`** bool restores the full list on either path. The cap is **display-only**: header counts stay **exact** (computed from the full `ReapResult`), `--yes`/`--force` still reap **every** match regardless of what was listed, and the dangerous-prefix guard, `_rk-ctl`/`rk-daemon` skips, and dry-run-by-default behavior are unchanged. The `Long` help text gained a sentence describing the cap/`--all`; `help_dump_test.go` asserts structure dynamically (no golden fixture) | | `snapshot` | `snapshot.go` | **Inspect and restore tmux-server layout snapshots** — a cobra parent with three children over `internal/snapshot`: `list []` (server / live-or-`died `-with-audited-marker state / age / session+window counts / history depth, capped at 10 rendered rows with a stated truncation notice and `--all`, header count exact — the `reaper` cap idiom), `show [--at ]` (stored layout tree, touches no tmux), `restore [--at ]` (recreates a dead server, prints the report). `validate.ValidateServerName` runs on every `` arg and `--at` must be non-negative, both **before** any filesystem or tmux use. All output is `cmd.OutOrStdout()` data (a stored layout is the requested result; a restore report is the record of a destructive mutation), so `--quiet` legitimately changes nothing — the `reaper`/`status` posture. Store and restore-engine access go through the `newSnapshotStore`/`snapshotRestoreFn` package-var seams (the `runBrewFn` idiom) so command tests need neither a real state dir nor a live tmux server. Full contract in [layout-snapshots](/run-kit/layout-snapshots.md) | | `role` | `role.go` | **Mark or unmark the current window as the server's operator** — `rk role ` sets/clears the `@rk_role` window option (§ Data Model) on the CURRENT window, resolved from `$TMUX_PANE` via `display-message -pt $TMUX_PANE '#{window_id}'` (validated by `validate.ValidateWindowID`) against the pane's OWN server (`-S ` derived from `tmux.OriginalTMUX`, never a bare invocation). `operator` applies the server-scoped radio clear via the shared `tmux.ClearWindowRoleExcept` helper (the same enforcement the window-options POST handler applies) before the set; `clear` unsets. **Hard-errors outside tmux** — an empty `$TMUX_PANE` exits non-zero with a clear message (the fail-silent contract belongs to the CALLER — the primary consumer is the fab-kit `/fab-operator` skill's self-mark — unlike the fail-silent `agent-hook`) — **and equally when `$TMUX` yields no socket**: `tmuxSocketArgs` returns an empty prefix for an unset/malformed `$TMUX`, which for `agent-hook`'s never-fail contract degrades to the default socket, but here would resolve `$TMUX_PANE` against — and radio-clear `@rk_role` across — whichever server owns the default socket (spawning one if it is dead, per the stale-socket resurrection rule). `$TMUX_PANE` set without `$TMUX` is exactly the `tmux run-shell` shape, so the pane guard does not cover it; the command refuses rather than guess a server. Principle 9 posture: the one-line confirmation (`@N role=operator` / `@N role cleared`) is `Dataf` on stdout (data, survives `--quiet`); errors flow through `RunE` to stderr with a non-zero exit. Every tmux call runs under one 5s `context.WithTimeout` through the `internal/tmux` `Run`/`RunOutput` core (Constitution §I); `roleRunFn`/`roleRunOutputFn`/`roleClearExceptFn`/`roleOriginalTMUXFn` package-var seams keep the command testable without a live server (the `$TMUX` seam is required because `internal/tmux`'s `init()` strips `$TMUX`, fixing `OriginalTMUX` at package-init time beyond `t.Setenv`'s reach). Registered on `rootCmd` in `root.go` (`260813-ifya-operator-role-pinned-row`) | +| `present` | `present.go` | **One-verb "show this to the user" for agents** (`260813-becu-rk-present-attach-verb`) — `rk present [--window[=name]] [--notify[=msg]]` resolves the positional target via `internal/present.ParseTarget` (five kinds: file, dir, `:port`, localhost-`http://` URL, external URL — § Backend Libraries) and **attaches it to the caller's OWN window**: pane → window id (`@N`) → server name (socket basename of `tmux.OriginalTMUX`, `default` for the default socket — the `agent_hook.go` socket-args pattern) via `$TMUX_PANE` + `display-message`, then sets `@rk_url` (and, for file/dir, `@rk_present_root`) through `tmux.SetWindowOptions`. **stdout carries exactly the resolved (relative) `@rk_url`** — data, printed even under `--quiet`; diagnostics on stderr. Port/local-URL kinds get a best-effort ~1s TCP reachability probe first (refusal → exit 1); file/dir/external are never probed. **The verb never opens the tile for any viewer** — no window creation, no API call, no layout mutation; availability (the rail's web button via SSE option polling) is the whole contract (see § Design Decisions → The verb never opens the tile). **Re-present is the refresh verb**: a re-run on the same file/dir target bumps the `?v=` unix-seconds cache-buster so an already-open web tile re-navigates; live edits to served files are visible on a plain reload. **`--window[=name]`** (the standalone fallback, and the one remaining producer of the `@rk_type=iframe` default-view hint) creates a new window in the caller's session via `tmux.CreateWindowWithOptionsID` carrying `@rk_type=iframe` + `@rk_url` (+ `@rk_present_root` for file/dir, with the NEW window's id in the URL); the name defaults from the target basename sanitized to `internal/validate` rules (colons/periods → `-`, the `port-{port}` precedent). **`--notify[=msg]`** sends a Web Push through the shared `rk notify` machinery after a successful attach (message defaulting to `presenting `), **fail-silent** per `rk notify`'s contract — a send failure exits 0 and prints nothing. Both optional-value flags use cobra `NoOptDefVal` with an untypable sentinel, distinguished from explicit values by equality and from absence by `Changed()`. **Exit codes** (toolkit convention): 0 success; 1 operational (no `$TMUX_PANE` without `--window`, missing file, unreachable port, tmux failure); 2 usage (no target, unknown flag) — only `--notify` deviates (fail-silent). Registered on `rootCmd`; appears in `help-dump` automatically | ## Embedded Frontend Assets @@ -1018,6 +1023,8 @@ Pane boards are named collections of pinned tmux windows rendered as a horizonta ## Design Decisions +- **Tmux-option-derived serving** (`260813-becu-rk-present-attach-verb`). *Decision*: file/dir targets of `rk present` are served by the `/present/{windowId}/` GET route, which resolves the window's `@rk_present_root` option from tmux at request time; the CLI only sets window options. *Why*: Constitution II/X native — the serve root is an ephemeral fact about what the pane is presenting; it lives in tmux, dies with the window, and needs no registration state, no GC, and no new disk store; live edits are visible on a plain reload. Containment is symlink-resolved (`EvalSymlinks` + `filepath.Rel`), never a lexical prefix check — real trees carry legitimate intra-tree symlinks, so a prefix ban is both too weak and too strict (the code-server tarball lesson); the tmux socket is already the trust boundary (anyone who can set window options can run code in panes), so no new principal is introduced — but path traversal through the web server must be impossible. *Rejected*: wrapping `python3 -m http.server` (port-picking, python dependency, orphan-process lifecycle); a spool-copy under `$XDG_STATE_HOME/rk/present/` (GC + size-cap problems, kills live iteration, strains Constitution II's carve-outs). +- **The `rk present` verb never opens the tile** (`260813-becu-rk-present-attach-verb`). *Decision*: `rk present` sets availability only (`@rk_url` + `@rk_present_root`, surfaced on the rail via SSE option polling, plus an optional `--notify` push); which tile a viewer opens stays per-viewer client state. *Why*: surface-layout spec R7/L3 — layout is per-viewer, URL+localStorage; a server-side push would recreate the `@rk_type`-mutation conflation the lens model retired. *Rejected*: auto-opening the tile (violates per-viewer layout); a server-pushed "suggested layout" (creep). - **One-click update / restart run in managed `rk-jobs` sibling-session windows, not detached children** (`260812-z1ya-update-daemon-tmux-window`). *Decision*: `POST /api/update` and `POST /api/restart` run their job via `daemon.RunJob` — a window in the `rk-jobs` sibling session on the `rk-daemon` socket — gated on the daemon running (`409` when down, no fallback path), with spawn-before-respond (`202` fresh + `watch` target / `200 already-running` / `502` spawn error); `rk daemon run` is the same primitive's CLI wrapper. *Why*: a tmux window converts an opaque fire-and-forget spawn into the product's own core competency — the job is watchable on the existing terminal route (`ListServers` surfaces every live server), in-flight state is derivable from window existence at request time (Constitution II), and the window survives the daemon restart it triggers because `daemon.Stop()`'s exact-match `=rk-daemon` kill never touches a sibling session (Constitution VI) — which also deletes the entire `Setsid` detachment carve-out from §I's `exec.CommandContext` discipline. `remain-on-exit on` keeps the completed run's output in the pane (the next run of the same job relaunches in place via `respawn-window -k` — no timer/reaper); `pipe-pane` tees to the durable `~/.rk/.log`. *Rejected*: a detached `Setsid` child (opaque — no exit observation, no in-flight answer, log-file-only diagnostics); a window inside the `=rk-daemon` session (the job would kill itself on the restart it triggers); a generic any-server/any-pane exec subcommand (reimplements tmux addressing where the target-hijack footguns live); a `409` for a second click while in-flight (a truthful derived answer, not an error — the UI navigates instead). - **Job windows persist after every exit** (`260813-4n9h-persistent-job-windows`). *Decision*: `RunJob`'s post-spawn option is `remain-on-exit on` — a job window (update, restart, `rk daemon run`) survives its command's exit as a dead pane, success and failure alike, until the next run of the same job respawns it in place. *Why*: with the earlier `failed` value a successful update's output vanished the moment it completed — the watchable-job story was asymmetric (failures left evidence, successes disappeared); the completed run's scrollback staying on screen until the next run replaces it is the feature, including the permanent `rk-jobs:` dead-pane row on the dashboard between runs. The respawn/dedup machinery needs no change: `jobWindowState` probes `#{pane_dead}` and `respawn-window -k` relaunches in place. *Rejected*: keeping `remain-on-exit failed` (success evidence lost); per-job configurability of the persistence (a flat flip covers every job window). - **Session-scoped daemon startup reap** (`260813-b41g-scope-daemon-socket-reap`). *Decision*: `reapStaleDaemonSocket` kills only the exact-match daemon sessions (`=rk-daemon`, legacy `=rk`) — never `kill-server` — on the multi-tenant `rk-daemon` socket. *Why*: the socket's sibling sessions (`rk-jobs`, `rk-code-server`, `rk-remotes`) deliberately outlive the daemon (Constitution VI); a server-wide kill at `Start()` had exactly one live-fire case — a server alive with siblings but no daemon session, i.e. the restart-via-job path — where it SIGHUPed the `rk-jobs` update window mid-`shll update` (the job that triggered the restart), left code-server absent after every auto-update (`ensureCodeServer` ran later in the dying updater process and lost the SIGHUP race), and tore down SSH tunnels on every restart. *Rejected*: probing `list-sessions` and issuing `kill-server` only at zero sessions (more machinery for the same coverage — a zero-session tmux server exits on its own); deleting the reap entirely (kept as a race-window safety net between the `IsRunning()` probe and `startSession`). diff --git a/docs/memory/run-kit/index.md b/docs/memory/run-kit/index.md index b6c132a42..cf520d6a3 100644 --- a/docs/memory/run-kit/index.md +++ b/docs/memory/run-kit/index.md @@ -15,6 +15,6 @@ description: "Web-based agent orchestration dashboard" | [remote-hosts](remote-hosts.md) | SSH-only remote hosts — the `rk remote` six-verb family (add/connect/list/status/disconnect/remove, no `update`) over `internal/remote`: remotes.yaml v1 with immutable 3100–3199 ports, the `ssh -N -L` tunnel as a window in the `rk-remotes` tmux session on the rk-daemon socket, idempotent connect (curl-installer bootstrap, older-than-local update, remote daemon start, origin via `rk url`), loopback binding with SSH as sole auth, and read-path validation of stored entries. | | [rk-riff](rk-riff.md) | `rk riff` spawn engine — worktree + tmux window + Claude launcher with argv-ordered pane arrays, presets, layouts, fan-out. Lives in internal/riff on explicit {server, session, repoRoot}, driven by the CLI and by the web UI (POST /api/riff, the conversation-fork endpoint, macro bindings). Covers spawn-shaping inputs (Where, WorktreeName, WindowNameBase, Tier), the ResumeSessionRef fork-launcher seam, launcher resolution via fab agent --print, fabconfig reads, exit codes + security. | | [tmux-guard-shim](tmux-guard-shim.md) | The tmux guard PATH shim — `rk tmux-guard` fronts the real tmux and refuses `kill-server` without an explicit `-L`/`-S` (a bare kill in a pane destroys the HOST server — precedence `-L`/`-S` > `$TMUX` > `TMUX_TMPDIR`). Covers the argv decision, shim-skipping resolution, the exec passthrough (`$TMUX` restored, `RK_TMUX_GUARD` stripped), the self-healing shim script (~3s rk probe → guard exec → fail-open PATH walk behind a crude backstop), the `rk agent-setup` install contract, and doctor states. | -| [tmux-sessions](tmux-sessions.md) | Session enumeration and group filtering; direct-attach terminal relay over the muxed `/ws/terminals` socket (pin-session-first attach, session-scoped select); link-based board pin-sessions (`_rk-pin-*`, dual membership, last-link recovery); server-birth CWD pin ($HOME, fallback `/`) at birth-capable seams; window `@N` addressing; exact-match `=name:` targets; `MoveWindow` active-window preservation; folder auto-naming; SSE dead-server reap; test sockets + `rk reaper`; pane chat-send. | -| [toolkit-standards](toolkit-standards.md) | run-kit's shll-toolkit-standards conformance posture — constitution binding, audit-against-HEAD-build rule, per-standard status. help-dump, readme-extraction, skill, ten principles, update, version PASS. Covers skill topic pages, Principle 9 `--quiet`/reaper caps, SIGTERM-with-grace brew mutations, the help-dump + Principle 9 new-surface check (`rk desktop`, `rk remote`, `rk daemon run`, `rk role`, `rk code-server`), `rk update`'s best-effort code-server leg, install-composition Policy B PASS. | +| [tmux-sessions](tmux-sessions.md) | Session enumeration and group filtering; direct-attach terminal relay over the muxed `/ws/terminals` socket (pin-session-first attach, session-scoped select); link-based board pin-sessions (`_rk-pin-*`); server-birth CWD pin; window `@N` addressing; exact-match `=name:` targets; `MoveWindow` active-window preservation; folder auto-naming; SSE dead-server reap; test sockets + `rk reaper`; pane chat-send; `@rk_*` user-option registry (incl. `@rk_present_root`, `@rk_url` attach semantics). | +| [toolkit-standards](toolkit-standards.md) | run-kit's shll-toolkit-standards conformance posture — constitution binding, audit-against-HEAD-build rule, per-standard status. help-dump, readme-extraction, skill, ten principles, update, version PASS. Covers skill topic pages, Principle 9 `--quiet`/reaper caps, SIGTERM-with-grace brew mutations, the help-dump + Principle 9 new-surface check (`rk desktop`/`remote`/`daemon run`/`role`/`code-server`/`present`), `rk update`'s best-effort code-server leg, install-composition Policy B PASS. | | [ui-patterns](ui-patterns.md) | Frontend UI patterns: routes; top-bar chrome; surface layout manager (shapes × surfaces × ratios, ?layout= ladder, framed tile chrome, rail toggles, mobile slot-A); window-view lenses; code-surface folder latch; right rail; boards + pinning; sidebar (perf, keyboard nav, multi-select, operator pinned row); status dot, PR registers, waiting; row flyout; tooltips; tiles; dialogs; palette; keybindings; bottom bar; compose strip; terminal relay/font; accent/PWA/titlebar/dock badge; optimistic UI. | diff --git a/docs/memory/run-kit/tmux-sessions.md b/docs/memory/run-kit/tmux-sessions.md index 2d5675a83..5e032afba 100644 --- a/docs/memory/run-kit/tmux-sessions.md +++ b/docs/memory/run-kit/tmux-sessions.md @@ -1,5 +1,5 @@ --- -description: "Session enumeration and group filtering; direct-attach terminal relay over the muxed `/ws/terminals` socket (pin-session-first attach, session-scoped select); link-based board pin-sessions (`_rk-pin-*`, dual membership, last-link recovery); server-birth CWD pin ($HOME, fallback `/`) at birth-capable seams; window `@N` addressing; exact-match `=name:` targets; `MoveWindow` active-window preservation; folder auto-naming; SSE dead-server reap; test sockets + `rk reaper`; pane chat-send." +description: "Session enumeration and group filtering; direct-attach terminal relay over the muxed `/ws/terminals` socket (pin-session-first attach, session-scoped select); link-based board pin-sessions (`_rk-pin-*`); server-birth CWD pin; window `@N` addressing; exact-match `=name:` targets; `MoveWindow` active-window preservation; folder auto-naming; SSE dead-server reap; test sockets + `rk reaper`; pane chat-send; `@rk_*` user-option registry (incl. `@rk_present_root`, `@rk_url` attach semantics)." type: memory --- # tmux Session Enumeration @@ -273,13 +273,14 @@ A server enters the poll set exactly once — when a browser subscribes to it: a ## Server-Scoped User Options -tmux distinguishes window-scoped (`-w`) options, server-scoped (`-s`) options, **pane-scoped (`-p`) options** (`260705-dmex-generic-agent-state-tier`), and session-scoped user options (the default — `set-option -t `). We use all four: window-scoped for per-window state (`@color`, `@rk_type`, `@rk_url`), server-scoped for state belonging to the tmux server as a whole (`@rk_session_order`, `@rk_server_rank`), **pane-scoped for per-pane agent lifecycle state (`@rk_agent_state`)**, and session-scoped on `_rk-pin-*` pin-sessions for board membership (`@rk_board`/`@rk_home`/`@rk_board_order`, `260602-qn62`) and on `_rk-ctl` for the control-mode keepalive marker. +tmux distinguishes window-scoped (`-w`) options, server-scoped (`-s`) options, **pane-scoped (`-p`) options** (`260705-dmex-generic-agent-state-tier`), and session-scoped user options (the default — `set-option -t `). We use all four: window-scoped for per-window state (`@color`, `@rk_type`, `@rk_url`, `@rk_present_root`), server-scoped for state belonging to the tmux server as a whole (`@rk_session_order`, `@rk_server_rank`), **pane-scoped for per-pane agent lifecycle state (`@rk_agent_state`)**, and session-scoped on `_rk-pin-*` pin-sessions for board membership (`@rk_board`/`@rk_home`/`@rk_board_order`, `260602-qn62`) and on `_rk-ctl` for the control-mode keepalive marker. | Option | Scope | Set via | Read via | Owner | |--------|-------|---------|----------|-------| | `@color` | window (`-w`) | `tmux.SetWindowOptions` (via `POST /options`) | `ListWindows` format string field 8 | per-window | | `@rk_type` | window (`-w`) | `CreateWindowWithOptions`, `tmux.SetWindowOptions` (both via `appendOptionOps`) | `ListWindows` format string field 9 | per-window (iframe) | -| `@rk_url` | window (`-w`) | `CreateWindowWithOptions`, `tmux.SetWindowOptions` (both via `appendOptionOps`) | `ListWindows` format string field 10 | per-window (iframe) | +| `@rk_url` | window (`-w`) | `CreateWindowWithOptions`, `tmux.SetWindowOptions` (both via `appendOptionOps`), `rk present` | `ListWindows` format string field 10 | per-window (iframe) | +| `@rk_present_root` | window (`-w`) | `rk present` (file/dir targets only, via `tmux.SetWindowOptions`), `CreateWindowWithOptionsID` under `--window` | `tmux.GetWindowOption` at request time by the `/present/{windowId}/*` handler (const `presentRootOption` in `api/present.go`; never enumerated into `ListWindows`) | per-window serve root for presented files (absolute dir; dies with the window) | | `@rk_session_order` | server (`-s`) | `tmux.SetSessionOrder(ctx, server, order)` | `tmux.GetSessionOrder(ctx, server)` | sidebar reorder | | `@rk_server_rank` | server (`-s`) | `tmux.SetServerRank(ctx, server, rank)` | `tmux.GetServerRank(ctx, server)` | server-tile display rank (`260705-bpnr-server-tiles-drag-reorder`) | | `@rk_agent_state` | **pane (`-p`)** | agent-harness hooks installed by `rk agent-setup` — plain `set-option -pt "$TMUX_PANE"` at hook-fire time, NO rk/server dependency (`260705-dmex-generic-agent-state-tier`) | `paneFormat` field 6 → `parsePanes` (→ `PaneInfo.AgentState`/`AgentStateEpoch`) | generic agent-lifecycle state (`active|waiting|idle:epoch`; const `tmux.AgentStateOption`). See [agent-state](agent-state.md) | @@ -288,6 +289,8 @@ tmux distinguishes window-scoped (`-w`) options, server-scoped (`-s`) options, * | `@rk_board_order` | session-scoped on each `_rk-pin-*` | `tmux.Pin` (append key) / `tmux.Reorder` | `tmux.ListBoardEntries` / `tmux.GetBoard` (sort) | board pin fractional order (const `tmux.BoardOrderOption`; via `ComputeOrderKey`) | | `@rk_ctl_keepalive` | session-scoped on `_rk-ctl` (set via `set-option -t =_rk-ctl`) | `tmuxctl.Client.setAnchorKeepalive` | (no runtime consumer; defensive marker) | tmuxctl control-mode anchor | +`@rk_url` is a **window-level option**, so on a multi-pane window an attach is **last-write-wins** — one `@rk_url` (and one `@rk_present_root`) per window, one web tile per surface kind in layout v1; a second simultaneous mock is what `rk present --window` exists for. `rk present` re-presenting the same file/dir target writes a fresh `?v=` unix-seconds cache-buster in `@rk_url`, so an already-open web tile re-navigates — re-present is the refresh verb (live edits are visible on a plain reload too, since serving is from the live filesystem). Setting `@rk_url` alone never steals the window's default view: the frontend's HINT_ORDER grants a `web` default hint only via `@rk_type=iframe`, so a tty-led window stays tty-led and the web tile is additive. The tmux server identity rides the presented URL as a `?server=` query param (window ids `@N` are unique only per tmux server; see § Window Addressing Identity). (`260813-becu-rk-present-attach-verb`.) + `@rk_session_order` stores a JSON-encoded array of session names defining the user-preferred sidebar render order. Because the value is server-scoped, it is shared by every client connected to the same tmux server — laptop and phone hitting the same `tmux -L runkit` see the same order. Lifetime matches the tmux server (lost on server kill, NOT on rk-go restart per Constitution VI). Both wrapper functions wrap their context with `context.WithTimeout(ctx, TmuxTimeout)` (10s) and route through `tmuxExecRawServer` (which captures stderr in error messages so callers can pattern-match "invalid option" / "no server running" to distinguish operational empty-state from real failures). The HTTP endpoints `GET /api/sessions/order` and `POST /api/sessions/order` (POST per §IX; see `architecture.md` § Endpoints) layer over these wrappers. The mutating POST triggers a synchronous SSE broadcast (`event: session-order`) so all connected clients on that server reorder live; the SSE hub also bootstraps the cache once per server on first poll so the order survives an rk-go restart that left tmux running. diff --git a/docs/memory/run-kit/toolkit-standards.md b/docs/memory/run-kit/toolkit-standards.md index e56822c74..ced31164d 100644 --- a/docs/memory/run-kit/toolkit-standards.md +++ b/docs/memory/run-kit/toolkit-standards.md @@ -1,6 +1,6 @@ --- type: memory -description: "run-kit's shll-toolkit-standards conformance posture — constitution binding, audit-against-HEAD-build rule, per-standard status. help-dump, readme-extraction, skill, ten principles, update, version PASS. Covers skill topic pages, Principle 9 `--quiet`/reaper caps, SIGTERM-with-grace brew mutations, the help-dump + Principle 9 new-surface check (`rk desktop`, `rk remote`, `rk daemon run`, `rk role`, `rk code-server`), `rk update`'s best-effort code-server leg, install-composition Policy B PASS." +description: "run-kit's shll-toolkit-standards conformance posture — constitution binding, audit-against-HEAD-build rule, per-standard status. help-dump, readme-extraction, skill, ten principles, update, version PASS. Covers skill topic pages, Principle 9 `--quiet`/reaper caps, SIGTERM-with-grace brew mutations, the help-dump + Principle 9 new-surface check (`rk desktop`/`remote`/`daemon run`/`role`/`code-server`/`present`), `rk update`'s best-effort code-server leg, install-composition Policy B PASS." --- # Toolkit Standards Conformance @@ -257,6 +257,38 @@ is the fifth surface measured against the same two checks not a command enumeration, and editing it would trip its byte-equality drift guard for no standard-mandated gain. +The `rk present` verb (`present.go` — see +[architecture](/run-kit/architecture.md) § CLI Subcommands, `present` row) is +the sixth surface measured against the same checks +(`260813-becu-rk-present-attach-verb`): + +- **help-dump: platform-stable registration.** `presentCmd` is registered + unconditionally on `rootCmd` with a `Long:` block, so the cobra tree walk + picks it up with no help-dump code change; the `$TMUX_PANE` guard is an + operational exit 1 at run time, not a registration condition. +- **Principle 9: the resolved URL is the only stdout line — data.** stdout + carries exactly the resolved `@rk_url` value (relative for `/present`/ + `/proxy` targets, absolute for external URLs) and prints it even + under `--quiet` (silence would hide the command's one result); diagnostics + go to stderr. `--notify`'s send failure is the documented fail-silent + exception (the `rk notify` contract), not a Principle 9 violation. +- **Exit-code convention (P4)** — 0 success, 1 operational (no `$TMUX_PANE` + without `--window`, missing file, unreachable port, tmux failure), 2 usage + (no target, unknown flag). +- **readme-extraction: the README command table gained the `run-kit present` + row**, keeping the published command documentation closed over the tree. +- **The `skill` standard is the load-bearing one this time** — unlike the + earlier surfaces, this change *rewrites* the bundle: the canonical + `docs/site/skill.md` and `docs/site/skill/display.md` (synced to the + embedded copies by `scripts/sync-skill.sh`) teach `rk present` as the + primary Visual Display Recipe, with the manual `@rk_url` attach path kept + as a short appendix for older rk versions. Both files stay within the + ≤150-line budget and under the byte-equality drift guards + (`TestSkillEmbedMatchesCanonical`, `TestSkillDisplayEmbedMatchesCanonical`), + so the skill standard keeps passing. No version-skew machinery is needed: + the bundle ships inside the binary, so an rk that has `present` is the same + rk whose pages teach it. + #### Scenario: A new subcommand group keeps the help tree platform-stable - **GIVEN** the `rk desktop` group on a Linux host - **WHEN** `rk desktop install` runs diff --git a/docs/site/skill.md b/docs/site/skill.md index 5e936c056..68aff5928 100644 --- a/docs/site/skill.md +++ b/docs/site/skill.md @@ -31,16 +31,8 @@ One line each, keyed to the subcommand or tmux option that does it: - `rk notify [--title ]` — Web Push a message to every subscribed browser/device. Fail-silent by contract (see Output contracts). - `rk url` — print the run-kit **server URL** (config-derived: RK_HOST/RK_PORT, default `http://127.0.0.1:3000`). It is a heuristic, not a liveness probe. Run it at use-time; never hardcode the value. -- `rk skill display` — the visual-display topic page: panes, iframe windows, the proxy, and the canonical Visual Display Recipe, in depth. -- **Iframe windows** — a tmux window that renders a web page instead of a terminal: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url - ``` - - Change the page later by re-setting `@rk_url`. +- `rk present ` — attach web content beside your own terminal: a file, a directory, a `:port`, a localhost URL, or an external URL. Prints the resolved URL to stdout. Depth: `rk skill display`. +- `rk skill display` — the visual-display topic page: target forms, attach vs. standalone windows, the proxy, and the canonical Visual Display Recipe, in depth. - **Proxy** — reach a local service through the run-kit server: ``` @@ -48,17 +40,11 @@ One line each, keyed to the subcommand or tmux option that does it: ``` The relative form `/proxy/{port}/...` works from the frontend behind any origin or reverse proxy. -- **Visual Display Recipe** — the canonical 4-step flow to show HTML to the user: +- **Visual Display Recipe** — the canonical flow to show HTML to the user: 1. **Generate HTML** to a known location (a temp dir or the project tree). - 2. **Serve it** on loopback: `python3 -m http.server --bind 127.0.0.1 -d &` - 3. **Open an iframe window** with a relative proxy path: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url /proxy// - ``` - 4. **Fail silently** — if any step's prerequisite is unavailable (run-kit missing, port in use, server start fails), skip the rest without surfacing an error. + 2. **`rk present ./file.html`** — serves it live and attaches it to your window's web tile; re-run the same command to refresh. + 3. **Optionally `--notify`** — push the user when they may be away. + 4. **Fail silently** — if any prerequisite is unavailable (run-kit missing, not in tmux), skip the rest without surfacing an error. ## Where am I @@ -85,6 +71,7 @@ rk url # server URL (config-derived) ## Output & exit-code contracts - **`rk notify` is fail-silent by contract.** Any error — server unreachable, no subscriptions, non-2xx — exits **0** and prints nothing, so it never stalls a calling loop. Do not branch on its output. +- **`rk present` prints only the resolved URL to stdout** (data — printed even under `--quiet`); diagnostics go to stderr. Its exit codes follow the convention below; its `--notify` send stays fail-silent like `rk notify`. - **`rk skill`, `rk url`, and `rk help-dump` print data to stdout** (stdout is data; stderr is diagnostics). `rk skill` emits this bundle byte-identical with empty stderr and exit 0; `rk skill ` (e.g. `display`) prints one topic page under the same contract, and an unknown topic exits non-zero with the valid topics on stderr; `rk url` prints the server URL newline-terminated; `rk help-dump` emits the machine-readable command tree. - **Exit codes follow the toolkit convention: `0` success, `1` operational failure, `2` usage error** — usage/flag/arg-count/unknown-command errors exit `2`; operational failures (dead server, failed check) exit `1`; `rk riff` subprocess failures exit `3`. The diagnostic is on stderr. (`rk notify` is the exception above — runtime failures exit `0`.) diff --git a/docs/site/skill/display.md b/docs/site/skill/display.md index 263003f1c..03eeb08b8 100644 --- a/docs/site/skill/display.md +++ b/docs/site/skill/display.md @@ -1,6 +1,6 @@ # run-kit skill: display -Depth for one job: **putting visual content in front of the user** — a terminal window, an iframe rendering a web page, a generated HTML report — from inside a tmux pane run-kit manages. This is a static topic page (`rk skill display`); the [core bundle](../skill.md) covers when to reach for run-kit at all. Everything here is byte-identical on every invocation; live values are symbolic — resolve the server URL at use-time with `rk url`. +Depth for one job: **putting visual content in front of the user** — a generated HTML report, a diagram, a dev server — from inside a tmux pane run-kit manages. This is a static topic page (`rk skill display`); the [core bundle](../skill.md) covers when to reach for run-kit at all. Everything here is byte-identical on every invocation; live values are symbolic — resolve the server URL at use-time with `rk url`. Gate first, as always — run-kit is optional and may be absent: @@ -8,30 +8,49 @@ Gate first, as always — run-kit is optional and may be absent: command -v rk >/dev/null 2>&1 && [ -n "$TMUX_PANE" ] || exit 0 ``` -## Terminal Windows +## `rk present` — the primary recipe -Create a new terminal window in the current tmux session: +One verb resolves the target, serves it if needed, and attaches it to the web tile of YOUR OWN window: ```sh -tmux new-window -n +rk present ./mock.html # a file — served live, attached +rk present ./dist/ # a directory (index.html default) +rk present :5173 # a port already serving → /proxy/5173/ +rk present http://localhost:8080/x # same, rewritten to /proxy/8080/x +rk present https://example.com/app # external URL — attached verbatim ``` -## Iframe Windows +The resolved URL prints to stdout (relative for `/present` and `/proxy` targets, absolute for external URLs); diagnostics go to stderr. Exit codes: `0` success, `1` operational failure (not in tmux, file missing, port not listening), `2` usage. -Create a window that renders a web page in an iframe instead of a terminal: +**You cannot open the tile for the user.** Layout is per-viewer client state — `rk present` only makes content AVAILABLE (the rail's web button lights up). When the user may be away, nudge them: ```sh -tmux new-window -n -tmux set-option -w @rk_type iframe -tmux set-option -w @rk_url +rk present ./mock.html --notify # message: "presenting mock.html" +rk present ./mock.html --notify "report ready" ``` -Change the page of an existing iframe window later by re-setting `@rk_url`: +The notify send is fail-silent (like `rk notify`) — never branch on it. + +## Iteration + +- **Re-present is the refresh verb** — re-running `rk present` on the same file/dir target bumps a cache-buster in the attached URL, so an open web tile re-navigates. +- File/dir targets serve from the LIVE filesystem — a plain browser reload already sees your edits; re-present only when the tile must re-navigate. + +## Attach vs. standalone window + +Default attaches to your own window — one `@rk_url` per window, so last write wins on multi-pane windows. Use `--window` for the residual cases: + +- an **external URL with no owning pane** (you are presenting something unrelated to your work), +- a **second simultaneous mock** (your window's tile is already taken), +- content that deserves its own **board-pinnable identity**. ```sh -tmux set-option -w @rk_url +rk present --window https://staging.example.com # name from the host +rk present --window=report ./dist/ # explicit name ``` +`--window` spawns a new tmux window in your session carrying `@rk_type=iframe` — the one remaining legitimate producer of that hint. + ## Proxy Reach a local service through the run-kit server using the proxy path: @@ -40,42 +59,32 @@ Reach a local service through the run-kit server using the proxy path: /proxy/{port}/... ``` -A service on port 8080 is available at `/proxy/8080/`. The **relative** form works from the frontend against whatever origin the user is on — `localhost` directly or behind a reverse proxy — so never compose an absolute `{server_url}/proxy/...`; hand the frontend the relative path and let it resolve. - -## Visual Display Recipe - -The canonical 4-step flow to show HTML content to the user in an iframe window. Every step SHALL fail silently if its prerequisite is unavailable (run-kit missing, port in use, server fails to start) — skip the remaining steps without surfacing an error. - -1. **Generate HTML** to a known location (e.g. `~/.agent/diagrams/`, a temp directory, or the project tree). -2. **Serve it** on loopback (not exposed on the LAN): - - ```sh - python3 -m http.server --bind 127.0.0.1 -d & - ``` - -3. **Open an iframe window** pointing to the **relative** proxy path. The run-kit frontend resolves it against whatever origin the user is on, so it works identically on `localhost:3000` directly or behind a reverse proxy: - - ```sh - tmux new-window -n - tmux set-option -w @rk_type iframe - tmux set-option -w @rk_url /proxy// - ``` - -4. **Fail silently** — if any step's prerequisite is unavailable (run-kit missing, port in use, server start fails), skip the rest without error. +A service on port 8080 is available at `/proxy/8080/`. The **relative** form works from the frontend against whatever origin the user is on — `localhost` directly or behind a reverse proxy — so never compose an absolute `{server_url}/proxy/...`; hand the frontend the relative path and let it resolve. `rk present :8080` and `rk present http://localhost:8080/...` derive this form for you. ## Conventions ### Tmux user options -- `@rk_type` — window type: `terminal` (default) or `iframe`. Set via `tmux set-option -w @rk_type `. -- `@rk_url` — URL for iframe windows. Set via `tmux set-option -w @rk_url `. +- `@rk_url` — the window's attached web content (availability signal for the rail's web tile). +- `@rk_present_root` — the absolute serve root for `/present//...` file serving; set by `rk present` for file/dir targets, dies with the window. +- `@rk_type` — window type: `terminal` (default) or `iframe`. A creation-time default-view hint only — attaching `@rk_url` to a tty-led window does NOT steal its default view. -`set-option -w` targets the **current** window: create the window first, then set options from within it (or pass `-t `). +### SSE reactivity + +Changes to tmux window options are picked up automatically by the run-kit server via SSE polling — no manual refresh, no API call. ### Window lifecycle Killing a tmux window kills the backing process. No separate cleanup step is needed. -### SSE reactivity +## Appendix: the manual recipe (older rk versions) -Changes to tmux window options are picked up automatically by the run-kit server via SSE polling — no manual refresh, no API call. +On an rk too old to have `present`, spawn an iframe window by hand. Serve the content yourself (e.g. `python3 -m http.server --bind 127.0.0.1 -d &`), then: + +```sh +tmux new-window -n +tmux set-option -w @rk_type iframe +tmux set-option -w @rk_url /proxy// +``` + +Change the page later by re-setting `@rk_url`. Every step SHALL fail silently if its prerequisite is unavailable (run-kit missing, port in use, server fails to start) — skip the remaining steps without surfacing an error. diff --git a/fab/changes/260813-becu-rk-present-attach-verb/.history.jsonl b/fab/changes/260813-becu-rk-present-attach-verb/.history.jsonl new file mode 100644 index 000000000..4e9308181 --- /dev/null +++ b/fab/changes/260813-becu-rk-present-attach-verb/.history.jsonl @@ -0,0 +1,15 @@ +{"action":"enter","driver":"fab-new","event":"stage-transition","stage":"intake","ts":"2026-08-13T18:36:07Z"} +{"args":"rk present — a one-verb show-this-to-the-user for agents (tmux-option-derived serving via /present/{windowId}/ route, --window fallback, --notify), plus the rk skill / rk skill display page rewrite that teaches it","cmd":"fab-new","event":"command","ts":"2026-08-13T18:36:07Z"} +{"delta":"+3.4","event":"confidence","score":3.4,"trigger":"calc-score","ts":"2026-08-13T18:38:44Z"} +{"cmd":"fab-draft","event":"command","ts":"2026-08-13T18:38:51Z"} +{"cmd":"fab-switch","event":"command","ts":"2026-08-13T18:39:51Z"} +{"cmd":"fab-fff","event":"command","ts":"2026-08-13T18:41:44Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"apply","ts":"2026-08-13T18:43:25Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"review","ts":"2026-08-13T19:13:04Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-08-13T19:23:07Z"} +{"event":"review","result":"passed","ts":"2026-08-13T19:23:07Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-08-13T19:34:13Z"} +{"cmd":"git-pr","event":"command","ts":"2026-08-13T19:35:06Z"} +{"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-08-13T19:37:01Z"} +{"cmd":"git-pr-review","event":"command","ts":"2026-08-13T19:38:11Z"} +{"event":"review","result":"passed","ts":"2026-08-13T19:49:48Z"} diff --git a/fab/changes/260813-becu-rk-present-attach-verb/.status.yaml b/fab/changes/260813-becu-rk-present-attach-verb/.status.yaml new file mode 100644 index 000000000..0289a77aa --- /dev/null +++ b/fab/changes/260813-becu-rk-present-attach-verb/.status.yaml @@ -0,0 +1,56 @@ +id: becu +name: 260813-becu-rk-present-attach-verb +created: 2026-08-13T18:36:07Z +created_by: sahil-noon +change_type: feat +issues: [] +progress: + intake: done + apply: done + review: done + hydrate: done + ship: done + review-pr: done +plan: + generated: true + task_count: 12 + acceptance_count: 19 + acceptance_completed: 19 +confidence: + certain: 12 + confident: 8 + tentative: 0 + unresolved: 0 + score: 3.4 + fuzzy: true + dimensions: + signal: 75.5 + reversibility: 80.8 + competence: 80.8 + disambiguation: 79.5 +stage_metrics: + intake: {started_at: "2026-08-13T18:36:07Z", driver: fab-new, iterations: 1, completed_at: "2026-08-13T18:43:25Z"} + apply: {started_at: "2026-08-13T18:43:25Z", driver: fab-fff, iterations: 1, completed_at: "2026-08-13T19:13:04Z"} + review: {started_at: "2026-08-13T19:13:04Z", driver: fab-fff, iterations: 1, completed_at: "2026-08-13T19:23:07Z"} + hydrate: {started_at: "2026-08-13T19:23:07Z", driver: fab-fff, iterations: 1, completed_at: "2026-08-13T19:34:13Z"} + ship: {started_at: "2026-08-13T19:34:13Z", driver: fab-fff, iterations: 1, completed_at: "2026-08-13T19:37:01Z"} + review-pr: {started_at: "2026-08-13T19:37:01Z", driver: git-pr, iterations: 1, completed_at: "2026-08-13T19:49:48Z"} +prs: + - https://github.com/sahil87/run-kit/pull/589 +change_type_source: explicit +true_impact: + added: 2215 + deleted: 126 + net: 2089 + excluding: + added: 1656 + deleted: 58 + net: 1598 + tests: + added: 887 + deleted: 0 + net: 887 + computed_at: "2026-08-13T19:37:01Z" + computed_at_stage: ship +# true_impact: lazily created on first stage-finish that computes it (no placeholder here). +last_updated: 2026-08-13T19:49:48Z diff --git a/fab/changes/260813-becu-rk-present-attach-verb/intake.md b/fab/changes/260813-becu-rk-present-attach-verb/intake.md new file mode 100644 index 000000000..109027c66 --- /dev/null +++ b/fab/changes/260813-becu-rk-present-attach-verb/intake.md @@ -0,0 +1,131 @@ +# Intake: `rk present` — one-verb "show this to the user" + skill page rewrite + +**Change**: 260813-becu-rk-present-attach-verb +**Created**: 2026-08-14 + +## Origin + +Dispatched promptless (`/fab-proceed` create-new dispatch, `{questioning-mode} = promptless-defer`) from a synthesized design description produced by a `/fab-discuss` design session (2026-08-13/14). The session resolved the load-bearing design decisions explicitly — serving strategy ("option 3", tmux-option-derived serving) was chosen by the user, the `rk skill` rewrite was explicitly confirmed in scope, and the `--notify` deep-link was deliberately deferred to a follow-up change. The full design is reproduced in **What Changes** below (state transfer — downstream agents have no access to the session). + +> Feature: `rk present` — a one-verb "show this to the user" for agents (resolve a file/dir/port/URL target, attach it as a `web` tile on the caller's own tmux window via `@rk_url`, serve file targets through a new tmux-option-derived `/present/{windowId}/…` route), plus `--window` (standalone iframe window fallback) and `--notify` (Web Push), plus the `rk skill` / `rk skill display` page rewrite that teaches the new verb in place of the old 4-step synthetic-iframe-window recipe. + +## Why + +run-kit's surface-layout model shipped: iframes are now a `web` tile/lens on an existing window (capability signal `@rk_url`), not a window identity (`@rk_type` is demoted to a creation-time default-view hint). But the agent-facing recipe in `rk skill` / `rk skill display` (embedded at `app/backend/cmd/rk/skill/skill.md` and `display.md`) still teaches ONLY the old pattern: spawn a synthetic iframe window (`tmux new-window` + `@rk_type=iframe` + `@rk_url`), which creates a sidebar row with an inert shell pane — exactly the "row-less surface wearing a window costume" the specs (`docs/specs/window-views.md` § Two Species, `docs/specs/surface-layout.md` "What dies, what stays") want retired. + +1. **Pain point**: agents presenting mocks/reports mid-conversation should attach content beside their own terminal, not pollute the sidebar with synthetic windows. The 4-step manual recipe (pick a port, run `python3 -m http.server`, set options) is footgun-prone — port collisions, a python dependency, orphan-process lifecycle. +2. **Consequence of not fixing**: every agent on the box keeps producing the retired window species, and the skill bundle actively teaches the anti-pattern the specs just retired. +3. **Why this approach**: the verb bakes the convention in (Constitution VII — convention over configuration); the tmux-option-derived serving design needs no spawned server, no spool copy, no registration state, and no GC (Constitution II/X native). + +## What Changes + +### 1. The `rk present` command (new cobra subcommand, `app/backend/cmd/rk/`) + +``` +rk present [flags] + + ./mock.html a file — serve it, attach to this window + ./dist/ a directory — serve it, attach (index.html default) + :5173 a local port already serving — attach via relative /proxy/5173/ + http://localhost:N/… same, rewritten to the relative /proxy/N/… form + https://… external URL — attached verbatim + + --window [name] spawn a standalone iframe window instead of attaching to the + caller's own window. Name defaults from the target basename. + --notify [msg] send a Web Push after attaching (fail-silent like rk notify), + message defaulting to "presenting " +``` + +**Behavior**: resolve target → derive the `@rk_url` value → `tmux set-option -w @rk_url ` on the caller's own window (located via `$TMUX_PANE`, e.g. `tmux display-message -t "$TMUX_PANE" -p '#{window_id}'`; reuse `internal/tmux` option setters — `SetWindowOption` at `internal/tmux/tmux.go:1608` ff.) → print the resolved URL to stdout. + +It **NEVER opens the tile for the viewer** — layout is per-viewer client state (`docs/specs/surface-layout.md` R7/L3); availability (the rail's web button lights via SSE option polling) plus optional `--notify` is the whole contract. + +**Exit codes** follow the toolkit convention: `0` success, `1` operational failure (not in tmux, file missing, server unreachable where required), `2` usage; only the `--notify` send is fail-silent (matching `rk notify`'s documented exception). + +### 2. Serving design (DECIDED by the user — "option 3", tmux-option-derived serving) + +For file/directory targets there is NO spawned static server and NO spool copy. Instead: + +- `rk present` sets a second window option `@rk_present_root=` (the file's parent dir, or the directory itself) alongside `@rk_url=/present//?v=`. +- A new Go route `/present/{windowId}/…` on the rk server (registered beside the existing `/proxy/{port}/*` routes in `api/router.go:716-718`; handler beside `api/proxy.go`) resolves that window's `@rk_present_root` **AT REQUEST TIME** (derive-from-tmux, Constitution II/X native — the serve root is an ephemeral fact about what the pane is presenting, lives in tmux, dies with the window; no registration state, no GC, no new disk store) and serves the requested file from under it. MIME by extension — PNG/PDF/HTML just work. +- **Security (Constitution I, critical)**: the handler serves ONLY when the option is present and absolute; it must resolve symlinks and verify the RESOLVED path stays contained under the RESOLVED root — a **containment check, not a lexical prefix ban** (the code-server tarball extraction lesson: real trees carry legitimate intra-tree symlinks; lexical prefix checks are both too weak and too strict). The tmux socket is already the trust boundary (anyone who can set window options can already run code in panes), so no new principal is introduced — but path traversal through the web server must be impossible. +- Re-running `rk present` with the same target bumps the `?v=` cache-buster in `@rk_url`, so an already-open web tile re-navigates — "re-present is the refresh verb". Live edits to served files are visible on a plain reload too (serving is from the live filesystem). + +**Rejected alternatives** (from the design session — record for traceability): +- (a) wrapping `python3 -m http.server` — port-picking, python dependency, orphan-process lifecycle; +- (b) spool-copy under `$XDG_STATE_HOME/rk/present/` served by the daemon — adds GC + size-cap problems, kills live iteration, sits awkwardly against Constitution II's write-only/seed-cache carve-outs; +- (c) auto-opening the tile for the viewer — violates per-viewer layout (R7/L3). + +### 3. `--window` fallback (the residual case) + +Spawns a standalone iframe window (new tmux window + `@rk_type=iframe` + `@rk_url`) — `--window` is the **one remaining legitimate producer** of the `@rk_type=iframe` default-view hint. Criteria (state them in the skill page): external URLs with no owning pane; a SECOND simultaneous mock (one `@rk_url` per window, one web tile per surface kind in layout v1); content that deserves its own board-pinnable identity. Window name defaults from target basename (sanitized per tmux name validation — no colons/periods, see the existing "Open in window" handler's `port-{port}` naming precedent). + +### 4. `--notify` scope decision + +v1 ships plain-text notify only (`rk notify` machinery as-is — `cmd/rk/notify.go` / `internal/push`). A deep-link click-through (`rk notify --url` carrying e.g. `/$server/$window?layout=split-h:tty,web`, requiring a URL field in the push payload + service-worker click-handler change) is **DELIBERATELY DEFERRED** to a follow-up change so `rk present` doesn't block on it. + +### 5. Attach semantics / edge notes + +- `@rk_url` is a window-level option: on multi-pane windows, last write wins (accepted; note in docs). +- Attaching a URL must NOT steal the window's default view — the frontend's HINT_ORDER gives a `web` default hint only via `@rk_type=iframe`, so a tty-led window stays tty-led and the web tile is additive. This is **existing frontend behavior; the change should verify, not modify it** (no frontend code changes). +- `:port` / localhost-URL targets involve no serving at all — pure attach via the relative `/proxy//…` form (never compose absolute origins; the relative form is the documented convention, see `skill/display.md` § Proxy). +- SSE picks up option changes automatically — no API call needed to make the rail button appear. + +### 6. The `rk skill` rewrite (IN SCOPE — user explicitly confirmed) + +1. **Core bundle** (`rk skill`, `app/backend/cmd/rk/skill/skill.md`): the iframe-windows capability bullet and the 4-step Visual Display Recipe collapse to `rk present ` one-liners plus the existing gate (`command -v rk` + `$TMUX_PANE`). Recipe becomes: generate → `rk present` → optionally `--notify`. +2. **`rk skill display` topic page** (`app/backend/cmd/rk/skill/display.md`): restructured around present — target forms and what each resolves to; attach-vs-standalone criteria (the § Two Species logic stated for agents); the explicit expectation "you cannot open the tile for the user — availability appears on the rail; use `--notify` when the user may be away"; iteration (re-present = refresh); a short appendix keeping the raw manual `@rk_url` attach path for older rk versions. +3. **No version-skew machinery needed**: the skill bundle ships inside the binary, so an rk that has `present` is the same rk whose pages teach it. + +### 7. Constraints + +- Constitution I: all subprocess calls `exec.CommandContext` with argument slices + timeouts; validate user-provided names; the `/present/` route containment is security-critical. +- Constitution II/X: no persistent registration state; serve root derived from tmux at request time. +- Constitution IX: any new mutating endpoint would be POST — but this change should need **no new mutating API**; `/present/{windowId}/…` is a GET content route (like `/proxy/{port}/`). +- Toolkit standards (Constitution § Toolkit Standards): new CLI surface must be checked against `shll standards` (help-dump, readme-extraction, skill page conventions, ten principles incl. `--quiet`); README/docs-site updates per the readme-extraction standard if applicable. +- Tests: Go tests for target parsing, URL derivation, and the containment handler (table-driven traversal/symlink cases); the skill-page content is embedded and covered by the existing byte-stability tests in `cmd/rk/skill_test.go` (fixtures updated in the same commit). + +## Affected Memory + +- `run-kit/architecture`: (modify) new `rk present` CLI subcommand, the `/present/{windowId}/` GET content route, and the tmux-option-derived (request-time) serving model +- `run-kit/toolkit-standards`: (modify) new CLI surface conformance — help-dump, readme-extraction, skill topic pages, ten-principles check for `rk present` +- `run-kit/tmux-sessions`: (modify) the new `@rk_present_root` window-option convention and the `@rk_url` attach semantics (last-write-wins, re-present cache-buster) — only if hydrate finds spec-level attach-semantics notes belong here rather than architecture + +## Impact + +- **Go backend** (`app/backend/`): new cobra command `cmd/rk/present.go` (+ test); target-resolution logic likely in a small new `internal/present` package (parse file/dir/`:port`/localhost-URL/external-URL forms, derive `@rk_url` values); new `api/present.go` handler + route registration in `api/router.go` beside the proxy routes; reuse `internal/tmux` window-option setters (`SetWindowOption`/`SetWindowOptions`) — no new tmux primitives expected. +- **Skill bundle content**: `app/backend/cmd/rk/skill/skill.md` and `app/backend/cmd/rk/skill/display.md` (embedded in the binary); `cmd/rk/skill_test.go` fixtures. +- **Docs/README**: whatever the readme-extraction standard requires for a new user-facing subcommand. +- **No frontend changes**: HINT_ORDER / web-tile behavior is verified, not modified. No new mutating API endpoints. +- **Tests**: table-driven Go tests — target parsing, URL derivation, containment handler (traversal + symlink cases, per the code-server tarball lesson: test containment semantics, not lexical prefixes). + +## Open Questions + +None — the design session resolved the load-bearing decisions (serving strategy, `--notify` scope, skill-rewrite scope, security posture); residual interpretation gaps are graded in Assumptions below. + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Certain | Serving via tmux-option-derived `/present/{windowId}/…` route reading `@rk_present_root` at request time — no spawned static server, no spool copy, no registration state | Discussed — user explicitly chose "option 3"; Constitution II/X native | S:95 R:70 A:90 D:95 | +| 2 | Certain | `rk present` never opens the tile for the viewer; availability (rail button via SSE) + optional `--notify` is the whole contract | Discussed — per-viewer layout is spec law (surface-layout R7/L3); alternative (c) explicitly rejected | S:95 R:80 A:90 D:95 | +| 3 | Certain | `--notify` v1 is plain-text only; deep-link click-through deferred to a follow-up change | Discussed — user deliberately deferred so present doesn't block on push-payload/service-worker changes | S:95 R:90 A:90 D:95 | +| 4 | Certain | The `rk skill` core-bundle + `rk skill display` rewrite (incl. manual `@rk_url` appendix for older rk) is in scope for this change | Discussed — user explicitly confirmed | S:95 R:85 A:90 D:95 | +| 5 | Certain | `/present/` security = resolve symlinks and verify RESOLVED path contained under RESOLVED root; never a lexical prefix check; serve only when option present and absolute | Discussed + recorded project lesson (code-server tarball symlinks); Constitution I | S:90 R:60 A:90 D:90 | +| 6 | Certain | `--window` is the one remaining legitimate producer of `@rk_type=iframe`; criteria (external URL with no owning pane / second simultaneous mock / board-pinnable identity) stated in the skill page | Discussed — matches window-views § Two Species migration map | S:90 R:80 A:85 D:90 | +| 7 | Certain | Exit codes `0`/`1`/`2` per toolkit convention; only the `--notify` send is fail-silent | Documented convention in the existing skill bundle; `rk notify` is the named exception | S:85 R:90 A:95 D:90 | +| 8 | Certain | Re-present bumps the `?v=` cache-buster so an open web tile re-navigates; live filesystem serving means plain reload also sees edits | Discussed — "re-present is the refresh verb" | S:90 R:85 A:85 D:85 | +| 9 | Certain | `--window` default name derives from target basename, sanitized per existing tmux name validation (no colons/periods; `port-{port}` precedent) | Discussed with explicit precedent pointer; `internal/validate` exists | S:80 R:85 A:85 D:85 | +| 10 | Certain | No frontend modification: HINT_ORDER's additive web tile (tty-led stays tty-led) is verified, not changed | Explicit in the design: "the change should verify, not modify it" | S:90 R:85 A:85 D:90 | +| 11 | Certain | Skill content lives at `app/backend/cmd/rk/skill/{skill.md,display.md}` (embedded); byte-stability covered by existing `cmd/rk/skill_test.go`, fixtures updated in-change | Verified in repo during intake | S:85 R:90 A:95 D:95 | +| 12 | Certain | MIME resolution by file extension via Go stdlib (`mime.TypeByExtension` / `http.ServeContent`-style serving) | One obvious stdlib default; trivially reversible | S:60 R:90 A:85 D:80 | +| 13 | Confident | tmux server identity must ride the presented URL (window IDs `@N` are unique only per tmux server): keep the decided `/present//` path shape and carry the server as a query param (e.g. `?server=`, matching the frontend's existing `withServer` convention); the CLI derives its server from `$TMUX` | Gap not addressed in the design session; multi-server enumeration is core to rk serve, so the handler cannot resolve `@N` alone; front-runner follows the existing query-param convention | S:50 R:65 A:60 D:55 | +| 14 | Confident | `` in the URL is the tmux `window_id` (`@N`), derived from the caller via `tmux display-message -t "$TMUX_PANE" -p '#{window_id}'` | Natural unique id; `internal/tmux` targets windows by `@N` throughout | S:75 R:75 A:85 D:80 | +| 15 | Confident | Directory targets default to `index.html`; no directory listing in v1; missing file under the root → 404 | "(index.html default)" stated; listing is unrequested surface with security cost | S:55 R:80 A:70 D:60 | +| 16 | Confident | Target-resolution logic lives in a small new `internal/present` package; the route handler lives beside `api/proxy.go` | Design says "likely a small internal package"; matches repo layout conventions | S:70 R:75 A:80 D:75 | +| 17 | Confident | `:port` / localhost-URL targets get a best-effort TCP reachability probe; connection refused → exit 1 | Interprets "server unreachable where required" in the exit-code contract; probe is cheap and matches the operational-failure semantics | S:45 R:85 A:55 D:50 | +| 18 | Confident | Only localhost/`127.0.0.1` absolute URLs rewrite to the relative `/proxy//…` form; any other absolute URL (http or https) attaches verbatim | Design names `https://…` external-verbatim and `http://localhost:N` rewrite; non-localhost http has no proxy port to map to | S:55 R:85 A:70 D:65 | +| 19 | Confident | `--window` composes with file/dir targets: the new window gets `@rk_type=iframe`, `@rk_url`, AND `@rk_present_root` | Serving design is orthogonal to which window carries the options; nothing in the design forbids it | S:65 R:80 A:75 D:70 | +| 20 | Confident | The `?v=` cache-buster applies to `/present/` URLs only; `:port`/URL targets re-set `@rk_url` verbatim without a buster | Buster is described inside the serving design; appending `?v=` to an arbitrary app URL could break query-sensitive apps | S:45 R:80 A:55 D:50 | + +20 assumptions (12 certain, 8 confident, 0 tentative, 0 unresolved). diff --git a/fab/changes/260813-becu-rk-present-attach-verb/plan.md b/fab/changes/260813-becu-rk-present-attach-verb/plan.md new file mode 100644 index 000000000..f4552b377 --- /dev/null +++ b/fab/changes/260813-becu-rk-present-attach-verb/plan.md @@ -0,0 +1,257 @@ +# Plan: `rk present` — one-verb "show this to the user" + skill page rewrite + +**Change**: 260813-becu-rk-present-attach-verb +**Intake**: `intake.md` + +## Requirements + +### CLI: `rk present` target resolution + +#### R1: Target forms resolve deterministically +`rk present ` MUST accept exactly one positional target and resolve it to one of five kinds, each deriving an `@rk_url` value: + +| Kind | Recognized as | Derived `@rk_url` | +|------|---------------|-------------------| +| file | existing regular file path | `/present//?server=&v=` (+ `@rk_present_root` = file's absolute parent dir) | +| dir | existing directory path | `/present//?server=&v=` (+ `@rk_present_root` = the absolute dir) | +| port | `:NNNN` (colon + digits) | `/proxy//` | +| local URL | absolute `http://` URL whose host is `localhost`, `127.0.0.1`, or `[::1]` | `/proxy//` (port = explicit, else 80) | +| external URL | any other absolute `http(s)://` URL | attached verbatim | + +A path that does not exist (and does not parse as a port or URL) MUST be an operational failure (exit 1). Target parsing SHALL live in a new `internal/present` package as a pure function, unit-testable without tmux. + +- **GIVEN** a file `./mock.html` exists in cwd +- **WHEN** `rk present ./mock.html` runs +- **THEN** the target resolves to kind `file` with root = absolute cwd and URL path `mock.html` + +- **GIVEN** the argument `:5173` +- **WHEN** the target resolves +- **THEN** the derived URL is `/proxy/5173/` and no `@rk_present_root` is involved + +- **GIVEN** the argument `http://localhost:8080/docs?x=1` +- **WHEN** the target resolves +- **THEN** the derived URL is `/proxy/8080/docs?x=1` (relative form — never an absolute origin) + +- **GIVEN** the argument `https://staging.example.com/app` +- **WHEN** the target resolves +- **THEN** the URL attaches verbatim + +#### R2: Port/local-URL targets get a best-effort reachability probe +For `port` and `local URL` kinds, the command MUST probe TCP reachability of `127.0.0.1:` with a short timeout (~1s); connection refused/timeout MUST exit 1 with a diagnostic on stderr. File/dir/external targets are never probed. + +- **GIVEN** nothing listens on port 59999 +- **WHEN** `rk present :59999` runs +- **THEN** exit code is 1 and stderr names the unreachable port + +### CLI: attach behavior + +#### R3: Default arm attaches to the caller's own window +Without `--window`, `rk present` MUST derive the caller's pane from `$TMUX_PANE` and its window id (`@N`) and tmux server from the pane (via `tmux display-message` using socket args derived from `tmux.OriginalTMUX`, the pattern `cmd/rk/agent_hook.go` uses), then set `@rk_url` (and, for file/dir targets, `@rk_present_root`) on that window via the existing `internal/tmux` window-option primitives (`SetWindowOption`/`SetWindowOptions`). It MUST print the resolved `@rk_url` value to stdout (data contract — printed even under `--quiet`). It MUST NOT create windows, POST to any API, or attempt to open/change any viewer's tile or layout. Running outside tmux (no `$TMUX_PANE`) without `--window` MUST exit 1. + +- **GIVEN** a shell inside a tmux pane on server `dev`, window `@7` +- **WHEN** `rk present ./mock.html` runs +- **THEN** window `@7` carries `@rk_present_root=` and `@rk_url=/present/@7/mock.html?server=dev&v=`, and stdout is that URL +- **AND** no new window exists and no HTTP call was made to the rk server + +#### R4: Re-present is the refresh verb +The `?v=` cache-buster (unix-seconds value) MUST be appended only to `/present/` URLs, so re-running `rk present` on the same file/dir target writes a different `@rk_url` and an open web tile re-navigates. Port/URL targets re-set `@rk_url` verbatim with no buster. + +- **GIVEN** window `@7` already carries `@rk_url=/present/@7/mock.html?server=dev&v=100` +- **WHEN** `rk present ./mock.html` runs again later +- **THEN** `@rk_url` differs only in its `v=` value + +#### R5: `--window` spawns the standalone fallback +`--window[=name]` MUST create a new tmux window in the caller's session (exact-match `=session:` target) via `tmux.CreateWindowWithOptions`, atomically setting `@rk_type=iframe`, `@rk_url`, and — for file/dir targets — `@rk_present_root` (with `` in the URL being the NEW window's id, so creation resolves the id first or uses the primitive's returned id). The window name defaults from the target basename, sanitized to pass `internal/validate` name rules (colons/periods replaced with `-`, following the `port-{port}` precedent). `--window` MUST work outside a tmux pane only when a session can be resolved; inside tmux it targets the caller's current session. + +- **GIVEN** a pane in session `work` and the argument `https://staging.example.com` +- **WHEN** `rk present --window https://staging.example.com` runs +- **THEN** a new window exists in session `work` with `@rk_type=iframe` and `@rk_url=https://staging.example.com`, and its name derives from the URL host (sanitized) + +#### R6: `--notify` is optional and fail-silent +`--notify[=msg]` MUST send a Web Push through the same machinery as `rk notify` (message defaulting to `presenting `), after the attach succeeds. The send is fail-silent per `rk notify`'s documented contract: any send failure exits 0 and prints nothing. v1 is plain text only — no deep-link/url field (deferred to a follow-up change). + +- **GIVEN** the rk server is unreachable +- **WHEN** `rk present ./mock.html --notify` runs in a tmux pane +- **THEN** the attach succeeds, the URL prints, and the exit code is 0 despite the failed push + +#### R7: Exit codes follow the toolkit convention +`0` success; `1` operational failure (not in tmux without `--window`, missing file, unreachable port, tmux command failure); `2` usage error (no target, unknown flag, both invalid). Only the `--notify` send deviates (fail-silent, R6). Diagnostics go to stderr; stdout carries only the URL. + +- **GIVEN** no arguments +- **WHEN** `rk present` runs +- **THEN** exit code is 2 with usage on stderr + +### API: the `/present/{windowId}/` content route + +#### R8: Serving is derived from tmux at request time +The server MUST register `GET /present/{windowId}/*` (and the bare `/present/{windowId}` → trailing-slash redirect, mirroring the proxy routes) beside the proxy routes in `api/router.go`. The handler (new `api/present.go`) MUST: validate `windowId` matches `^@[0-9]+$` before any subprocess call; resolve the tmux server via the existing `serverFromRequest` helper (`?server=` query param, `default` fallback); read the window's `@rk_present_root` option from tmux AT REQUEST TIME (a new `internal/tmux` window-option getter mirroring `SetWindowOption`, `exec.CommandContext` with the 5s tmux timeout tier); and serve the requested file from under that root. No cache, no registration state, no disk store — a dead window or unset option is a 404. Responses set MIME by extension via the Go stdlib (`http.ServeContent`/`ServeFile` semantics). + +- **GIVEN** window `@7` on server `dev` carries `@rk_present_root=/home/u/mocks` +- **WHEN** `GET /present/@7/mock.html?server=dev` arrives +- **THEN** `/home/u/mocks/mock.html` is served with `Content-Type: text/html` + +- **GIVEN** window `@7` carries no `@rk_present_root` +- **WHEN** `GET /present/@7/mock.html` arrives +- **THEN** the response is 404 + +#### R9: Containment, not lexical prefixes +The handler MUST refuse to serve unless: the option value is an absolute path; the resolved (symlink-evaluated) requested file stays contained under the resolved root (checked via `filepath.Rel` on the two `EvalSymlinks` results — never a lexical prefix/`..` string ban, per the code-server tarball lesson: intra-tree symlinks are legitimate, escaping ones are not). Directory requests (`/` or a path resolving to a directory) serve `index.html` under that directory or 404 — never a directory listing. Traversal attempts (`..`, encoded variants, symlinks pointing outside the root) MUST yield 404 without touching files outside the root. + +- **GIVEN** root `/home/u/mocks` containing `link.html → ./real.html` +- **WHEN** `GET /present/@7/link.html` arrives +- **THEN** it serves (intra-tree symlink allowed) + +- **GIVEN** root `/home/u/mocks` containing `evil → /etc` +- **WHEN** `GET /present/@7/evil/passwd` arrives +- **THEN** the response is 404 and `/etc/passwd` is never read + +- **GIVEN** any request path containing `..` segments that would escape the root +- **WHEN** the handler resolves it +- **THEN** the response is 404 + +### Skill bundle: teach the new verb + +#### R10: Core bundle rewrite +`docs/site/skill.md` (canonical; synced to `app/backend/cmd/rk/skill/skill.md` by `scripts/sync-skill.sh`) MUST replace the iframe-windows capability bullet and the 4-step Visual Display Recipe with `rk present`: the capability line becomes a one-liner (`rk present ` — attach web content beside your own terminal), and the recipe becomes generate → `rk present` → optionally `--notify`. The existing gate (`command -v rk` + `$TMUX_PANE`), output/exit-code contracts section (extended with `rk present`'s codes), and the ≤150-line budget MUST be preserved. + +- **GIVEN** the rewritten bundle +- **WHEN** `rk skill` prints it +- **THEN** it teaches `rk present` as the canonical visual-display path, stays ≤150 lines, and no longer instructs agents to create `@rk_type=iframe` windows as the primary recipe + +#### R11: `display` topic page rewrite +`docs/site/skill/display.md` (canonical; synced to `app/backend/cmd/rk/skill/display.md`) MUST be restructured around `rk present`: target forms and what each resolves to; attach-vs-standalone criteria (external URL with no owning pane / second simultaneous mock / board-pinnable identity → `--window`); the explicit expectation "you cannot open the tile for the user — availability appears on the rail; use `--notify` when the user may be away"; iteration ("re-present is the refresh verb"; live filesystem serving means plain reload sees edits); and a short appendix keeping the raw manual `@rk_type`/`@rk_url` window recipe for older rk versions. ≤150-line budget preserved. + +- **GIVEN** the rewritten topic page +- **WHEN** `rk skill display` prints it +- **THEN** the primary recipe is `rk present`, the standalone-window criteria are stated, and the manual appendix survives for version skew + +### Toolkit standards & docs + +#### R12: New CLI surface conforms to toolkit standards +The new subcommand MUST conform to the standards governing changed surfaces: `help-dump` (the command appears in the machine-readable tree; any pinned goldens/tests updated), `principles` (stdout=data/stderr=diagnostics, `--quiet` suppresses only decoration — the URL still prints, exit codes per R7), and `readme-extraction` (README/docs-site command documentation updated as that standard requires). Check each with `shll standards ` before finalizing. + +- **GIVEN** the finished change +- **WHEN** `rk help-dump` runs +- **THEN** `present` appears with its flags, and the help-dump tests pass + +### Non-Goals + +- No `rk notify --url` deep-link click-through (deferred follow-up — push payload + service-worker change) +- No frontend changes: HINT_ORDER's additive web tile behavior is verified by inspection, not modified; no new mutating API endpoints +- No directory listing on `/present/` (index.html or 404) +- No multi-mock support on one window (one `@rk_url` per window; `--window` is the escape) +- No spawned static server, no spool/copy store, no GC + +### Design Decisions + +#### Tmux-option-derived serving +**Decision**: File/dir targets are served by a new `/present/{windowId}/` GET route that resolves the window's `@rk_present_root` option from tmux at request time; `rk present` only sets window options. +**Why**: Constitution II/X native — the serve root is an ephemeral fact about what the pane is presenting; it lives in tmux, dies with the window, needs no registration state, no GC, no new disk store; live edits are visible on reload. +**Rejected**: wrapping `python3 -m http.server` (port-picking, python dependency, orphan-process lifecycle); spool-copy under `$XDG_STATE_HOME/rk/present/` (GC + size caps, kills live iteration, strains Constitution II's carve-outs). +*Introduced by*: 260813-becu-rk-present-attach-verb + +#### The verb never opens the tile +**Decision**: `rk present` sets availability only; which tile a viewer opens stays per-viewer client state. +**Why**: surface-layout spec R7/L3 — layout is per-viewer, URL+localStorage; a server-side push would recreate the `@rk_type`-mutation conflation the lens model just retired. +**Rejected**: auto-opening the tile (violates per-viewer layout); server-pushed "suggested layout" (creep). +*Introduced by*: 260813-becu-rk-present-attach-verb + +## Tasks + +### Phase 1: Setup + +- [x] T001 Create `app/backend/internal/present/present.go`: `Target` type + `ParseTarget(arg, cwd string) (Target, error)` covering the five kinds of R1 (file/dir/port/local-URL/external-URL, localhost-host set, default port 80, verbatim external), plus URL-derivation helpers (`PresentURL(windowID, name, server string, now func() int64)`, proxy-form composition preserving path+query). Pure functions, no tmux. +- [x] T002 [P] Add table-driven tests `app/backend/internal/present/present_test.go`: every target form, nonexistent path → error, localhost variants (`localhost`, `127.0.0.1`, `[::1]`, explicit/default port, path+query preserved), external https verbatim, `?v=` only on `/present/` URLs. + +### Phase 2: Core Implementation + +- [x] T003 Add a window-option getter to `app/backend/internal/tmux/tmux.go` (beside `SetWindowOption` at ~:1608): `GetWindowOption(ctx, windowID, server, option string) (string, error)` via `show-options -w -qv`, `exec.CommandContext` + 5s timeout, with a unit test alongside existing option-primitive tests. Reuse an existing equivalent instead if one already exists (verify first). +- [x] T004 Create `app/backend/cmd/rk/present.go`: cobra command `present ` — derive pane/window-id/server-name from `$TMUX_PANE` + `tmux.OriginalTMUX` (agent_hook.go's socket-args pattern; server name = socket basename, `default` for the default socket), run `ParseTarget`, probe reachability for port/local-URL kinds (R2), set `@rk_url` (+ `@rk_present_root` for file/dir) via `tmux.SetWindowOptions`, print the URL to stdout, exit codes per R7. Register in `root.go` if registration is explicit. +- [x] T005 Implement the `--window[=name]` arm in `cmd/rk/present.go`: resolve the caller's session, derive/sanitize the default name from the target basename (`internal/validate` conformant, `port-{port}` precedent), create via `tmux.CreateWindowWithOptions` with `@rk_type=iframe` + `@rk_url` + (file/dir) `@rk_present_root`, using the new window's id in the `/present/` URL. +- [x] T006 Implement `--notify[=msg]`: extract the send logic of `cmd/rk/notify.go` into a reusable helper (same file or shared location), call it after a successful attach with default message `presenting `, fail-silent (exit 0, no output on failure). +- [x] T007 Create `app/backend/api/present.go`: `handlePresent` — `windowId` regexp gate (`^@[0-9]+$`), `serverFromRequest`, request-time `tmux.GetWindowOption` for `@rk_present_root`, absolute-root check, symlink-resolved containment (`filepath.EvalSymlinks` both sides + `filepath.Rel`), dir → `index.html`, misses/escapes/errors → 404, stdlib MIME serving. Register `/present/{windowId}` + `/present/{windowId}/*` in `api/router.go` beside the proxy routes (~:716) with the same trailing-slash redirect pattern. + +### Phase 3: Integration & Edge Cases + +- [x] T008 Add `app/backend/api/present_test.go`: table-driven handler tests over a temp fixture tree — plain file serves with correct MIME; dir serves `index.html`; missing file 404; unset option 404; relative root 404; `..` traversal 404; intra-tree symlink serves; escaping symlink 404 (target file outside root must remain unread); invalid windowId 400/404 without subprocess; `?server=` validation falls back to `default`. +- [x] T009 Add `app/backend/cmd/rk/present_test.go`: command-level tests with seams (no live tmux where avoidable) — flag/usage errors exit 2; missing `$TMUX_PANE` without `--window` exits 1; unreachable port exits 1; option-set composition for each target kind; `--notify` failure still exits 0; stdout carries exactly the URL. + +### Phase 4: Polish + +- [x] T010 Rewrite `docs/site/skill.md` per R10 and run `scripts/sync-skill.sh` (or `go generate ./cmd/rk`) so `app/backend/cmd/rk/skill/skill.md` matches; keep ≤150 lines; verify `TestSkillEmbedMatchesCanonical` and the line-budget test pass. +- [x] T011 Rewrite `docs/site/skill/display.md` per R11 and sync `app/backend/cmd/rk/skill/display.md`; keep ≤150 lines; verify `TestSkillDisplayEmbedMatchesCanonical` passes. +- [x] T012 Standards + docs conformance: run `shll standards help-dump`, `shll standards readme-extraction`, `shll standards principles`; update `rk help-dump` goldens/tests if pinned, README/docs-site command documentation as required; confirm `--quiet` still prints the URL (data). Then run the full backend gate `cd app/backend && go test ./...`. + +## Execution Order + +- T001 blocks T002 (tests target the parser) and T004 (command consumes it) +- T003 blocks T004/T005 (getter/primitives used by command) and T007 (handler reads the option) +- T007 blocks T008; T004–T006 block T009 +- T010–T012 are independent of each other, after core lands + +## Acceptance + +### Functional Completeness + +- [x] A-001 R1: All five target forms resolve per the table (file, dir, `:port`, localhost http URL, external URL), with a nonexistent-path exit 1; covered by `internal/present` unit tests +- [x] A-002 R3: Default arm sets `@rk_url` (and `@rk_present_root` for file/dir) on the caller's own window and prints the URL; no window creation, no API calls, no layout mutation +- [x] A-003 R5: `--window` creates a session-local window with `@rk_type=iframe` + `@rk_url` (+ root option when applicable), name derived/sanitized from the target +- [x] A-004 R6: `--notify` sends via the shared notify machinery with the default message and is fail-silent +- [x] A-005 R8: `GET /present/{windowId}/…` serves from the request-time-derived `@rk_present_root` with stdlib MIME; unset option or dead window → 404; route registered beside the proxy routes + +### Behavioral Correctness + +- [x] A-006 R4: Re-presenting the same file/dir target changes only the `?v=` value; port/URL targets carry no buster +- [x] A-007 R2: Port/local-URL targets probe reachability; refusal exits 1; file/dir/external targets never probe +- [x] A-008 R7: Exit codes are 0/1/2 per the toolkit convention; stdout carries only the URL (also under `--quiet`); diagnostics on stderr + +### Scenario Coverage + +- [x] A-009 R1: localhost URL rewrite preserves path+query and never composes an absolute origin (test exists) +- [x] A-010 R10: `rk skill` output teaches `rk present` as the primary visual-display recipe; byte-stability + line-budget tests pass against the resynced bundle +- [x] A-011 R11: `rk skill display` output leads with `rk present`, states the attach-vs-standalone criteria and the "cannot open the tile" expectation, and keeps the older-rk manual appendix + +### Edge Cases & Error Handling + +- [x] A-012 R9: Containment test table covers: `..` traversal 404, escaping symlink 404 (outside file unread), intra-tree symlink 200, relative/unset root 404, dir → index.html or 404 (no listing) +- [x] A-013 R8: Invalid `windowId` (fails `^@[0-9]+$`) is rejected before any tmux subprocess runs +- [x] A-014 R3: Running without `$TMUX_PANE` and without `--window` exits 1 with a diagnostic + +### Code Quality + +- [x] A-015 Pattern consistency: new Go code follows surrounding conventions (cobra command shape, `internal/` package layout, chi route registration style, table-driven tests) +- [x] A-016 No unnecessary duplication: reuses `internal/tmux` option primitives, `internal/validate`, `serverFromRequest`, and the notify send path instead of reimplementing +- [x] A-017 All subprocess calls use `exec.CommandContext` with argument slices and the 5s tmux timeout tier; no shell strings (code-quality + Constitution I) +- [x] A-018 No polling, no in-memory caches, no new state stores (code-quality: derive from tmux + filesystem) + +### Security + +- [x] A-019 R9: Path traversal through `/present/` is impossible — verified by the symlink/traversal test table; the handler never serves without a present, absolute `@rk_present_root` + +## Notes + +- Check items as you review: `- [x]` +- All acceptance items must pass before `/fab-continue` (hydrate) +- If an item is not applicable, mark checked and prefix with **N/A**: `- [x] A-NNN **N/A**: {reason}` + +## Deletion Candidates + +- None — this change adds new functionality without making existing code redundant. The retired 4-step synthetic-iframe-window recipe was prose in the skill pages and was already removed in-diff (`docs/site/skill.md`, `docs/site/skill/display.md`); no Go code, endpoint, or frontend path became unused (the frontend iframe-window creation path stays — it backs the palette's "Window: New Iframe Window" and the ports "Open in window" action). + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Confident | `--window`/`--notify` optional values via cobra `NoOptDefVal` (so bare `--window` works; a value uses `--window=name` syntax), help text showing `--window[=name]` | Cobra's only mechanism for optional flag values; matches the intake's `--window [name]` shape as closely as cobra allows | S:55 R:85 A:75 D:60 | +| 2 | Confident | Server name for `?server=` = basename of the socket path in `tmux.OriginalTMUX`; `default` when it matches the default socket; matches `serverFromRequest`/`ListServers` naming | ListServers derives names from socket basenames in `/tmp/tmux-{uid}/`; agent_hook targets via the same env capture | S:70 R:80 A:80 D:75 | +| 3 | Confident | Local-URL rewrite covers `http://` scheme only with hosts `localhost`/`127.0.0.1`/`[::1]`; explicit port or default 80; `https://localhost` attaches verbatim | The plaintext proxy targets local http services; https-to-local through the proxy is an untested edge not worth v1 surface | S:55 R:85 A:70 D:65 | +| 4 | Confident | `?v=` buster value is unix seconds at invocation | Simple, monotonic enough for the refresh-verb semantics; opaque to the frontend | S:60 R:90 A:85 D:80 | +| 5 | Confident | A window-option getter (`GetWindowOption`) is added to `internal/tmux` (none exists today per grep); mirrors `SetWindowOption`'s signature and timeout | Setter exists at tmux.go:1608 with no read counterpart; handler needs a request-time read | S:70 R:85 A:80 D:80 | +| 6 | Confident | Stdout prints the relative `@rk_url` value only (no absolute origin) — including under `--quiet` | Relative form is the documented convention; stdout-is-data per toolkit principles | S:65 R:85 A:80 D:75 | +| 7 | Certain | Skill rewrite edits the canonical `docs/site/skill{,.md,/display.md}` files and resyncs the embedded copies via `scripts/sync-skill.sh`; both stay ≤150 lines | Verified embed + drift-guard mechanism in `cmd/rk/skill.go` / `skill_test.go` | S:90 R:90 A:90 D:90 | +| 8 | Certain | Bare `--window`/`--notify` use a non-empty NoOptDefVal sentinel (`\x00auto`), distinguished from explicit values by equality and from absence by `Changed()` | Cobra ignores an EMPTY NoOptDefVal (the flag then consumes the next positional arg — caught by command tests); an untypable sentinel is the working shape of assumption 1 | S:90 R:90 A:90 D:90 | +| 9 | Confident | New `tmux.CreateWindowWithOptionsID` (new-window `-P -F '#{window_id}'`) returns the fresh id; file/dir `--window` is two-step (create with `@rk_type` atomically, then set `@rk_url`+`@rk_present_root` on the returned id) | The `/present/` URL embeds the new window's id, which cannot be known when composing the creation argv; a one-poll-cycle transient of an iframe window without URL is benign | S:70 R:80 A:75 D:70 | +| 10 | Tentative | `--window` outside a tmux pane resolves the DEFAULT server's current session via bare `tmux display-message -p '#{session_name}'` (server name `default`); failure (no server running) exits 1 | R5's "only when a session can be resolved" has no other deterministic reading — without a pane there is no socket/session context; this is tmux's own most-recent-session resolution | S:40 R:75 A:45 D:45 | +| 11 | Confident | The handler reads `@rk_present_root` through a package-level seam (`getWindowOptionFn`, the update.go pattern) instead of extending the `TmuxOps` interface | One consumer, one read: the interface change would churn `prodTmuxOps` + `mockTmuxOps` for no second caller; api already uses package-level seams for exactly this case | S:60 R:75 A:65 D:60 | + +11 assumptions (2 certain, 8 confident, 1 tentative).