Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a72b1c0
Improve OpenClaw DX context and workspace behavior
andyrewlee Feb 22, 2026
cd8e73d
fix(dx): allow dash-prefixed --task values in preflight
andyrewlee Feb 22, 2026
5c24afb
refactor(amux): generalize OpenClaw surfaces to assistant
andyrewlee Feb 22, 2026
a34cda3
Improve CLI routing and needs-input UX for non-bypass flows
andyrewlee Feb 23, 2026
fe83e58
Fix enter-only flows and expand needs-input prompt handling
andyrewlee Feb 23, 2026
4d99bc2
Remove OpenClaw compatibility scripts and fix jq label key parsing
andyrewlee Feb 23, 2026
1be6a75
build: add openclaw-sync workflow target
andyrewlee Feb 24, 2026
bb4b8f7
fix: restore jq 1.6 compatibility in assistant dx
andyrewlee Feb 24, 2026
01d745d
fix(hooks): isolate git env for pre-push tests
andyrewlee Feb 24, 2026
9e2c19f
fix(dx): avoid jq label keyword in workspace summary
andyrewlee Feb 24, 2026
1e63a39
fix(dx): harden jq argjson handling for workspace recovery
andyrewlee Feb 24, 2026
894e368
fix(cli): preserve quoted agent output lines
andyrewlee Feb 24, 2026
5628a3f
fix(cli): harden output parsing and doctor tmux checks
andyrewlee Feb 25, 2026
a1e75db
Fix project/workspace resolution and DX workflow robustness
andyrewlee Feb 26, 2026
6feb3ad
Split CLI test files to satisfy line-length checks
andyrewlee Feb 26, 2026
d583a7d
refactor: share text detection helpers and cache symlink resolution
andyrewlee Feb 27, 2026
9d9447c
refactor: split workspace path helpers into dedicated file
andyrewlee Feb 27, 2026
c790386
fix: harden assistant step chrome filtering and status checks
andyrewlee Feb 27, 2026
4b4ac32
Harden assistant orchestration and review flow reliability
andyrewlee Feb 28, 2026
dc6e4d0
rewrite amux assistant control plane to deterministic task flow
andyrewlee Feb 28, 2026
e67d64b
fix status workspace suggestion and wrapper cli contract drift
andyrewlee Feb 28, 2026
632eff7
Fix assistant-dx error-path reliability and macOS shell compatibility
andyrewlee Mar 1, 2026
9580c38
Fix assistant-dx JSON error handling for non-zero amux exits
andyrewlee Mar 1, 2026
6712b96
Fix assistant-dx task followups for completed no-agent status
andyrewlee Mar 1, 2026
0a1dc8f
Harden assistant-dx JSON envelope parsing and temp file handling
andyrewlee Mar 1, 2026
e1f7480
Add task capabilities and validate task status assistants
andyrewlee Mar 1, 2026
952facd
Respect include-stale in assistant-dx status
andyrewlee Mar 1, 2026
1ef75cc
Preserve assistant Try lines in output parsing
andyrewlee Mar 1, 2026
1e80522
Fix registry canonicalization for symlink aliases
andyrewlee Mar 2, 2026
d048bd8
Align assistant scripts with positional CLI contracts
andyrewlee Mar 2, 2026
2522390
Make dogfood review gate handle terminal attention states
andyrewlee Mar 2, 2026
f6a5146
Harden task start locking and fix assistant-step agent error envelope
andyrewlee Mar 3, 2026
e50e9da
Preserve session_exited precedence in task start mapping
andyrewlee Mar 4, 2026
1b79721
Shell-escape suggested task command values
andyrewlee Mar 4, 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
23 changes: 20 additions & 3 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,25 @@ fi

center_args=(${AMUX_HARNESS_CENTER_ARGS:---mode=center --tabs=8 --frames=200 --warmup=20 --hot-tabs=1 --payload-bytes=128})

