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
43 changes: 43 additions & 0 deletions cmd/chat_subcommand_host_tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"fmt"
"strings"

tea "charm.land/bubbletea/v2"

"github.com/GrayCodeAI/reflect/internal/hosttools"
)

// hostToolsSubcommand lists registered host tools.
type hostToolsSubcommand struct{}

func (c *hostToolsSubcommand) Name() string { return "host-tools" }
func (c *hostToolsSubcommand) Aliases() []string { return []string{"hosttools"} }
func (c *hostToolsSubcommand) Description() string {
return "list host-provided tools (~/.reflect/host_tools)"
}
func (c *hostToolsSubcommand) Usage() string { return "/host-tools" }

func (c *hostToolsSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
manifests, err := hosttools.Load()
if err != nil {
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
return m, nil
}
if len(manifests) == 0 {
m.messages = append(m.messages, displayMsg{role: "system", content: "No host tools registered. Add JSON manifests to " + hosttools.Dir()})
return m, nil
}
var b strings.Builder
b.WriteString("Host tools:\n")
for _, m := range manifests {
b.WriteString(fmt.Sprintf(" - %s: %s\n", m.Name, m.Description))
}
m.messages = append(m.messages, displayMsg{role: "system", content: b.String()})
return m, nil
}

func init() {
subcommandRegistry.Register(&hostToolsSubcommand{})
}
47 changes: 47 additions & 0 deletions cmd/chat_subcommand_sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"fmt"
"os"

tea "charm.land/bubbletea/v2"

"github.com/GrayCodeAI/reflect/internal/config"
"github.com/GrayCodeAI/reflect/internal/sandbox"
)

// sandboxSubcommand reports and toggles sandboxed tool execution.
type sandboxSubcommand struct{}

func (c *sandboxSubcommand) Name() string { return "sandbox" }
func (c *sandboxSubcommand) Aliases() []string { return nil }
func (c *sandboxSubcommand) Description() string {
return "show sandbox status (Mirror-style isolated execution)"
}
func (c *sandboxSubcommand) Usage() string { return "/sandbox" }

func (c *sandboxSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
s := config.LoadSettings()
mode := s.Sandbox
if envMode := os.Getenv("REFLECT_SANDBOX"); envMode != "" {
mode = envMode
}
cfg := sandbox.Config{Mode: sandbox.NormalizeMode(mode), Image: os.Getenv("REFLECT_SANDBOX_IMAGE")}
status := "off (host execution)"
if sandbox.Enabled(cfg) {
status = "on (docker)"
}
docker := "not found"
if sandbox.DockerAvailable() {
docker = "available"
}
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf(
"Sandbox: %s\nDocker: %s\n\nTo enable, set \"sandbox\": \"docker\" in ~/.reflect/settings.json\nor export REFLECT_SANDBOX=docker (and optionally REFLECT_SANDBOX_IMAGE).",
status, docker,
)})
return m, nil
}

func init() {
subcommandRegistry.Register(&sandboxSubcommand{})
}
41 changes: 41 additions & 0 deletions cmd/chat_subcommand_workspaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import (
"fmt"
"strings"

tea "charm.land/bubbletea/v2"

"github.com/GrayCodeAI/reflect/internal/session"
)

// workspacesSubcommand lists saved sessions (Mirror-style workspace roster).
type workspacesSubcommand struct{}

func (c *workspacesSubcommand) Name() string { return "workspaces" }
func (c *workspacesSubcommand) Aliases() []string { return []string{"worktree-list"} }
func (c *workspacesSubcommand) Description() string { return "list saved sessions/workspaces" }
func (c *workspacesSubcommand) Usage() string { return "/workspaces" }

func (c *workspacesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) {
entries, err := session.List()
if err != nil {
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
return m, nil
}
if len(entries) == 0 {
m.messages = append(m.messages, displayMsg{role: "system", content: "No saved sessions."})
return m, nil
}
var b strings.Builder
b.WriteString("Sessions:\n")
for i, e := range entries {
b.WriteString(fmt.Sprintf(" %d. %s (id=%s)\n", i+1, e.Preview, e.ID))
}
m.messages = append(m.messages, displayMsg{role: "system", content: b.String()})
return m, nil
}

