From 786f8744e05e5cbc6eb7386aa36f3d86da72fa63 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:09:41 -0700 Subject: [PATCH] feat(open): add --stdin to batch resume sessions from piped IDs Resume many sessions in one shot by piping IDs into `open --stdin`, one per line. Composes with any command that emits session IDs, such as `search --ids`. Blank and `#` comment lines are skipped, only the first field of each line is read, and duplicate IDs are dropped while preserving order. Each ID is alias-resolved like single-session open. Batch resume needs an explicit launch target (`--mode tab|window|pane`) or `--print`, since inplace launch cannot host multiple sessions. A missing or unresumable ID does not abort the run: the rest resume, a `resumed N of M sessions` summary prints, and the command exits with an aggregated error. `--stdin` is rejected alongside a positional ID or `--last`. Also drains the stdout pipe on a goroutine in TestPrintUsage_Output so the grown usage banner cannot deadlock on the Windows pipe buffer. Closes #310 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/dispatch/main.go | 2 + cmd/dispatch/main_test.go | 15 +++- cmd/dispatch/open.go | 152 ++++++++++++++++++++++++++++--- cmd/dispatch/open_test.go | 183 +++++++++++++++++++++++++++++++++++++- 4 files changed, 333 insertions(+), 19 deletions(-) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..4e7eade 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -118,6 +118,8 @@ Commands: open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) --print writes the resume command instead of launching open --last [--mode M] Resume the most recently active session + open --stdin [--mode M] Resume every session ID read from standard input + (one per line; pairs with search --ids) new [dir] [--mode M] Start a new session in a directory (default: current) completion Print shell completion (bash, zsh, fish, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..56e6b55 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -457,15 +457,22 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + // Drain the pipe concurrently. The usage banner exceeds the OS pipe + // buffer (~4KB on Windows), so printUsage() would block on write if we + // only read after it returns. Reading on a goroutine avoids the deadlock. + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + printUsage() w.Close() os.Stdout = origStdout - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-done for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output) diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 7611339..1903eba 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "database/sql" "errors" @@ -22,18 +23,24 @@ var ( openGetLastSessionFn = defaultOpenGetLastSession openLaunchFn = defaultOpenLaunch openResumeCmdFn = platform.BuildResumeCommandString + + // openStdin is the reader used by --stdin batch resume. It is a package + // variable so tests can feed it a fixed list of IDs. + openStdin io.Reader = os.Stdin ) // runOpen resumes a session using the same launch path the TUI uses. It // resumes the session named by ID, or the most recently active session when // --last is passed. With --print it writes the resolved resume command to w -// and does not launch. args is the full argument slice with args[0] == "open". +// and does not launch. With --stdin it reads session IDs from standard input +// (one per line) and resumes each, which composes with `dispatch search --ids`. +// args is the full argument slice with args[0] == "open". func runOpen(w io.Writer, args []string) error { if w == nil { w = io.Discard } - id, modeFlag, last, printCmd, err := parseOpenArgs(args) + id, modeFlag, last, printCmd, stdin, err := parseOpenArgs(args) if err != nil { return err } @@ -43,6 +50,10 @@ func runOpen(w io.Writer, args []string) error { return fmt.Errorf("loading config: %w", err) } + if stdin { + return runOpenBatch(w, cfg, modeFlag, printCmd) + } + var sess *data.Session if last { sess, err = openGetLastSessionFn() @@ -80,6 +91,109 @@ func runOpen(w io.Writer, args []string) error { return openLaunchFn(w, cfg, sess, mode) } +// runOpenBatch resumes every session whose ID is read from standard input, +// one ID per line. Blank lines and lines beginning with '#' are ignored, and +// each line's first whitespace-separated field is used as the ID, so annotated +// lists still work. It composes with `dispatch search --ids`: +// +// dispatch search --ids feat | dispatch open --stdin --mode tab +// +// Resolution and launch reuse the single-session path. A missing session or a +// launch failure for one ID does not abort the batch; failures are collected +// and returned together so the exit code is non-zero while the rest still run. +func runOpenBatch(w io.Writer, cfg *config.Config, modeFlag string, printCmd bool) error { + ids, err := readSessionIDs(openStdin) + if err != nil { + return fmt.Errorf("reading session IDs: %w", err) + } + if len(ids) == 0 { + return errors.New("open --stdin: no session IDs on standard input") + } + + mode := resolveOpenMode(modeFlag, cfg) + if !printCmd && mode == config.LaunchModeInPlace { + return errors.New("open --stdin cannot launch in inplace mode; pass --mode tab, window, or pane") + } + + var failures []error + resumed := 0 + for _, rawID := range ids { + target := rawID + if resolved := cfg.SessionIDForAlias(target); resolved != "" { + target = resolved + } + + sess, gErr := openGetSessionFn(target) + if gErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, gErr)) + continue + } + if sess == nil { + failures = append(failures, fmt.Errorf("%s: session not found", rawID)) + continue + } + + if printCmd { + cmdStr, cErr := openResumeCmdFn(sess.ID, openResumeConfig(cfg, sess)) + if cErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, cErr)) + continue + } + fmt.Fprintln(w, cmdStr) + resumed++ + continue + } + + if lErr := openLaunchFn(w, cfg, sess, mode); lErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, lErr)) + continue + } + resumed++ + } + + if len(failures) > 0 { + fmt.Fprintf(w, "resumed %d of %d sessions\n", resumed, len(ids)) + return fmt.Errorf("open --stdin: %d of %d failed: %w", + len(failures), len(ids), errors.Join(failures...)) + } + return nil +} + +// readSessionIDs reads session IDs from r, one per line. It trims surrounding +// whitespace, skips blank lines and lines starting with '#', takes the first +// whitespace-separated field of each remaining line, and drops duplicates while +// preserving first-seen order. +func readSessionIDs(r io.Reader) ([]string, error) { + if r == nil { + return nil, nil + } + + var ids []string + seen := make(map[string]struct{}) + + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tolerate long lines + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if i := strings.IndexAny(line, " \t"); i >= 0 { + line = line[:i] + } + if _, dup := seen[line]; dup { + continue + } + seen[line] = struct{}{} + ids = append(ids, line) + } + if scErr := sc.Err(); scErr != nil { + return nil, scErr + } + return ids, nil +} + // openResumeConfig builds the resume config used to launch or print a // session's resume command. Terminal, launch style, and pane direction are // omitted because they affect terminal placement, not the copilot invocation. @@ -94,9 +208,9 @@ func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeCon } // parseOpenArgs extracts the session ID, optional launch mode, the --last -// flag, and the --print flag from the "open" subcommand arguments. args[0] is -// expected to be "open". -func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err error) { +// flag, the --print flag, and the --stdin flag from the "open" subcommand +// arguments. args[0] is expected to be "open". +func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "open" token @@ -108,9 +222,11 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err err switch { case arg == "--last" || arg == "-l": last = true + case arg == "--stdin": + stdin = true case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", false, false, errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", false, false, false, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ @@ -119,33 +235,41 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err err case arg == "--print": printCmd = true case strings.HasPrefix(arg, "-"): - return "", "", false, false, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, false, false, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } } - if last { + switch { + case stdin: + if last { + return "", "", false, false, false, errors.New("open --stdin cannot be combined with --last") + } if len(positionals) > 0 { - return "", "", false, false, errors.New("open --last does not take a session ID") + return "", "", false, false, false, errors.New("open --stdin reads session IDs from standard input; do not also pass an ID") } - } else { + case last: + if len(positionals) > 0 { + return "", "", false, false, false, errors.New("open --last does not take a session ID") + } + default: switch len(positionals) { case 0: - return "", "", false, false, errors.New("open requires a session ID (or use --last)") + return "", "", false, false, false, errors.New("open requires a session ID (or use --last or --stdin)") case 1: id = positionals[0] default: - return "", "", false, false, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) + return "", "", false, false, false, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) } } if mode != "" { if _, mErr := normalizeLaunchMode(mode); mErr != nil { - return "", "", false, false, mErr + return "", "", false, false, false, mErr } } - return id, mode, last, printCmd, nil + return id, mode, last, printCmd, stdin, nil } // normalizeLaunchMode maps a user-facing mode string to a config launch mode. diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index cc2aa37..808220d 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -21,6 +21,7 @@ func TestParseOpenArgs(t *testing.T) { wantMode string wantLast bool wantPrint bool + wantStdin bool wantErr bool }{ {name: "id only", args: []string{"open", "abc123"}, wantID: "abc123"}, @@ -34,17 +35,22 @@ func TestParseOpenArgs(t *testing.T) { {name: "print flag", args: []string{"open", "abc", "--print"}, wantID: "abc", wantPrint: true}, {name: "print before id", args: []string{"open", "--print", "abc"}, wantID: "abc", wantPrint: true}, {name: "print with mode", args: []string{"open", "abc", "--print", "--mode", "tab"}, wantID: "abc", wantMode: "tab", wantPrint: true}, + {name: "stdin", args: []string{"open", "--stdin"}, wantStdin: true}, + {name: "stdin with mode", args: []string{"open", "--stdin", "--mode", "tab"}, wantStdin: true, wantMode: "tab"}, + {name: "stdin with print", args: []string{"open", "--stdin", "--print"}, wantStdin: true, wantPrint: true}, {name: "missing id", args: []string{"open"}, wantErr: true}, {name: "missing id with mode", args: []string{"open", "--mode", "tab"}, wantErr: true}, {name: "two ids", args: []string{"open", "a", "b"}, wantErr: true}, {name: "last with id", args: []string{"open", "--last", "abc"}, wantErr: true}, + {name: "stdin with id", args: []string{"open", "--stdin", "abc"}, wantErr: true}, + {name: "stdin with last", args: []string{"open", "--stdin", "--last"}, wantErr: true}, {name: "unknown flag", args: []string{"open", "--nope", "a"}, wantErr: true}, {name: "mode without value", args: []string{"open", "a", "--mode"}, wantErr: true}, {name: "invalid mode", args: []string{"open", "a", "--mode", "sideways"}, wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, last, printCmd, err := parseOpenArgs(tc.args) + id, mode, last, printCmd, stdin, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q last=%v", id, mode, last) @@ -66,6 +72,9 @@ func TestParseOpenArgs(t *testing.T) { if printCmd != tc.wantPrint { t.Errorf("print = %v, want %v", printCmd, tc.wantPrint) } + if stdin != tc.wantStdin { + t.Errorf("stdin = %v, want %v", stdin, tc.wantStdin) + } }) } } @@ -310,3 +319,175 @@ func TestHandleArgs_OpenMissingID(t *testing.T) { t.Error("expected error for open with no session ID") } } + +// withOpenBatchStubs installs config, per-ID session lookup, and launch seams +// for --stdin batch tests. sessions maps a session ID to the session returned +// for it; an ID absent from the map resolves to "not found" (nil, nil). The +// returned slice records the IDs launched, in order. +func withOpenBatchStubs(t *testing.T, cfg *config.Config, sessions map[string]*data.Session) *[]string { + t.Helper() + launched := &[]string{} + origCfg, origGet, origLaunch := openLoadConfigFn, openGetSessionFn, openLaunchFn + openLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + openGetSessionFn = func(id string) (*data.Session, error) { + return sessions[id], nil + } + openLaunchFn = func(_ io.Writer, _ *config.Config, s *data.Session, _ string) error { + *launched = append(*launched, s.ID) + return nil + } + t.Cleanup(func() { + openLoadConfigFn, openGetSessionFn, openLaunchFn = origCfg, origGet, origLaunch + }) + return launched +} + +// withStdin points the openStdin seam at s for the duration of the test. +func withStdin(t *testing.T, s string) { + t.Helper() + orig := openStdin + openStdin = strings.NewReader(s) + t.Cleanup(func() { openStdin = orig }) +} + +func TestReadSessionIDs(t *testing.T) { + in := strings.Join([]string{ + "sess-1", + " sess-2 ", // trimmed + "", // blank skipped + "# a comment", // comment skipped + "sess-1", // duplicate skipped + "sess-3 extra", // first field only + "\tsess-4\t", // tabs trimmed + }, "\n") + + got, err := readSessionIDs(strings.NewReader(in)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"sess-1", "sess-2", "sess-3", "sess-4"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("id[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestReadSessionIDs_NilReader(t *testing.T) { + got, err := readSessionIDs(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("got %v, want nil", got) + } +} + +func TestRunOpen_StdinBatch(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace // default is inplace; --mode overrides + sessions := map[string]*data.Session{ + "sess-1": {ID: "sess-1", Cwd: "/tmp/a"}, + "sess-2": {ID: "sess-2", Cwd: "/tmp/b"}, + } + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nsess-2\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin", "--mode", "tab"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 2 || (*launched)[0] != "sess-1" || (*launched)[1] != "sess-2" { + t.Errorf("launched = %v, want [sess-1 sess-2]", *launched) + } +} + +func TestRunOpen_StdinResolvesAlias(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + cfg.SessionAliases = map[string]string{"sess-1": "authfix"} + sessions := map[string]*data.Session{"sess-1": {ID: "sess-1", Cwd: "/tmp/a"}} + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "authfix\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 1 || (*launched)[0] != "sess-1" { + t.Errorf("launched = %v, want [sess-1]", *launched) + } +} + +func TestRunOpen_StdinInplaceRejected(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace + withOpenBatchStubs(t, cfg, map[string]*data.Session{"s": {ID: "s"}}) + withStdin(t, "s\n") + + // No explicit mode, so the inplace default applies and batch must reject it. + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err == nil { + t.Fatal("expected error rejecting inplace mode for batch resume") + } +} + +func TestRunOpen_StdinEmpty(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + withOpenBatchStubs(t, cfg, nil) + withStdin(t, "\n#only a comment\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err == nil { + t.Fatal("expected error when standard input has no session IDs") + } +} + +func TestRunOpen_StdinPartialFailure(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + sessions := map[string]*data.Session{"sess-1": {ID: "sess-1", Cwd: "/tmp/a"}} + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nmissing\n") + + var buf bytes.Buffer + err := runOpen(&buf, []string{"open", "--stdin"}) + if err == nil { + t.Fatal("expected an aggregated error for the missing session") + } + if len(*launched) != 1 || (*launched)[0] != "sess-1" { + t.Errorf("launched = %v, want [sess-1] (found session still resumes)", *launched) + } + if !strings.Contains(buf.String(), "resumed 1 of 2") { + t.Errorf("summary = %q, want it to mention resumed 1 of 2", buf.String()) + } +} + +func TestRunOpen_StdinPrint(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace // print ignores mode entirely + sessions := map[string]*data.Session{ + "sess-1": {ID: "sess-1", Cwd: "/tmp/a"}, + "sess-2": {ID: "sess-2", Cwd: "/tmp/b"}, + } + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nsess-2\n") + + origResume := openResumeCmdFn + openResumeCmdFn = func(id string, _ platform.ResumeConfig) (string, error) { + return "copilot --resume " + id, nil + } + t.Cleanup(func() { openResumeCmdFn = origResume }) + + var buf bytes.Buffer + if err := runOpen(&buf, []string{"open", "--stdin", "--print"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 0 { + t.Errorf("expected --print to skip launching, launched = %v", *launched) + } + out := buf.String() + if !strings.Contains(out, "copilot --resume sess-1") || !strings.Contains(out, "copilot --resume sess-2") { + t.Errorf("output = %q, want resume commands for both sessions", out) + } +}