Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da74c84
refactor(frontend): split agent configuration tabs
Aug 4, 2026
3017136
refactor(frontend): consolidate agent general settings
Aug 4, 2026
a7dec28
style(frontend): refine agent general layout
Aug 4, 2026
9fadc1b
style(frontend): rename org tools tab
Aug 4, 2026
3dd8d76
refactor(frontend): simplify agent runtime controls
Aug 4, 2026
e4ef92e
style(frontend): simplify contextual sidebar header
Aug 4, 2026
13661b7
fix(frontend): interleave chat tool call runs
Aug 4, 2026
9f2dfa4
fix(frontend): render expanded thoughts as markdown
Aug 4, 2026
a3fcc2f
fix(frontend): preserve thought timeline order
Aug 4, 2026
bdd62ae
style(frontend): simplify thought row labels
Aug 4, 2026
328bd1a
style(frontend): brighten assistant chat text
Aug 4, 2026
494c8be
style(frontend): align chat text hierarchy
Aug 4, 2026
d06ee50
test(frontend): update assistant contrast assertion
Aug 4, 2026
7adea89
fix(frontend): compact live activity timeline
Aug 4, 2026
19463ab
fix(frontend): hide thoughts while streaming
Aug 4, 2026
21e9083
style(frontend): align tool history disclosure
Aug 4, 2026
992827c
style(frontend): align tool history icon
Aug 4, 2026
497ea2c
fix(frontend): keep tool disclosure stationary
Aug 4, 2026
c663f0c
style(frontend): extend toolbar across context sidebar
Aug 4, 2026
d6bfa4e
fix(frontend): correct project section breadcrumbs
Aug 4, 2026
b4fabf7
refactor(frontend): decouple router context
Aug 4, 2026
427dd6d
fix(org): load worker identity from instruction files
Aug 4, 2026
3f2d77e
refactor(frontend): consolidate organization navigation
Aug 4, 2026
a27d824
fix(test): update GlobalNotifications mock from router to react-router5
Aug 4, 2026
9f4c85c
style(frontend): align agents and projects layouts
Aug 4, 2026
53b5dbf
style(frontend): compact primary create actions
Aug 5, 2026
b581633
fix(frontend): align chat sidebar header
Aug 5, 2026
f6c48d7
fix(org): scope worker instructions to sessions
Aug 5, 2026
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
84 changes: 67 additions & 17 deletions api/pkg/external-agent/hydra_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,21 @@ func (h *HydraExecutor) StartDesktop(ctx context.Context, agent *types.DesktopAg
if err != nil {
log.Warn().Err(err).Str("project_id", agent.ProjectID).Str("session_id", agent.SessionID).Msg("Failed to load project secrets, continuing without them")
} else if len(projectSecrets) > 0 {
agent.Env = append(agent.Env, projectSecrets...)
log.Info().Int("secret_count", len(projectSecrets)).Str("project_id", agent.ProjectID).Str("session_id", agent.SessionID).Msg("Injected project secrets into desktop env")
before := len(agent.Env)
agent.Env = appendProjectSecrets(agent.Env, projectSecrets)
injected := len(agent.Env) - before
log.Info().Int("secret_count", injected).Str("project_id", agent.ProjectID).Str("session_id", agent.SessionID).Msg("Injected project secrets into desktop env")
}
}

// Org-worker identity is session state, not project state: SpecTask and
// human exploratory sessions can share the same project and must not inherit
// it. Materialize both agent-native instruction files before the container
// starts; the workspace bind mount persists them across desktop restarts.
if err := h.attachSessionBootstrap(ctx, agent); err != nil {
return nil, err
}

// Call OnBeforeCreate hook inside the lock to refresh API keys.
// This prevents a race where StopDesktop revokes the key between
// addUserAPITokenToAgent (outside the lock) and container creation (inside).
Expand Down Expand Up @@ -363,21 +373,22 @@ func (h *HydraExecutor) StartDesktop(ctx context.Context, agent *types.DesktopAg
// NOTE: GPUVendor is empty - Hydra reads it from its own GPU_VENDOR env var
// Privileged mode is required for the inner dockerd (overlay2 needs it)
req := &hydra.CreateDevContainerRequest{
SessionID: agent.SessionID,
Image: image,
ContainerName: containerName,
Hostname: containerName,
Env: env,
Mounts: mounts,
DisplayWidth: agent.DisplayWidth,
DisplayHeight: agent.DisplayHeight,
DisplayFPS: agent.DisplayRefreshRate,
ContainerType: hydra.DevContainerType(containerType),
UserID: agent.UserID,
Network: "bridge",
Privileged: true, // Required for inner dockerd (docker-in-desktop mode)
ProjectID: agent.ProjectID,
GoldenBuild: agent.GoldenBuild,
SessionID: agent.SessionID,
Image: image,
ContainerName: containerName,
Hostname: containerName,
Env: env,
Mounts: mounts,
WorkspaceFiles: agent.WorkspaceFiles,
DisplayWidth: agent.DisplayWidth,
DisplayHeight: agent.DisplayHeight,
DisplayFPS: agent.DisplayRefreshRate,
ContainerType: hydra.DevContainerType(containerType),
UserID: agent.UserID,
Network: "bridge",
Privileged: true, // Required for inner dockerd (docker-in-desktop mode)
ProjectID: agent.ProjectID,
GoldenBuild: agent.GoldenBuild,
}

// Create dev container via Hydra
Expand Down Expand Up @@ -567,6 +578,45 @@ func (h *HydraExecutor) StartDesktop(ctx context.Context, agent *types.DesktopAg
}, nil
}

