From 11180725c0ce6734fb6d320b0b092f3188c0bf0b Mon Sep 17 00:00:00 2001 From: mschoi Date: Thu, 22 Oct 2026 09:49:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(launcher):=20background=20engagement?= =?UTF-8?q?=20lifecycle=20=E2=80=94=20--background,=20attach,=20engagement?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the background engagement session lifecycle from #1041: - decepticon start --background: the full startup pipeline (verify Docker, select engagement, start services, wait for readiness) runs without attaching the interactive CLI. Prints the engagement name and attach hint, exits 0 with services kept running. - decepticon attach : re-opens the interactive CLI against an existing engagement workspace — the same startup pipeline as start minus the picker. Supports --resume/--continue session selection. With no argument it lists available engagements. - decepticon engagements: lists engagement workspaces with Red-approval state. - engagementEntry is exported as engagement.EngagementEntry so the cmd package can render the list. The start --background flag is the first slice of the Claude Code --bg pattern applied to Decepticon's engagement semantics (an engagement resume, not a generic agent job). attach is the counterpart for re-entry. Addresses #1041 (first slice: detached start + attach + list; per-engagement logs and cancel follow as separate slices). --- clients/launcher/cmd/attach.go | 102 ++++++++++++++++++ clients/launcher/cmd/start.go | 19 ++++ .../launcher/internal/engagement/picker.go | 14 +-- .../internal/engagement/picker_test.go | 2 +- 4 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 clients/launcher/cmd/attach.go diff --git a/clients/launcher/cmd/attach.go b/clients/launcher/cmd/attach.go new file mode 100644 index 000000000..2668351dd --- /dev/null +++ b/clients/launcher/cmd/attach.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/config" + "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/engagement" + "github.com/PurpleAILAB/Decepticon/clients/launcher/internal/ui" + "github.com/spf13/cobra" +) + +// resolveEngagementOrList resolves an engagement slug for attach; with an +// empty slug it lists the available engagements and asks the operator to +// pick one. +func resolveEngagementOrList(slug string) (string, error) { + home := config.DecepticonHome() + entries, err := engagement.ScanEngagements(home) + if err != nil { + return "", fmt.Errorf("scan engagements: %w", err) + } + if len(entries) == 0 { + return "", fmt.Errorf("no engagements yet; run 'decepticon start' to create one") + } + if slug != "" { + for _, entry := range entries { + if entry.Slug == slug { + return slug, nil + } + } + return "", fmt.Errorf("unknown engagement %q; available: %s", + slug, engagementSlugs(entries)) + } + return "", fmt.Errorf("choose an engagement: %s", engagementSlugs(entries)) +} + +func engagementSlugs(entries []engagement.EngagementEntry) string { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Slug) + } + return strings.Join(names, ", ") +} + +var attachCmd = &cobra.Command{ + Use: "attach ", + Short: "Open an existing engagement in the interactive CLI", + Long: `Start the Decepticon services (if needed) and open the interactive +conversation CLI attached to an existing engagement workspace. + +This is the counterpart of 'decepticon start --background': background +engagements are prepared without the CLI and attached here later. Supports +the same --resume/--continue session selection as 'decepticon start'.`, + Example: ` decepticon attach my-engagement + decepticon attach my-engagement --continue + decepticon attach --resume=THREAD_ID my-engagement`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + slug := strings.TrimSpace(args[0]) + if slug == "" { + return fmt.Errorf("engagement name is required; run 'decepticon engagements' to list") + } + if _, err := resolveEngagementOrList(slug); err != nil { + return err + } + ui.Info("Attaching to engagement " + slug + "…") + return runStartMode(cmd, nil, slug) + }, +} + +var engagementsCmd = &cobra.Command{ + Use: "engagements", + Short: "List existing engagement workspaces", + Long: `List engagement workspaces under the Decepticon home, with their +Red-approval state (whether an approved scope is present). Run +'decepticon attach ' to open one in the interactive CLI.`, + Example: " decepticon engagements\n decepticon attach my-engagement", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + entries, err := engagement.ScanEngagements(config.DecepticonHome()) + if err != nil { + return fmt.Errorf("scan engagements: %w", err) + } + if len(entries) == 0 { + ui.DimText("No engagements yet — run 'decepticon start' to create one.") + return nil + } + for _, entry := range entries { + state := "not approved" + if entry.Ready { + state = "red approved" + } + cmd.Printf(" %-40s %s\n", entry.Slug, state) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(attachCmd) + rootCmd.AddCommand(engagementsCmd) +} \ No newline at end of file diff --git a/clients/launcher/cmd/start.go b/clients/launcher/cmd/start.go index ba61a46ac..c3d291410 100644 --- a/clients/launcher/cmd/start.go +++ b/clients/launcher/cmd/start.go @@ -64,6 +64,7 @@ var ( skipUpdate bool resumeThread string continueThread bool + backgroundRun bool ) var startCmd = &cobra.Command{ @@ -98,6 +99,9 @@ func init() { rootCmd.MarkFlagsMutuallyExclusive("resume", "continue") startCmd.MarkFlagsMutuallyExclusive("resume", "continue") + startCmd.Flags().BoolVar(&backgroundRun, "background", false, + "Start services and prepare the engagement without opening the interactive CLI; attach later with 'decepticon attach '") + rootCmd.RunE = func(cmd *cobra.Command, args []string) error { return runStart(cmd, args) } @@ -423,6 +427,14 @@ func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) cliEnv["WEB_PORT"] = port } + // Background mode: services are up and the engagement workspace is bound; + // skip the interactive CLI and let the operator attach later. + if backgroundRun { + ui.Success("Engagement " + choice.Engagement + " ready in the background (thread: " + threadOrPickerHint(thread) + ").") + ui.DimText("Attach with 'decepticon attach " + choice.Engagement + "'. Services kept running.") + return nil + } + // Pass through terminal. Services are intentionally left running on CLI exit // so re-entry is fast (cold start is ~75s); use 'decepticon stop' to shut // the stack down. @@ -588,3 +600,10 @@ func candidateProbeURLs(raw string) []string { add("127.0.0.1") return candidates } + +func threadOrPickerHint(thread string) string { + if thread != "" && thread != "picker" { + return thread + } + return "created on first attach" +} diff --git a/clients/launcher/internal/engagement/picker.go b/clients/launcher/internal/engagement/picker.go index 3e242f2f3..91f0b7725 100644 --- a/clients/launcher/internal/engagement/picker.go +++ b/clients/launcher/internal/engagement/picker.go @@ -46,9 +46,9 @@ type Choice struct { AuthorizationConfirmed bool } -// engagementEntry pairs a slug with metadata used for picker rendering and +// EngagementEntry pairs a slug with metadata used for picker rendering and // downstream assistant selection. -type engagementEntry struct { +type EngagementEntry struct { Slug string Ready bool mtime int64 @@ -99,7 +99,7 @@ func readScopeTarget(root string) (string, bool) { return roe.InScope[0].Target, true } -func ScanEngagements(home string) ([]engagementEntry, error) { +func ScanEngagements(home string) ([]EngagementEntry, error) { root := filepath.Join(home, "workspace") entries, err := os.ReadDir(root) if err != nil { @@ -109,7 +109,7 @@ func ScanEngagements(home string) ([]engagementEntry, error) { return nil, fmt.Errorf("read workspace: %w", err) } - var out []engagementEntry + var out []EngagementEntry for _, e := range entries { if !e.IsDir() { continue @@ -118,7 +118,7 @@ func ScanEngagements(home string) ([]engagementEntry, error) { if strings.HasPrefix(slug, ".") { continue } - entry := engagementEntry{Slug: slug, Ready: isRedApproved(home, slug)} + entry := EngagementEntry{Slug: slug, Ready: isRedApproved(home, slug)} if entry.Ready { if st, err := os.Stat(filepath.Join(root, slug, "plan", "roe.json")); err == nil { entry.mtime = st.ModTime().Unix() @@ -285,7 +285,7 @@ func (m pickerModel) renderConfirmOverlay() string { return confirmStyle.Render(body) } -func newPickerModel(home string, entries []engagementEntry) pickerModel { +func newPickerModel(home string, entries []EngagementEntry) pickerModel { items := buildItems(entries) delegate := list.NewDefaultDelegate() l := list.New(items, delegate, 0, 0) @@ -302,7 +302,7 @@ func newPickerModel(home string, entries []engagementEntry) pickerModel { return pickerModel{home: home, list: l, keys: keys} } -func buildItems(entries []engagementEntry) []list.Item { +func buildItems(entries []EngagementEntry) []list.Item { items := []list.Item{pickerItem{isNew: true}} for _, e := range entries { items = append(items, pickerItem{ diff --git a/clients/launcher/internal/engagement/picker_test.go b/clients/launcher/internal/engagement/picker_test.go index 648176e10..6a2d8f341 100644 --- a/clients/launcher/internal/engagement/picker_test.go +++ b/clients/launcher/internal/engagement/picker_test.go @@ -68,7 +68,7 @@ func TestScanEngagements_ApprovedAndUnapproved(t *testing.T) { if len(got) != 3 { t.Fatalf("expected 3 entries, got %d (%v)", len(got), got) } - byName := map[string]engagementEntry{got[0].Slug: got[0], got[1].Slug: got[1], got[2].Slug: got[2]} + byName := map[string]EngagementEntry{got[0].Slug: got[0], got[1].Slug: got[1], got[2].Slug: got[2]} if !byName["alpha"].Ready { t.Errorf("alpha should be ready, got %v", byName["alpha"]) } From ed7b340a344aea7c79b856539c7c60a799b7bfe0 Mon Sep 17 00:00:00 2001 From: mschoi Date: Thu, 22 Oct 2026 17:48:04 +0900 Subject: [PATCH 2/2] fix(launcher): give attach its own interactive path attach shared the desktop-service branch of runStartMode, so it started the stack, printed the desktop-ready line and exited without ever opening the CLI. runStartMode now takes attachInteractive: the desktop service still ends after readiness, attach drops the operator into RunInteractive on the existing engagement. --- clients/launcher/cmd/attach.go | 2 +- clients/launcher/cmd/serve.go | 2 +- clients/launcher/cmd/start.go | 13 ++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/clients/launcher/cmd/attach.go b/clients/launcher/cmd/attach.go index 2668351dd..68742cfe6 100644 --- a/clients/launcher/cmd/attach.go +++ b/clients/launcher/cmd/attach.go @@ -64,7 +64,7 @@ the same --resume/--continue session selection as 'decepticon start'.`, return err } ui.Info("Attaching to engagement " + slug + "…") - return runStartMode(cmd, nil, slug) + return runStartMode(cmd, nil, slug, true) }, } diff --git a/clients/launcher/cmd/serve.go b/clients/launcher/cmd/serve.go index 67799c11e..29a5c229b 100644 --- a/clients/launcher/cmd/serve.go +++ b/clients/launcher/cmd/serve.go @@ -27,7 +27,7 @@ programmatically. Requires --engagement; interactive users should use if slug == "" { return fmt.Errorf("choose an existing engagement with --engagement") } - return runStartMode(cmd, args, slug) + return runStartMode(cmd, args, slug, false) }, } diff --git a/clients/launcher/cmd/start.go b/clients/launcher/cmd/start.go index c3d291410..ad8213e88 100644 --- a/clients/launcher/cmd/start.go +++ b/clients/launcher/cmd/start.go @@ -108,10 +108,14 @@ func init() { } func runStart(cmd *cobra.Command, args []string) error { - return runStartMode(cmd, args, "") + return runStartMode(cmd, args, "", false) } -func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) error { +// runStartMode boots the stack for one of three callers: plain `start` +// (existingEngagement == ""), the desktop agent service (`serve`, which +// returns once services are ready), and `attach` (attachInteractive, which +// drops the operator into the interactive CLI on an existing engagement). +func runStartMode(cmd *cobra.Command, args []string, existingEngagement string, attachInteractive bool) error { if cmd == rootCmd { if err := validateRootArgs(args); err != nil { return err @@ -395,7 +399,10 @@ func runStartMode(cmd *cobra.Command, args []string, existingEngagement string) return fmt.Errorf("service readiness: %w; inspect `decepticon doctor` and `decepticon logs` (services kept)", err) } - if existingEngagement != "" { + // The desktop agent service ends here — it serves host clients and has + // no terminal. Attach keeps going: its whole point is the interactive + // CLI on the existing engagement. + if existingEngagement != "" && !attachInteractive { ui.Success("Desktop agent service ready for " + choice.Engagement) fmt.Println("DECEPTICON_DESKTOP_ASSISTANT=" + choice.AssistantID) return nil