Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions clients/launcher/cmd/attach.go
Original file line number Diff line number Diff line change
@@ -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 <engagement-name>",
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow no-argument attach to list engagements

When an operator runs decepticon attach without a name, Cobra rejects the invocation before RunE, so the advertised no-argument listing behavior and the empty-slug branch of resolveEngagementOrList are unreachable; the user only receives an argument-count error. Accept zero or one argument and route the zero-argument case to the engagement listing.

Useful? React with 👍 / 👎.

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, true)
},
}

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 <name>' 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)
}
2 changes: 1 addition & 1 deletion clients/launcher/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand Down
32 changes: 29 additions & 3 deletions clients/launcher/cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var (
skipUpdate bool
resumeThread string
continueThread bool
backgroundRun bool
)

var startCmd = &cobra.Command{
Expand Down Expand Up @@ -98,16 +99,23 @@ 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 <name>'")
Comment on lines +102 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the new lifecycle in CHAGELOG.md

This adds the user-visible start --background, attach, and engagements surfaces without an Unreleased entry in the single root CHAGELOG.md. The repository explicitly requires every PR to record user-visible changes and compatibility notes there, so add the proposed change and PR link before merging.

AGENTS.md reference: AGENTS.md:L3-L8

Useful? React with 👍 / 👎.


rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
return runStart(cmd, args)
}
}

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
Expand Down Expand Up @@ -391,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
Expand Down Expand Up @@ -423,6 +434,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.
Expand Down Expand Up @@ -588,3 +607,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"
}
14 changes: 7 additions & 7 deletions clients/launcher/internal/engagement/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion clients/launcher/internal/engagement/picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
Expand Down
Loading