func (h *HydraExecutor) attachSessionBootstrap(ctx context.Context, agent *types.DesktopAgent) error {
session, err := h.store.GetSession(ctx, agent.SessionID)
if err != nil {
return fmt.Errorf("load session bootstrap state for %s: %w", agent.SessionID, err)
}
return applySessionBootstrap(session.Metadata, agent)
}

func applySessionBootstrap(metadata types.SessionMetadata, agent *types.DesktopAgent) error {
workerID := metadata.OrgWorkerID
instructions := metadata.RuntimeInstructions
if workerID == "" && instructions == "" {
return nil
}
if workerID == "" || instructions == "" {
return fmt.Errorf("incomplete org-worker bootstrap state for session %s", agent.SessionID)
}
agent.Env = append(agent.Env, "HELIX_WORKER_ID="+workerID)
if agent.WorkspaceFiles == nil {
agent.WorkspaceFiles = make(map[string][]byte, 2)
}
agent.WorkspaceFiles["AGENTS.md"] = []byte(instructions)
agent.WorkspaceFiles["CLAUDE.md"] = []byte(instructions)
return nil
}

func appendProjectSecrets(env, secrets []string) []string {
for _, secret := range secrets {
// HELIX_WORKER_ID used to be stored at project scope. Never propagate
// that legacy value: only session bootstrap may identify a desktop as
// an org worker.
if strings.HasPrefix(secret, "HELIX_WORKER_ID=") {
continue
}
env = append(env, secret)
}
return env
}

// StopDesktop stops a dev container using Hydra
func (h *HydraExecutor) StopDesktop(ctx context.Context, sessionID string) error {
// Detach from the caller's context immediately. Stopping a container is a
Expand Down
64 changes: 64 additions & 0 deletions api/pkg/external-agent/hydra_executor_bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package external_agent

import (
"testing"

"github.com/helixml/helix/api/pkg/types"
)

func TestApplySessionBootstrapScopesInstructionsToOrgWorker(t *testing.T) {
agent := &types.DesktopAgent{SessionID: "ses_worker"}
err := applySessionBootstrap(types.SessionMetadata{
OrgWorkerID: "b-alex",
RuntimeInstructions: "worker instructions",
}, agent)
if err != nil {
t.Fatalf("applySessionBootstrap: %v", err)
}
if len(agent.Env) != 1 || agent.Env[0] != "HELIX_WORKER_ID=b-alex" {
t.Fatalf("agent env = %v", agent.Env)
}
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
if got := string(agent.WorkspaceFiles[name]); got != "worker instructions" {
t.Errorf("%s = %q", name, got)
}
}
}

func TestApplySessionBootstrapLeavesSpecTaskUnchanged(t *testing.T) {
agent := &types.DesktopAgent{SessionID: "ses_spec", SpecTaskID: "spt_test"}
if err := applySessionBootstrap(types.SessionMetadata{SpecTaskID: "spt_test"}, agent); err != nil {
t.Fatalf("applySessionBootstrap: %v", err)
}
if len(agent.Env) != 0 || len(agent.WorkspaceFiles) != 0 {
t.Fatalf("spec task inherited worker bootstrap: env=%v files=%v", agent.Env, agent.WorkspaceFiles)
}
}

func TestAppendProjectSecretsDropsLegacyWorkerIdentity(t *testing.T) {
got := appendProjectSecrets([]string{"BASE=1"}, []string{
"HELIX_ORG_URL=http://helix",
"HELIX_WORKER_ID=b-alex",
"TOKEN=value",
})
want := []string{"BASE=1", "HELIX_ORG_URL=http://helix", "TOKEN=value"}
if len(got) != len(want) {
t.Fatalf("env = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("env = %v, want %v", got, want)
}
}
}