# Git hooks run with repository-scoped env vars (e.g. GIT_DIR/GIT_WORK_TREE).
# Clear them so tests that spawn git commands operate in their own temp repos.
run_without_git_env() {
env \
-u GIT_DIR \
-u GIT_WORK_TREE \
-u GIT_INDEX_FILE \
-u GIT_OBJECT_DIRECTORY \
-u GIT_ALTERNATE_OBJECT_DIRECTORIES \
-u GIT_COMMON_DIR \
-u GIT_PREFIX \
"$@"
}

echo "amux pre-push: running headless UI harness (center)..."
go run ./cmd/amux-harness "${center_args[@]}"
run_without_git_env go run ./cmd/amux-harness "${center_args[@]}"

echo "amux pre-push: running CLI tests..."
run_without_git_env go test ./internal/cli

echo "amux pre-push: running tests..."
go test ./internal/e2e
echo "amux pre-push: running E2E tests..."
run_without_git_env go test ./internal/e2e
39 changes: 37 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,46 @@ HARNESS_WIDTH ?= 160
HARNESS_HEIGHT ?= 48
HARNESS_SCROLLBACK_FRAMES ?= 600
GOFUMPT ?= go run mvdan.cc/gofumpt@v0.9.2
OPENCLAW_DEFAULT_WORKSPACE ?= $(HOME)/.openclaw/workspace
OPENCLAW_DEV_WORKSPACE ?= $(HOME)/.openclaw/workspace-dev
OPENCLAW_AMUX_SKILL_SRC ?= $(CURDIR)/skills/amux

.PHONY: build install test bench lint lint-strict lint-strict-new lint-ci-parity check-file-length fmt fmt-check vet clean run dev devcheck help release-check release-tag release-push release harness-center harness-sidebar harness-monitor harness-presets
.PHONY: build install openclaw-sync test bench lint lint-strict lint-strict-new lint-ci-parity check-file-length fmt fmt-check vet clean run dev devcheck help release-check release-tag release-push release harness-center harness-sidebar harness-monitor harness-presets tmux-doctor tmux-prune

build:
go build -o $(BINARY_NAME) $(MAIN_PACKAGE)

install: build
cp $(BINARY_NAME) /usr/local/bin/$(BINARY_NAME)

openclaw-sync: install
@command -v openclaw >/dev/null 2>&1 || (echo "openclaw CLI is required"; exit 1)
@set -e; \
skill_src="$(OPENCLAW_AMUX_SKILL_SRC)"; \
main_ws="$$(openclaw config get agents.defaults.workspace 2>/dev/null || true)"; \
dev_ws="$$(openclaw --dev config get agents.defaults.workspace 2>/dev/null || true)"; \
[ -n "$$main_ws" ] || main_ws="$(OPENCLAW_DEFAULT_WORKSPACE)"; \
[ -n "$$dev_ws" ] || dev_ws="$(OPENCLAW_DEV_WORKSPACE)"; \
for ws in "$$main_ws" "$$dev_ws"; do \
mkdir -p "$$ws/skills"; \
rm -rf "$$ws/skills/amux"; \
ln -s "$$skill_src" "$$ws/skills/amux"; \
echo "Linked $$ws/skills/amux -> $$skill_src"; \
done
@openclaw skills info amux
@openclaw --dev skills info amux
@echo "OpenClaw amux binary installed and skill synced."

test:
go test -v ./...

DEVCHECK_TEST_FLAGS ?= -v

devcheck:
go vet ./...
go test ./...
# internal/cli can run for a few minutes; keep per-test output visible so
# local runs don't appear hung.
go test $(DEVCHECK_TEST_FLAGS) ./...
$(MAKE) lint

