Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
49237b3
feat: add command mode with /cmd, /pico, /hipico, /byepico commands
claude Feb 24, 2026
cad9d82
feat: add /help command available in all interactive modes
claude Feb 24, 2026
5f3fc4c
feat: add /usage command to show model info and token usage
claude Feb 24, 2026
f09be8f
refactor: rename printHelp to printCliHelp and printInteractiveHelp
Feb 24, 2026
d9ae60b
feat: add :edit command, replace Makefile with scripts/, improve cmd …
Feb 24, 2026
454d12f
Merge branch 'main' into dev_features
Feb 24, 2026
a9f1d0e
feat: inject cmd working directory into system prompt for hipico mode
Feb 24, 2026
18193c6
revert: restore Makefile and remove scripts/ directory
Feb 25, 2026
8bba821
Merge branch 'main' into dev_features
Feb 25, 2026
30fd9bf
fix: restore Makefile usage in build.yml and Dockerfile
Feb 25, 2026
3fe492c
fix: harden cmd mode cd redirection and allow ./executable in guard
Feb 25, 2026
06247ca
merge: sync dev_features with origin/main
Feb 26, 2026
9bf114e
fix: enforce workspace restriction in :edit command
Feb 26, 2026
e268735
fix: stop intercepting non-command : prefixed messages
Feb 26, 2026
64ff8a4
fix: remove silent ls -l injection, keep emoji for explicit ls -l
Feb 26, 2026
217728c
test: add unit tests for edit path, cd, guard, ls helpers
Feb 26, 2026
ed76642
style: fix golangci-lint formatting (gci, golines)
Feb 26, 2026
cc7e249
refactor: unify command prefix style (/ β†’ :) and merge handlers
Feb 26, 2026
f536cdd
revert: restore /show /list /switch as slash commands, hide from :help
Feb 26, 2026
ba0e9f5
fix: prevent cd traversal (../../..) from escaping workspace
Feb 26, 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
33 changes: 15 additions & 18 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Binaries
# Go build artifacts
# Binaries & build artifacts
bin/
build/
dist/
*.exe
*.dll
*.so
Expand All @@ -12,35 +12,32 @@ build/
/picoclaw-test
cmd/**/workspace

# Picoclaw specific

# PicoClaw
# PicoClaw workspace & config
.picoclaw/
config.json
sessions/
build/

# Coverage
cmd/picoclaw/workspace

# Secrets & Config (keep templates, ignore actual secrets)
# Secrets
.env
config/config.json

# Test
# Coverage
coverage.txt
coverage.html

# OS
.DS_Store

# Ralph workspace
ralph/
.ralph/
tasks/

# Editors
# Editors & tools
.vscode/
.idea/
.claude/

# Added by goreleaser init:
dist/
# Task tracking
TASKS.md

# Legacy
ralph/
.ralph/
tasks/
104 changes: 104 additions & 0 deletions pkg/agent/cmd_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package agent

import (
"os"
"path/filepath"
"testing"
)

func TestResolveEditPath_AbsoluteBlocked(t *testing.T) {
workspace := t.TempDir()
_, err := resolveEditPath("/etc/passwd", workspace, workspace)
if err == nil {
t.Fatal("Expected absolute path outside workspace to be blocked")
}
}

func TestResolveEditPath_TildeIsWorkspace(t *testing.T) {
workspace := t.TempDir()
// Create a file so the path resolves
os.WriteFile(filepath.Join(workspace, "test.txt"), []byte("hi"), 0o644)

path, err := resolveEditPath("~/test.txt", workspace, workspace)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expected := filepath.Join(workspace, "test.txt")
if path != expected {
t.Errorf("Expected %s, got %s", expected, path)
}
}

func TestResolveEditPath_BareTildeIsWorkspace(t *testing.T) {
workspace := t.TempDir()
path, err := resolveEditPath("~", workspace, workspace)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if path != workspace {
t.Errorf("Expected %s, got %s", workspace, path)
}
}

func TestResolveEditPath_TraversalBlocked(t *testing.T) {
workspace := t.TempDir()
_, err := resolveEditPath("../../etc/passwd", workspace, workspace)
if err == nil {
t.Fatal("Expected path traversal to be blocked")
}
}

func TestResolveEditPath_SymlinkBlocked(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
os.MkdirAll(workspace, 0o755)
secret := filepath.Join(root, "secret.txt")
os.WriteFile(secret, []byte("secret"), 0o644)
link := filepath.Join(workspace, "link.txt")
if err := os.Symlink(secret, link); err != nil {
t.Skip("symlinks not supported")
}
_, err := resolveEditPath("link.txt", workspace, workspace)
if err == nil {
t.Fatal("Expected symlink escape to be blocked")
}
}

func TestResolveEditPath_ValidRelative(t *testing.T) {
workspace := t.TempDir()
subdir := filepath.Join(workspace, "subdir")
os.MkdirAll(subdir, 0o755)
testFile := filepath.Join(subdir, "test.txt")
os.WriteFile(testFile, []byte("content"), 0o644)

path, err := resolveEditPath("subdir/test.txt", workspace, workspace)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if path != testFile {
t.Errorf("Expected %s, got %s", testFile, path)
}
}

func TestShortenHomePath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot get home dir")
}

tests := []struct {
input string
expected string
}{
{home, "~"},
{filepath.Join(home, "projects"), "~/projects"},
{"/tmp/other", "/tmp/other"},
}

for _, tt := range tests {
result := shortenHomePath(tt.input)
if result != tt.expected {
t.Errorf("shortenHomePath(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
16 changes: 16 additions & 0 deletions pkg/agent/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"

"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
Expand Down Expand Up @@ -31,6 +32,21 @@ type AgentInstance struct {
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate

// Accumulated token usage counters (atomic for concurrent safety)
TotalPromptTokens atomic.Int64
TotalCompletionTokens atomic.Int64
TotalRequests atomic.Int64
}

// AddUsage accumulates token usage from a single LLM response.
func (a *AgentInstance) AddUsage(usage *providers.UsageInfo) {
if usage == nil {
return
}
a.TotalPromptTokens.Add(int64(usage.PromptTokens))
a.TotalCompletionTokens.Add(int64(usage.CompletionTokens))
a.TotalRequests.Add(1)
}

// NewAgentInstance creates an agent instance from config.
Expand Down
Loading