func TestApplySessionBootstrapRejectsPartialState(t *testing.T) {
for _, metadata := range []types.SessionMetadata{
{OrgWorkerID: "b-alex"},
{RuntimeInstructions: "instructions"},
} {
if err := applySessionBootstrap(metadata, &types.DesktopAgent{SessionID: "ses_bad"}); err == nil {
t.Fatalf("applySessionBootstrap accepted partial state: %+v", metadata)
}
}
}
54 changes: 54 additions & 0 deletions api/pkg/hydra/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
Expand Down Expand Up @@ -517,6 +518,9 @@ func (dm *DevContainerManager) CreateDevContainer(ctx context.Context, req *Crea
log.Debug().Str("path", m.Source).Msg("Ensured mount source directory exists")
}
}
if err := materializeWorkspaceFiles(req.Mounts, req.WorkspaceFiles); err != nil {
return nil, fmt.Errorf("materialize workspace files: %w", err)
}

// Apply a timeout for the Docker operations that follow. This is separate
// from the golden cache copy (which can take minutes for 50+ GB) — that
Expand Down Expand Up @@ -784,6 +788,56 @@ func (dm *DevContainerManager) CreateDevContainer(ctx context.Context, req *Crea
}, nil
}

func materializeWorkspaceFiles(mounts []MountConfig, files map[string][]byte) error {
if len(files) == 0 {
return nil
}
var workspaceRoot string
for _, mount := range mounts {
if mount.Type != "volume" && mount.Destination == "/home/retro/work" {
workspaceRoot = mount.Source
break
}
}
if workspaceRoot == "" {
return errors.New("workspace bind mount not found")
}
if err := os.MkdirAll(workspaceRoot, 0o755); err != nil {
return fmt.Errorf("create workspace root: %w", err)
}
for name, content := range files {
if name == "" || filepath.Base(name) != name || name == "." || name == ".." {
return fmt.Errorf("invalid workspace filename %q", name)
}
tmp, err := os.CreateTemp(workspaceRoot, ".helix-bootstrap-*")
if err != nil {
return fmt.Errorf("create temporary file for %s: %w", name, err)
}
tmpPath := tmp.Name()
cleanup := func() {
tmp.Close()
_ = os.Remove(tmpPath)
}
if err := tmp.Chmod(0o644); err != nil {
cleanup()
return fmt.Errorf("chmod temporary file for %s: %w", name, err)
}
if _, err := tmp.Write(content); err != nil {
cleanup()
return fmt.Errorf("write temporary file for %s: %w", name, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close temporary file for %s: %w", name, err)
}
if err := os.Rename(tmpPath, filepath.Join(workspaceRoot, name)); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("install %s: %w", name, err)
}
}
return nil
}

// buildEnv builds environment variables for the container
func (dm *DevContainerManager) buildEnv(req *CreateDevContainerRequest) []string {
env := make([]string, len(req.Env))
Expand Down
41 changes: 41 additions & 0 deletions api/pkg/hydra/devcontainer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,47 @@ func TestResolveRegistryImage(t *testing.T) {
})
}

func TestMaterializeWorkspaceFiles(t *testing.T) {
root := t.TempDir()
mounts := []MountConfig{{Source: root, Destination: "/home/retro/work"}}
files := map[string][]byte{
"AGENTS.md": []byte("agent instructions"),
"CLAUDE.md": []byte("claude instructions"),
}
if err := materializeWorkspaceFiles(mounts, files); err != nil {
t.Fatalf("materializeWorkspaceFiles: %v", err)
}
for name, want := range files {
got, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(got) != string(want) {
t.Errorf("%s = %q, want %q", name, got, want)
}
}

files["AGENTS.md"] = []byte("updated")
if err := materializeWorkspaceFiles(mounts, files); err != nil {
t.Fatalf("refresh workspace files: %v", err)
}
got, err := os.ReadFile(filepath.Join(root, "AGENTS.md"))
if err != nil || string(got) != "updated" {
t.Fatalf("refreshed AGENTS.md = %q, %v", got, err)
}
}

func TestMaterializeWorkspaceFilesRejectsUnsafeNames(t *testing.T) {
mounts := []MountConfig{{Source: t.TempDir(), Destination: "/home/retro/work"}}
for _, name := range []string{"", "../AGENTS.md", "nested/AGENTS.md", "/AGENTS.md"} {
t.Run(name, func(t *testing.T) {
if err := materializeWorkspaceFiles(mounts, map[string][]byte{name: []byte("x")}); err == nil {
t.Fatalf("materializeWorkspaceFiles accepted %q", name)
}
})
}
}