bench:
Expand Down Expand Up @@ -124,6 +149,8 @@ dev:
help:
@echo "Available targets:"
@echo " build - Build the binary"
@echo " install - Build and install amux to /usr/local/bin"
@echo " openclaw-sync - Build+install amux, relink OpenClaw amux skill, and verify"
@echo " test - Run all tests"
@echo " lint - Run golangci-lint and file length checks (max 500 lines)"
@echo " lint-strict - Run stricter lint profile across the whole repo"
Expand All @@ -141,6 +168,8 @@ help:
@echo " harness-sidebar - Run sidebar harness preset (deep scrollback)"
@echo " harness-monitor - Run monitor harness preset"
@echo " harness-presets - Run all harness presets"
@echo " tmux-doctor - Diagnose tmux/PTY pressure and stale sessions"
@echo " tmux-prune - Prune detached/orphaned amux tmux sessions"
@echo " release-check - Run tests and harness smoke checks"
@echo " release-tag - Create an annotated tag (VERSION=vX.Y.Z)"
@echo " release-push - Push the tag to origin (VERSION=vX.Y.Z)"
Expand All @@ -162,3 +191,9 @@ release-push:
@git push origin "$(VERSION)"

release: release-check release-tag release-push

tmux-doctor:
go run ./cmd/amux doctor tmux

tmux-prune:
go run ./cmd/amux doctor tmux --prune --yes --older-than 6h
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Then run `amux` to open the dashboard.

Each workspace tracks a repo checkout and its metadata. For local workflows, workspaces are typically backed by git worktrees on their own branches so agents work in isolation and you can merge changes back when done.

Workspace scope terminology:
- **Project workspace**: created directly for a project (`amux workspace create <name> --project <repo>`). By default, it starts from the project's default branch.
- **Nested workspace**: created from an existing workspace context (Assistant DX: `workspace create --scope nested --from-workspace <id>`). It remains isolated, and also starts from the project's default branch.

## Architecture quick tour