func init() {
subcommandRegistry.Register(&workspacesSubcommand{})
}
6 changes: 6 additions & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ func defaultRegistry(settings reflectconfig.Settings) (*tool.Registry, error) {
_ = registry.Register(t)
}

// Host tools: external (e.g. Python) tools registered via manifests in
// ~/.reflect/host_tools/*.json (Mirror-style host tool contributions).
for _, t := range tool.LoadHostTools() {
_ = registry.Register(t)
}

// Load MCP tools in the background so a hung/absent stdio server delays
// tool availability — not first paint. loadStartupMCPToolSets can block up
// to 1.5s per configured server. The CLI tool filters still apply.
Expand Down
111 changes: 111 additions & 0 deletions docs/plans/mirror-port-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Plan: Port Mirror (reflection-oss/mirror) capabilities into Reflect

Source repo (cloned locally): `../mirror-ref` (reflection-oss/mirror, Apache-2.0).
Target: `reflect` (Go, monolithic TUI + relay engine).

Mirror is a Rust agent + Python CLI supervisor with AgentRPC workers. Reflect is a
monolithic Go TUI. We port **features and UX**, not the architecture (worker
supervision, AgentRPC) — Reflect's in-process agent loop is a strength, not a gap.

## Phase 0 — Inventory (done)

Mirror's CLI surface (`mirror-cli/src/mirror/cli/`) and features:

- Core: input/rendering, conversation, resume, rewind, branching
- Workspaces: roster, creation, switching, persistence (`workspace_controller`, `workspaces`, `workspaces_application`)
- Worktrees: parallel independent checkouts (`worktrees`)
- Sandbox: isolated file/shell tool execution (`sandbox/`, `sandbox_shell.py`, `sandboxes.py`)
- Extensions: Python extensions, commands, host tools, MCP declarations (`extension.py`, `extensions/`, `host_tools.py`, `mcp_configuration.py`, `user_extensions.py`)
- Settings: menu-driven settings with templates (`cli/extensions/settings/`)
- Themes: theme system (`themes.py`, `docs/themes.md`)
- Transcript: rendering/history (`transcript.py`, `transcript_view.py`)
- Approvals: bash/tool approvals (`bash_approvals.py`, `tool_approvals.py`)
- Onboarding, API keys, models (`onboarding.py`, `api_keys.py`, `models.py`)
- Noninteractive mode (`noninteractive.py`)
- Shell integration, terminal (`shell.py`, `terminal.py`, `extended_keys.py`)
- Safety, statsig telemetry, logging
- Memory (opt-in, cross-session)
- Project instructions, skills

## Mapping to Reflect (already present)

| Mirror feature | Reflect equivalent | Status |
|---|---|---|
| Worktrees / parallel tasks | `internal/multiagent`, mission mode, EnterWorktree/ExitWorktree | ✅ present |
| Resume conversations | session resume (`-r`/`-c`) | ✅ |
| Providers/models | relay engine (28+) | ✅ |
| Memory | conversation arc + memory | ✅ |
| Project instructions | AGENTS.md / ZERO.md | ✅ |
| Skills | skills system | ✅ |
| MCP | MCP client | ✅ |
| Approvals | permissions/autonomy + spec gate | ✅ (different UX) |
| Themes | (check Reflect theme) | partial |
| Transcript | session JSONL + TUI | partial |

## Gaps worth porting (prioritized)

### P1 — Sandbox workspaces (HIGH value, MEDIUM effort)
Mirror runs file/shell tools in an isolated sandbox (microsandbox/container) instead
of on the host. Reflect executes on the host with permission gates. Porting an
opt-in sandbox mode would be a major safety upgrade.

Scope:
- Add `internal/sandbox/` package wrapping a container runtime (Docker/Podman) or
a lightweight namespace/chroot fallback.
- Wire into `tool.Bash`, `tool.Write`, `tool.Edit` behind a setting
(`settings.sandbox = off|docker|microsandbox`).
- Add `/sandbox` command + status in `/path` and doctor.
- Config: `REFLECT_SANDBOX`, `REFLECT_SANDBOX_IMAGE`.
- Tests: sandbox round-trip, fallback to host when runtime missing.

### P2 — Conversation rewind / branching (MEDIUM value, MEDIUM effort)
Mirror's `/rewind` forks conversation history (not filesystem). Reflect has
session rewind? Check `internal/session`. If missing, add:
- `/rewind [n]` command to fork a session at turn n into a new session file.
- TUI picker over turns.
- Reuse existing session checkpoint/WAL.

