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
12 changes: 6 additions & 6 deletions server/internal/handler/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ import (
"github.com/forgeutah/deuce/server/internal/ws"
)

// provisionAgentTools installs the agent harnesses into a workspace container:
// Claude Code (legacy fallback), Pi, and the ask-user extension. All installers
// are idempotent and non-fatal, so this is safe to run on every create/start —
// provisionAgentTools installs the agent harness into a workspace container:
// Pi, the pi-subagents package, and the ask-user extension. All installers are
// idempotent and non-fatal, so this is safe to run on every create/start —
// it migrates containers created before agent support without recreating them.
func (h *Handler) provisionAgentTools(ctx context.Context, workspaceID string, logFn workspace.LogFunc) {
if err := h.workspaces.InstallTools(ctx, workspaceID, logFn); err != nil {
slog.Warn("claude code installation failed", "workspace", workspaceID, "error", err)
}
if err := h.workspaces.InstallPi(ctx, workspaceID, logFn); err != nil {
slog.Warn("pi installation failed", "workspace", workspaceID, "error", err)
}
if err := h.workspaces.InstallPiPackage(ctx, workspaceID, workspace.PiSubagentsPackage, logFn); err != nil {
slog.Warn("pi-subagents installation failed", "workspace", workspaceID, "error", err)
}
if err := h.workspaces.InstallPiExtension(ctx, workspaceID, extension.AskUserFilename, extension.AskUser, logFn); err != nil {
// Loud, not fatal: the workspace still comes up, but the user has been
// told via logFn that agents can't ask questions here (R10). Error level
Expand Down
124 changes: 58 additions & 66 deletions server/internal/workspace/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,72 +369,6 @@ func readWorkspaceUID(workspaceID string) (string, error) {
// .bashrc/.profile, so $HOME/.local/bin is not on PATH by default.
const ClaudePathPrefix = `PATH="$HOME/.local/bin:$PATH" `

// InstallTools installs Claude Code inside the DevPod workspace using the
// native installer (https://claude.ai/install.sh) — single curl-piped
// script, no Node/npm needed. Output is streamed line-by-line to logFn
// (if non-nil).
func (m *Manager) InstallTools(ctx context.Context, workspaceID string, logFn LogFunc) error {
if logFn != nil {
logFn("Checking for Claude Code installation...")
}

// Check if claude is already installed
checkCmd := m.ExecInWorkspace(ctx, workspaceID, ClaudePathPrefix+"claude --version")
if output, err := checkCmd.CombinedOutput(); err == nil {
version := strings.TrimSpace(string(output))
slog.Info("claude code already installed", "workspace", workspaceID, "version", version)
if logFn != nil {
logFn(fmt.Sprintf("Claude Code already installed: %s", version))
}
return nil
}

// Install Claude Code
if logFn != nil {
logFn("Installing Claude Code...")
}
slog.Info("installing claude code in workspace", "workspace", workspaceID)

installCmd := m.ExecInWorkspace(ctx, workspaceID, "curl -fsSL https://claude.ai/install.sh | bash")
stdout, err := installCmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
installCmd.Stderr = installCmd.Stdout

if err := installCmd.Start(); err != nil {
if logFn != nil {
logFn("WARNING: Failed to install Claude Code (curl may not be available in this devcontainer)")
}
slog.Warn("failed to start claude code install", "workspace", workspaceID, "error", err)
return nil // Non-fatal — workspace is still usable, just without agent support
}

scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
slog.Debug("claude install output", "workspace", workspaceID, "line", line)
if logFn != nil {
logFn(line)
}
}

if err := installCmd.Wait(); err != nil {
if logFn != nil {
logFn("WARNING: Claude Code installation failed — agents will not be available")
}
slog.Warn("claude code install failed", "workspace", workspaceID, "error", err)
return nil // Non-fatal
}

if logFn != nil {
logFn("Claude Code installed successfully")
}
slog.Info("claude code installed successfully", "workspace", workspaceID)
return nil
}

// InstallPi installs the Pi coding agent (pi.dev) inside the DevPod workspace.
// Pi is an npm package requiring Node >= 22.19; its official installer can't
// install Node over a non-TTY pipe, so piInstallScript provisions Node first
Expand Down Expand Up @@ -619,3 +553,61 @@ func (m *Manager) InstallPiExtension(ctx context.Context, workspaceID, filename,
slog.Info("pi extension installed", "workspace", workspaceID, "filename", filename)
return nil
}

// PiSubagentsPackage is the Pi package providing the `subagent` tool, consumed
// by the compound-engineering bundle's agents/*.md personas. Installed via
// `pi install` so Pi tracks it in its own package registry.
const PiSubagentsPackage = "npm:pi-subagents"

// InstallPiPackage installs a Pi package (e.g. npm:pi-subagents) inside the
// DevPod workspace via `pi install`, which registers it in Pi's settings and
// fetches it. Re-running on an already-installed package is a cheap update, so
// this is safe on every create/start. Requires Pi (InstallPi) to have run
// first. Output is streamed line-by-line to logFn. Non-fatal at the call site:
// a workspace without the package still runs agents, just without the tools it
// provides.
func (m *Manager) InstallPiPackage(ctx context.Context, workspaceID, pkg string, logFn LogFunc) error {
if logFn != nil {
logFn(fmt.Sprintf("Installing Pi package %s...", pkg))
}
slog.Info("installing pi package", "workspace", workspaceID, "package", pkg)

installCmd := m.ExecInWorkspace(ctx, workspaceID, piLoginShell(fmt.Sprintf("pi install %s", pkg)))
stdout, err := installCmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
installCmd.Stderr = installCmd.Stdout

if err := installCmd.Start(); err != nil {
if logFn != nil {
logFn(fmt.Sprintf("WARNING: failed to start install of Pi package %s", pkg))
}
slog.Warn("failed to start pi package install", "workspace", workspaceID, "package", pkg, "error", err)
return fmt.Errorf("start pi install %s: %w", pkg, err)
}

scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
slog.Debug("pi package install output", "workspace", workspaceID, "package", pkg, "line", line)
if logFn != nil {
logFn(line)
}
}

if err := installCmd.Wait(); err != nil {
if logFn != nil {
logFn(fmt.Sprintf("WARNING: Pi package %s installation failed — agent tools from this package will be unavailable (see log above)", pkg))
}
slog.Warn("pi package install failed", "workspace", workspaceID, "package", pkg, "error", err)
return fmt.Errorf("pi install %s: %w", pkg, err)
}

if logFn != nil {
logFn(fmt.Sprintf("Pi package installed: %s", pkg))
}
slog.Info("pi package installed", "workspace", workspaceID, "package", pkg)
return nil
}
Loading