Start with `internal/app/ARCHITECTURE.md` for lifecycle, PTY flow, tmux tagging, and persistence invariants. Message boundaries and command discipline are documented in `internal/app/MESSAGE_FLOW.md`.
Expand Down Expand Up @@ -94,8 +98,8 @@ Assistant profiles can be configured in `~/.amux/config.json`:
```json
{
"assistants": {
"openclaw": {
"command": "openclaw",
"assistant": {
"command": "assistant",
"interrupt_count": 1,
"interrupt_delay_ms": 0
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app_operations_rescan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func TestRescanWorkspaces_IgnoresExternalWorktrees(t *testing.T) {

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down
48 changes: 44 additions & 4 deletions internal/app/app_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"

Expand Down Expand Up @@ -61,7 +62,7 @@ func TestLoadProjects_StoreFirstMerge(t *testing.T) {

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -135,7 +136,7 @@ func TestRescanWorkspaces_ImportsDiscoveredWorkspaces(t *testing.T) {

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -169,7 +170,7 @@ func TestRescanWorkspaces_ImportsDiscoveredWorkspaces(t *testing.T) {

project = nil
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -257,7 +258,7 @@ func TestLoadProjects_PrimaryLegacyMetadataUsesDefaultAssistant(t *testing.T) {

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand All @@ -283,6 +284,45 @@ func TestLoadProjects_PrimaryLegacyMetadataUsesDefaultAssistant(t *testing.T) {
}
}

func TestLoadProjects_DedupesCanonicalProjectAliases(t *testing.T) {
skipIfNoGit(t)
if runtime.GOOS == "windows" {
t.Skip("symlink path canonicalization path is unstable on windows in test environment")
}

repoReal := filepath.Join(t.TempDir(), "repo-real")
if err := os.MkdirAll(repoReal, 0o755); err != nil {
t.Fatalf("MkdirAll(repoReal) error = %v", err)
}
runGit(t, repoReal, "init", "-b", "main")

repoLink := filepath.Join(t.TempDir(), "repo-link")
if err := os.Symlink(repoReal, repoLink); err != nil {
t.Fatalf("Symlink() error = %v", err)
}

tmp := t.TempDir()
registryPath := filepath.Join(tmp, "projects.json")
raw := `{"projects":[{"name":"repo","path":"` + repoReal + `"},{"name":"repo","path":"` + repoLink + `"}]}`
if err := os.WriteFile(registryPath, []byte(raw), 0o644); err != nil {
t.Fatalf("WriteFile(registry) error = %v", err)
}

registry := data.NewRegistry(registryPath)
store := data.NewWorkspaceStore(filepath.Join(tmp, "workspaces-metadata"))
workspaceService := newWorkspaceService(registry, store, nil, filepath.Join(tmp, "workspaces"))
app := &App{workspaceService: workspaceService}

msg := app.loadProjects()()
loaded, ok := msg.(messages.ProjectsLoaded)
if !ok {
t.Fatalf("expected ProjectsLoaded, got %T", msg)
}
if len(loaded.Projects) != 1 {
t.Fatalf("expected 1 project after canonical alias dedupe, got %d", len(loaded.Projects))
}
}

func normalizePath(path string) string {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
return filepath.Clean(resolved)
Expand Down
2 changes: 1 addition & 1 deletion internal/app/workspace_service_create_pending_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestCreateWorkspaceEmptyBaseResolvesToMainBranch(t *testing.T) {

// Switch to a feature branch so HEAD != main, simulating the bug
// scenario where the user is on a different workspace branch.
runGit(t, repo, "checkout", "-b", "openclaw")
runGit(t, repo, "checkout", "-b", "assistant")

var capturedBase string
svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces")
Expand Down
32 changes: 22 additions & 10 deletions internal/app/workspace_service_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ func (s *workspaceService) LoadProjects() tea.Cmd {
}

var projects []data.Project
for _, path := range paths {
if !git.IsGitRepository(path) {
continue
}

for _, path := range uniqueRegisteredGitRepos(paths) {
project := data.NewProject(path)

// Start from stored workspaces so metadata is authoritative.
Expand Down Expand Up @@ -111,11 +107,7 @@ func (s *workspaceService) RescanWorkspaces() tea.Cmd {
return messages.Error{Err: err, Context: errorContext(errorServiceWorkspace, "rescanning workspaces")}
}

for _, path := range paths {
if !git.IsGitRepository(path) {
continue
}

for _, path := range uniqueRegisteredGitRepos(paths) {
project := data.NewProject(path)
discoveredWorkspaces, err := git.DiscoverWorkspaces(project)
if err != nil {
Expand Down Expand Up @@ -187,3 +179,23 @@ func (s *workspaceService) RescanWorkspaces() tea.Cmd {
return messages.RefreshDashboard{}
}
}

func uniqueRegisteredGitRepos(paths []string) []string {
out := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
if !git.IsGitRepository(path) {
continue
}
key := data.NormalizePath(path)
if key == "" {
key = filepath.Clean(path)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, path)
}
return out
}
8 changes: 4 additions & 4 deletions internal/app/workspace_service_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestLoadProjects_SymlinkedWorkspacesRootKeepsMissingManagedWorkspace(t *tes

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -139,7 +139,7 @@ func TestLoadProjects_SymlinkedWorkspacesRootKeepsMissingResolvedManagedWorkspac

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -211,7 +211,7 @@ func TestLoadProjects_BrokenSymlinkedWorkspacesRootKeepsMissingResolvedManagedWo

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repo {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repo) {
project = &loaded.Projects[i]
break
}
Expand Down Expand Up @@ -288,7 +288,7 @@ func TestWorkspaceVisibilityAcrossRepoAliasBasenameChange(t *testing.T) {

var project *data.Project
for i := range loaded.Projects {
if loaded.Projects[i].Path == repoAlias {
if normalizePath(loaded.Projects[i].Path) == normalizePath(repoAlias) {
project = &loaded.Projects[i]
break
}
Expand Down
Loading