### P3 — Themes (LOW-MEDIUM value, LOW effort)
Mirror has a theme system (`themes.py`, `docs/themes.md`). Reflect has
`internal/theme`. Audit parity: color tokens, custom theme files, `/theme` command.

### P4 — Host tools / Python extensions (MEDIUM value, HIGH effort)
Mirror allows Python extensions contributing commands, host tools, MCP decls.
Reflect has Go plugins + MCP. Porting Python extensions would broaden ecosystem
but is high effort. Defer unless demanded.

### P5 — Workspace tabs / roster UI (MEDIUM value, HIGH effort)
Mirror's multi-workspace tab UI. Reflect uses mission mode (background). Could
surface mission workers as tabs. Defer.

### P6 — Noninteractive / scripting mode parity
Mirror has `noninteractive.py`. Reflect has `decoder exec`/`-p`. Compare flags
and output formats; align.

### P7 — Onboarding / API key UX
Mirror's masked sign-in prompt. Reflect uses `/config`. Compare and improve.

## Phase plan

- Phase 1 (P1 sandbox) — 1-2 weeks
- Phase 2 (P2 rewind) — 3-5 days
- Phase 3 (P3 themes) — 2-3 days
- Phase 4 (P6, P7) — 1 week
- Phase 5 (P4, P5) — only if demanded

## Out of scope (not porting)

- Rust agent / AgentRPC worker supervision (Reflect is monolithic by design)
- Python CLI runtime (Reflect is Go)
- statsig telemetry (Reflect has its own observability)
- microsandbox specifically (use Docker/Podman abstraction)

## Open questions

1. Sandbox runtime preference (Docker vs Podman vs namespace)?
2. Should sandbox be default-on or opt-in?
3. Rewind: fork to new session or in-place truncate?

## Next action

Start Phase 1 (sandbox) unless user picks a different priority.
3 changes: 1 addition & 2 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type Settings struct {
InvertScroll bool `json:"invert_scroll,omitempty"` // natural scrolling invert
CompactMode bool `json:"compact_mode,omitempty"` // reduce outer padding
PaginatorLines int `json:"paginator_lines,omitempty"` // scrollback buffer lines (0 = unlimited)
Sandbox string `json:"sandbox,omitempty"` // off | docker — isolated tool execution (Mirror-style)
PaginatorShowLineNums *bool `json:"paginator_show_line_nums,omitempty"` // show line numbers in scrollback
}

Expand Down
82 changes: 82 additions & 0 deletions internal/hosttools/hosttools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Package hosttools provides a lightweight host-tool registry inspired by
// Mirror's Python extensions. External (e.g. Python) scripts can be registered
// as Reflect tools via JSON manifests in ~/.reflect/host_tools/*.json.
//
// Each manifest describes one tool: name, description, command, and args
// (with {input} placeholder replaced by the tool's JSON input). Reflect invokes
// the command and returns its stdout as the tool result.
package hosttools

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/GrayCodeAI/reflect/internal/storage"
)

// Manifest describes a host tool.
type Manifest struct {
Name string `json:"name"`
Description string `json:"description"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
}

// Dir returns the host-tools directory.
func Dir() string {
return filepath.Join(storage.ConfigDir(), "host_tools")
}

// Load reads all manifests from Dir().
func Load() ([]Manifest, error) {
dir := Dir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Manifest
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
continue
}
if m.Name == "" || m.Command == "" {
continue
}
out = append(out, m)
}
return out, nil
}

// Tool adapts a Manifest into a tool.Tool-compatible wrapper. The wrapper is
// defined in the tool package to avoid an import cycle; here we expose a
// constructor returning an opaque value. Callers should use hosttools.AsTool.
//
// Run executes a host tool with the given JSON input and returns stdout.
func Run(ctx context.Context, m Manifest, input string) (string, error) {
args := make([]string, len(m.Args))
for i, a := range m.Args {
args[i] = strings.ReplaceAll(a, "{input}", input)
}
cmd := exec.CommandContext(ctx, m.Command, args...) // #nosec G204 -- command comes from a trusted local manifest
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("host tool %s failed: %w: %s", m.Name, err, string(out))
}
return string(out), nil
}
Loading
Loading