Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
85 lines
1.6 KiB
Go
85 lines
1.6 KiB
Go
package shell
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"sync"
|
|
)
|
|
|
|
type registryEntry struct {
|
|
shell Shell
|
|
weight int
|
|
}
|
|
|
|
// Registry holds shells with associated weights for random selection.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
entries []registryEntry
|
|
}
|
|
|
|
// NewRegistry returns an empty Registry.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{}
|
|
}
|
|
|
|
// Register adds a shell with the given weight. Weight must be >= 1 and
|
|
// no duplicate names are allowed.
|
|
func (r *Registry) Register(shell Shell, weight int) error {
|
|
if weight < 1 {
|
|
return fmt.Errorf("weight must be >= 1, got %d", weight)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
for _, e := range r.entries {
|
|
if e.shell.Name() == shell.Name() {
|
|
return fmt.Errorf("shell %q already registered", shell.Name())
|
|
}
|
|
}
|
|
|
|
r.entries = append(r.entries, registryEntry{shell: shell, weight: weight})
|
|
return nil
|
|
}
|
|
|
|
// Select picks a shell using weighted random selection.
|
|
func (r *Registry) Select() (Shell, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
if len(r.entries) == 0 {
|
|
return nil, errors.New("no shells registered")
|
|
}
|
|
|
|
total := 0
|
|
for _, e := range r.entries {
|
|
total += e.weight
|
|
}
|
|
|
|
pick := rand.IntN(total)
|
|
cumulative := 0
|
|
for _, e := range r.entries {
|
|
cumulative += e.weight
|
|
if pick < cumulative {
|
|
return e.shell, nil
|
|
}
|
|
}
|
|
|
|
// Should never reach here, but return last entry as fallback.
|
|
return r.entries[len(r.entries)-1].shell, nil
|
|
}
|
|
|
|
// Get returns a shell by name.
|
|
func (r *Registry) Get(name string) (Shell, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
for _, e := range r.entries {
|
|
if e.shell.Name() == name {
|
|
return e.shell, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|