// stageFakeSysfs builds a synthetic /dev/dri + /sys/class/drm tree for
// tests. devs is a list of {render, card, pci, driver} tuples — render
// and card are basenames (e.g. "renderD129", "card1"); pci is a BDF
Expand Down
4 changes: 4 additions & 0 deletions api/pkg/hydra/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ type CreateDevContainerRequest struct {
Hostname string `json:"hostname"`
Env []string `json:"env"` // KEY=value format
Mounts []MountConfig `json:"mounts"`
// WorkspaceFiles are materialized into the bind mount targeting
// /home/retro/work before the container starts. Keys are root filenames;
// byte values are base64-encoded by JSON.
WorkspaceFiles map[string][]byte `json:"workspace_files,omitempty"`

// Display settings (optional - headless containers omit these)
DisplayWidth int `json:"display_width,omitempty"`
Expand Down
5 changes: 2 additions & 3 deletions api/pkg/org/application/lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,8 @@ type CreateResult struct {
// This is the single implementation the MCP create_bot tool and the
// REST POST /bots handler both call.
//
// State lives in the domain DB, with role.md mirrored to the per-Node
// helix-specs branch for workspace workflows. A Node's MCP tool surface is
// derived live from Node.Tools.
// State lives in the domain DB. A Node's MCP tool surface is derived live from
// Node.Tools.
func (s *Service) Create(ctx context.Context, orgID string, p CreateParams) (CreateResult, error) {
if s.NewID == nil || s.Now == nil {
return CreateResult{}, fmt.Errorf("lifecycle: clock/id-generator not wired")
Expand Down
35 changes: 21 additions & 14 deletions api/pkg/org/domain/briefing/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,30 @@ import (
"github.com/helixml/helix/api/pkg/org/domain/orgchart"
)

// BuildPrompt assembles the per-activation prompt: identity hint +
// mandate + the triggers that woke the Worker up. The dispatcher
// BuildInstructions assembles the durable runtime instructions loaded by
// Codex and Claude from AGENTS.md / CLAUDE.md before an activation starts.
// The mandate is the canonical role text resolved by the runtime.
func BuildInstructions(workerID orgchart.NodeID, mandate string) string {
return fmt.Sprintf(`You are Bot %s, running inside helix-org. Your environment is
the current working directory. Each activation is a single turn — do
the work and exit.

=== Instructions ===
%s
`, workerID, mandate)
}

// BuildPrompt assembles the per-activation user message from only the
// triggers that woke the Worker up. Durable identity and role instructions
// live in the runtime's AGENTS.md / CLAUDE.md instead of being repeated in
// every interaction. The dispatcher
// coalesces bursts, so a single activation may carry multiple triggers
// — the prompt frames them as a numbered list when that happens, so
// the agent can read all of them before deciding what to do (often the
// most recent supersedes the earlier ones). Tools are exposed natively
// via MCP under the "helix" server (tool names appear as
// mcp__helix__<name>); Claude figures the rest out from tools/list.
//
// `mandate` is the static text the agent reads first — for the Helix
// runtime it's a short pointer at the helix-specs branch, which carries
// the real policy text the Worker reads before acting.
func BuildPrompt(workerID orgchart.NodeID, mandate string, triggers []activation.Trigger) string {
func BuildPrompt(triggers []activation.Trigger) string {
var ctx strings.Builder

if len(triggers) > 1 {
Expand All @@ -38,7 +49,7 @@ func BuildPrompt(workerID orgchart.NodeID, mandate string, triggers []activation
case activation.TriggerEvent:
ctx.WriteString(renderTrigger(t))
case activation.TriggerManual:
ctx.WriteString("An operator manually woke you up from the worker UI. Re-read your role and identity (per the mandate above), summarise your current state, and stand by — the operator will follow up with instructions on the next message.\n")
ctx.WriteString("Manual activation. An operator woke you from the worker UI. Summarise your current state and stand by — the operator will follow up with instructions on the next message.\n")
default:
fmt.Fprintf(&ctx, "Activation kind: %q.\n", t.Kind)
}
Expand All @@ -47,17 +58,13 @@ func BuildPrompt(workerID orgchart.NodeID, mandate string, triggers []activation
}
}

return fmt.Sprintf(`You are Bot %s, running inside helix-org. Your environment is
the current working directory. Each activation is a single turn — do
the work and exit.

%s
return fmt.Sprintf(`Re-read /home/retro/work/AGENTS.md or /home/retro/work/CLAUDE.md before acting so you use the current worker instructions.

=== Trigger ===
%s=== end trigger ===

Act now. No preamble.
`, workerID, mandate, ctx.String())
`, ctx.String())
}

// renderTrigger formats an event-kind activation.Trigger for the activation
Expand Down
Loading