Compare commits

..

2 Commits

Author SHA1 Message Date
cexll
4dd735034e fix: parser critical bugs and add PR #86 compatibility
Fixes 3 critical bugs in parser.go:

1. Gemini detection: Change Delta from bool to *bool for proper field
   presence detection. Fixes issue where delta:false events were lost.

2. Codex detection: Tighten logic to only classify as Codex if
   thread_id exists OR item.type is non-empty. Prevents Claude events
   with item:null/item:{} from being misclassified and dropped.

3. Performance: Move itemHeader parsing inside item.completed case,
   only parse when actually needed.

Additional changes:
- Implement PR #86's Gemini notifyMessage trigger on Status field
  instead of Content, resolving parser.go conflict between PRs
- Add regression tests for all fixed scenarios
- All tests pass: go test ./...

Co-authored-by: Codeagent (Codex)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 13:55:48 +08:00
徐文彬
a08dd62b59 Parser重复解析问题优化 2025-12-21 00:29:07 +08:00
211 changed files with 6447 additions and 19028 deletions

View File

@@ -1,47 +1,209 @@
{ {
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "claude-code-dev-workflows",
"name": "myclaude",
"version": "5.6.1",
"description": "Professional multi-agent development workflows with OmO orchestration, Requirements-Driven and BMAD methodologies",
"owner": { "owner": {
"name": "cexll", "name": "Claude Code Dev Workflows",
"email": "evanxian9@gmail.com" "email": "contact@example.com",
"url": "https://github.com/cexll/myclaude"
},
"metadata": {
"description": "Professional multi-agent development workflows with Requirements-Driven and BMAD methodologies, featuring 16+ specialized agents and 12+ commands",
"version": "1.0.0"
}, },
"plugins": [ "plugins": [
{ {
"name": "omo", "name": "requirements-driven-development",
"description": "Multi-agent orchestration for code analysis, bug investigation, fix planning, and implementation with intelligent routing to specialized agents", "source": "./requirements-driven-workflow/",
"version": "5.6.1", "description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
"source": "./skills/omo", "version": "1.0.0",
"category": "development" "author": {
"name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"requirements",
"workflow",
"automation",
"quality-gates",
"feature-development",
"agile",
"specifications"
],
"category": "workflows",
"strict": false,
"commands": [
"./commands/requirements-pilot.md"
],
"agents": [
"./agents/requirements-generate.md",
"./agents/requirements-code.md",
"./agents/requirements-testing.md",
"./agents/requirements-review.md"
]
}, },
{ {
"name": "requirements", "name": "bmad-agile-workflow",
"description": "Requirements-driven development workflow with quality gates for practical feature implementation", "source": "./bmad-agile-workflow/",
"version": "5.6.1",
"source": "./agents/requirements",
"category": "development"
},
{
"name": "bmad",
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates", "description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
"version": "5.6.1", "version": "1.0.0",
"source": "./agents/bmad", "author": {
"category": "development" "name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"bmad",
"agile",
"scrum",
"product-owner",
"architect",
"developer",
"qa",
"workflow-orchestration"
],
"category": "workflows",
"strict": false,
"commands": [
"./commands/bmad-pilot.md"
],
"agents": [
"./agents/bmad-po.md",
"./agents/bmad-architect.md",
"./agents/bmad-sm.md",
"./agents/bmad-dev.md",
"./agents/bmad-qa.md",
"./agents/bmad-orchestrator.md",
"./agents/bmad-review.md"
]
}, },
{ {
"name": "dev-kit", "name": "development-essentials",
"source": "./development-essentials/",
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation", "description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
"version": "5.6.1", "version": "1.0.0",
"source": "./agents/development-essentials", "author": {
"category": "productivity" "name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"code",
"debug",
"test",
"optimize",
"review",
"bugfix",
"refactor",
"documentation"
],
"category": "essentials",
"strict": false,
"commands": [
"./commands/code.md",
"./commands/debug.md",
"./commands/test.md",
"./commands/optimize.md",
"./commands/review.md",
"./commands/bugfix.md",
"./commands/refactor.md",
"./commands/docs.md",
"./commands/ask.md",
"./commands/think.md"
],
"agents": [
"./agents/code.md",
"./agents/bugfix.md",
"./agents/bugfix-verify.md",
"./agents/optimize.md",
"./agents/debug.md"
]
}, },
{ {
"name": "sparv", "name": "codex-cli",
"description": "Minimal SPARV workflow (Specify→Plan→Act→Review→Vault) with 10-point spec gate, unified journal, 2-action saves, 3-failure protocol, and EHRB risk detection", "source": "./skills/codex/",
"version": "1.1.0", "description": "Execute Codex CLI for code analysis, refactoring, and automated code changes with file references (@syntax) and structured output",
"source": "./skills/sparv", "version": "1.0.0",
"category": "development" "author": {
"name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"codex",
"code-analysis",
"refactoring",
"automation",
"gpt-5",
"ai-coding"
],
"category": "essentials",
"strict": false,
"skills": [
"./SKILL.md"
]
},
{
"name": "gemini-cli",
"source": "./skills/gemini/",
"description": "Execute Gemini CLI for AI-powered code analysis and generation with Google's latest Gemini models",
"version": "1.0.0",
"author": {
"name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"gemini",
"google-ai",
"code-analysis",
"code-generation",
"ai-reasoning"
],
"category": "essentials",
"strict": false,
"skills": [
"./SKILL.md"
]
},
{
"name": "dev-workflow",
"source": "./dev-workflow/",
"description": "Minimal lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
"version": "1.0.0",
"author": {
"name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"dev",
"workflow",
"codex",
"testing",
"coverage",
"concurrent",
"lightweight"
],
"category": "workflows",
"strict": false,
"commands": [
"./commands/dev.md"
],
"agents": [
"./agents/dev-plan-generator.md"
]
} }
] ]
} }

View File

@@ -8,10 +8,7 @@ on:
jobs: jobs:
test: test:
strategy: runs-on: ubuntu-latest
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,13 +21,11 @@ jobs:
run: | run: |
cd codeagent-wrapper cd codeagent-wrapper
go test -v -cover -coverprofile=coverage.out ./... go test -v -cover -coverprofile=coverage.out ./...
shell: bash
- name: Check coverage - name: Check coverage
run: | run: |
cd codeagent-wrapper cd codeagent-wrapper
go tool cover -func=coverage.out | grep total | awk '{print $3}' go tool cover -func=coverage.out | grep total | awk '{print $3}'
shell: bash
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4

View File

@@ -74,7 +74,7 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then if [ "${{ matrix.goos }}" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe" OUTPUT_NAME="${OUTPUT_NAME}.exe"
fi fi
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent-wrapper go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
chmod +x ${OUTPUT_NAME} chmod +x ${OUTPUT_NAME}
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
@@ -97,6 +97,11 @@ jobs:
with: with:
path: artifacts path: artifacts
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Prepare release files - name: Prepare release files
run: | run: |
mkdir -p release mkdir -p release
@@ -104,10 +109,26 @@ jobs:
cp install.sh install.bat release/ cp install.sh install.bat release/
ls -la release/ ls -la release/
- name: Generate release notes with git-cliff
run: |
# Install git-cliff via npx
npx git-cliff@latest --current --strip all -o release_notes.md
# Fallback if generation failed
if [ ! -s release_notes.md ]; then
echo "⚠️ Failed to generate release notes with git-cliff" > release_notes.md
echo "" >> release_notes.md
echo "## What's Changed" >> release_notes.md
echo "See commits in this release for details." >> release_notes.md
fi
echo "--- Generated Release Notes ---"
cat release_notes.md
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: release/* files: release/*
generate_release_notes: true body_path: release_notes.md
draft: false draft: false
prerelease: false prerelease: false

4
.gitignore vendored
View File

@@ -1,11 +1,7 @@
.claude/ .claude/
.claude-trace .claude-trace
.DS_Store
**/.DS_Store
.venv .venv
.pytest_cache .pytest_cache
__pycache__ __pycache__
.coverage .coverage
coverage.out coverage.out
references
output/

View File

@@ -2,451 +2,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [6.0.0] - 2026-01-26
### 🚀 Features
- support `npx github:cexll/myclaude` for installation and execution
- default module changed from `dev` to `do`
### 🚜 Refactor
- restructure: create `agents/` and move `bmad-agile-workflow``agents/bmad`, `requirements-driven-workflow``agents/requirements`, `development-essentials``agents/development-essentials`
- remove legacy directories: `docs/`, `hooks/`, `dev-workflow/`
- update references across `config.json`, `README.md`, `README_CN.md`, `marketplace.json`, etc.
### 📚 Documentation
- add `skills/README.md` and `PLUGIN_README.md`
### 💼 Other
- add `package.json` and `bin/cli.js` for npx packaging
## [6.1.5] - 2026-01-25
### 🐛 Bug Fixes
- correct gitignore to not exclude cmd/codeagent-wrapper
## [6.1.4] - 2026-01-25
### 🐛 Bug Fixes
- support concurrent tasks with unique state files
## [6.1.3] - 2026-01-25
### 🐛 Bug Fixes
- correct build path in release workflow
- increase stdoutDrainTimeout from 100ms to 500ms
## [6.1.2] - 2026-01-24
### 🐛 Bug Fixes
- use ANTHROPIC_AUTH_TOKEN for Claude CLI env injection
### 💼 Other
- update codeagent version
### 📚 Documentation
- restructure root READMEs with do as recommended workflow
- update do/omo/sparv module READMEs with detailed workflows
- add README for bmad and requirements modules
### 🧪 Testing
- use prefix match for version flag tests
## [6.1.1] - 2026-01-23
### 🚜 Refactor
- rename feature-dev to do workflow
## [6.1.0] - 2026-01-23
### ⚙️ Miscellaneous Tasks
- ignore references directory
- add go.work.sum for workspace dependencies
### 🐛 Bug Fixes
- read GEMINI_MODEL from ~/.gemini/.env ([#131](https://github.com/cexll/myclaude/issues/131))
- validate non-empty output message before printing
### 🚀 Features
- add feature-dev skill with 7-phase workflow
- support \${CLAUDE_PLUGIN_ROOT} variable in hooks config
## [6.0.0-alpha1] - 2026-01-20
### 🐛 Bug Fixes
- add missing cmd/codeagent/main.go entry point
- update release workflow build path for new directory structure
- write PATH config to both profile and rc files ([#128](https://github.com/cexll/myclaude/issues/128))
### 🚀 Features
- add course module with dev, product-requirements and test-cases skills
- add hooks management to install.py
### 🚜 Refactor
- restructure codebase to internal/ directory with modular architecture
## [5.6.7] - 2026-01-17
### 💼 Other
- remove .sparv
### 📚 Documentation
- update 'Agent Hierarchy' model for frontend-ui-ux-engineer and document-writer in README ([#127](https://github.com/cexll/myclaude/issues/127))
- update mappings for frontend-ui-ux-engineer and document-writer in README ([#126](https://github.com/cexll/myclaude/issues/126))
### 🚀 Features
- add sparv module and interactive plugin manager
- add sparv enhanced rules v1.1
- add sparv skill to claude-plugin v1.1.0
- feat sparv skill
## [5.6.6] - 2026-01-16
### 🐛 Bug Fixes
- remove extraneous dash arg for opencode stdin mode ([#124](https://github.com/cexll/myclaude/issues/124))
### 💼 Other
- update readme
## [5.6.5] - 2026-01-16
### 🐛 Bug Fixes
- correct default models for oracle and librarian agents ([#120](https://github.com/cexll/myclaude/issues/120))
### 🚀 Features
- feat dev skill
## [5.6.4] - 2026-01-15
### 🐛 Bug Fixes
- filter codex 0.84.0 stderr noise logs ([#122](https://github.com/cexll/myclaude/issues/122))
- filter codex stderr noise logs
## [5.6.3] - 2026-01-14
### ⚙️ Miscellaneous Tasks
- bump codeagent-wrapper version to 5.6.3
### 🐛 Bug Fixes
- update version tests to match 5.6.3
- use config override for codex reasoning effort
## [5.6.2] - 2026-01-14
### 🐛 Bug Fixes
- propagate SkipPermissions to parallel tasks ([#113](https://github.com/cexll/myclaude/issues/113))
- add timeout for Windows process termination
- reject dash as workdir parameter ([#118](https://github.com/cexll/myclaude/issues/118))
### 📚 Documentation
- add OmO workflow to README and fix plugin marketplace structure
### 🚜 Refactor
- remove sisyphus agent and unused code
## [5.6.1] - 2026-01-13
### 🐛 Bug Fixes
- add sleep in fake script to prevent CI race condition
- fix gemini env load
- fix omo
### 🚀 Features
- add reasoning effort config for codex backend
## [5.6.0] - 2026-01-13
### 📚 Documentation
- update FAQ for default bypass/skip-permissions behavior
### 🚀 Features
- default to skip-permissions and bypass-sandbox
- add omo module for multi-agent orchestration
### 🚜 Refactor
- streamline agent documentation and remove sisyphus
## [5.5.0] - 2026-01-12
### 🐛 Bug Fixes
- 修复 Gemini init 事件 session_id 未提取的问题 ([#111](https://github.com/cexll/myclaude/issues/111))
- fix codeagent skill TaskOutput
### 💼 Other
- Merge branch 'master' of github.com:cexll/myclaude
- add test-cases skill
- add browser skill
### 🚀 Features
- add multi-agent support with yolo mode
## [5.4.4] - 2026-01-08
### 💼 Other
- 修复 Windows 后端退出taskkill 结束进程树 + turn.completed 支持 ([#108](https://github.com/cexll/myclaude/issues/108))
## [5.4.3] - 2026-01-06
### 🐛 Bug Fixes
- support model parameter for all backends, auto-inject from settings ([#105](https://github.com/cexll/myclaude/issues/105))
### 📚 Documentation
- add FAQ Q5 for permission/sandbox env vars
### 🚀 Features
- feat skill-install install script and security scan
- add uninstall scripts with selective module removal
## [5.4.2] - 2025-12-31
### 🐛 Bug Fixes
- replace setx with reg add to avoid 1024-char PATH truncation ([#101](https://github.com/cexll/myclaude/issues/101))
## [5.4.1] - 2025-12-26
### 🐛 Bug Fixes
- 移除未知事件格式的日志噪声 ([#96](https://github.com/cexll/myclaude/issues/96))
- prevent duplicate PATH entries on reinstall ([#95](https://github.com/cexll/myclaude/issues/95))
### 📚 Documentation
- 添加 FAQ 常见问题章节
- update troubleshooting with idempotent PATH commands ([#95](https://github.com/cexll/myclaude/issues/95))
### 🚀 Features
- Add intelligent backend selection based on task complexity ([#61](https://github.com/cexll/myclaude/issues/61))
## [5.4.0] - 2025-12-24
### 🐛 Bug Fixes
- Minor issues #12 and #13 - ASCII mode and performance optimization
- code review fixes for PR #94 - all critical and major issues resolved
### 🚀 Features
- v5.4.0 structured execution report ([#94](https://github.com/cexll/myclaude/issues/94))
## [5.2.8] - 2025-12-22
### ⚙️ Miscellaneous Tasks
- simplify release workflow to use GitHub auto-generated notes
### 🐛 Bug Fixes
- correct settings.json filename and bump version to v5.2.8
## [5.2.7] - 2025-12-21
### ⚙️ Miscellaneous Tasks
- bump version to v5.2.7
### 🐛 Bug Fixes
- allow claude backend to read env from setting.json while preventing recursion ([#92](https://github.com/cexll/myclaude/issues/92))
- comprehensive security and quality improvements for PR #85 & #87 ([#90](https://github.com/cexll/myclaude/issues/90))
- Parser重复解析优化 + 严重bug修复 + PR #86兼容性 ([#88](https://github.com/cexll/myclaude/issues/88))
### 💼 Other
- Improve backend termination after message and extend timeout ([#86](https://github.com/cexll/myclaude/issues/86))
### 🚀 Features
- add millisecond-precision timestamps to all log entries ([#91](https://github.com/cexll/myclaude/issues/91))
## [5.2.6] - 2025-12-19
### 🐛 Bug Fixes
- filter noisy stderr output from gemini backend ([#83](https://github.com/cexll/myclaude/issues/83))
- 修復 wsl install.sh 格式問題 ([#78](https://github.com/cexll/myclaude/issues/78))
### 💼 Other
- update all readme
- BMADh和Requirements-Driven支持根据语义生成对应的文档 ([#82](https://github.com/cexll/myclaude/issues/82))
## [5.2.5] - 2025-12-17
### 🐛 Bug Fixes
- 修复多 backend 并行日志 PID 混乱并移除包装格式 ([#74](https://github.com/cexll/myclaude/issues/74)) ([#76](https://github.com/cexll/myclaude/issues/76))
- replace "Codex" to "codeagent" in dev-plan-generator subagent
- 修復 win python install.py
### 💼 Other
- Merge pull request #71 from aliceric27/master
- Merge branch 'cexll:master' into master
- Merge pull request #72 from changxvv/master
- update changelog
- update codeagent skill backend select
## [5.2.4] - 2025-12-16 ## [5.2.4] - 2025-12-16

View File

@@ -7,12 +7,12 @@
help: help:
@echo "Claude Code Multi-Agent Workflow - Quick Deployment" @echo "Claude Code Multi-Agent Workflow - Quick Deployment"
@echo "" @echo ""
@echo "Recommended installation: npx github:cexll/myclaude" @echo "Recommended installation: python3 install.py --install-dir ~/.claude"
@echo "" @echo ""
@echo "Usage: make [target]" @echo "Usage: make [target]"
@echo "" @echo ""
@echo "Targets:" @echo "Targets:"
@echo " install - LEGACY: install all configurations (prefer npx github:cexll/myclaude)" @echo " install - LEGACY: install all configurations (prefer install.py)"
@echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)" @echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)"
@echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)" @echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)"
@echo " deploy-essentials - Deploy Development Essentials workflow" @echo " deploy-essentials - Deploy Development Essentials workflow"
@@ -31,16 +31,16 @@ CLAUDE_CONFIG_DIR = ~/.claude
SPECS_DIR = .claude/specs SPECS_DIR = .claude/specs
# Workflow directories # Workflow directories
BMAD_DIR = agents/bmad BMAD_DIR = bmad-agile-workflow
REQUIREMENTS_DIR = agents/requirements REQUIREMENTS_DIR = requirements-driven-workflow
ESSENTIALS_DIR = agents/development-essentials ESSENTIALS_DIR = development-essentials
ADVANCED_DIR = advanced-ai-agents ADVANCED_DIR = advanced-ai-agents
OUTPUT_STYLES_DIR = output-styles OUTPUT_STYLES_DIR = output-styles
# Install all configurations # Install all configurations
install: deploy-all install: deploy-all
@echo "⚠️ LEGACY PATH: make install will be removed in future versions." @echo "⚠️ LEGACY PATH: make install will be removed in future versions."
@echo " Prefer: npx github:cexll/myclaude" @echo " Prefer: python3 install.py --install-dir ~/.claude"
@echo "✅ Installation complete!" @echo "✅ Installation complete!"
# Deploy BMAD workflow # Deploy BMAD workflow
@@ -159,3 +159,4 @@ changelog:
@echo "" @echo ""
@echo "Preview the changes:" @echo "Preview the changes:"
@echo " git diff CHANGELOG.md" @echo " git diff CHANGELOG.md"

View File

@@ -1,18 +0,0 @@
# Plugin System
Claude Code plugins for this repo are defined in `.claude-plugin/marketplace.json`.
## Install
```bash
/plugin marketplace add cexll/myclaude
/plugin list
```
## Available Plugins
- `bmad` - BMAD workflow (`./agents/bmad`)
- `requirements` - requirements-driven workflow (`./agents/requirements`)
- `dev-kit` - development essentials (`./agents/development-essentials`)
- `omo` - orchestration skill (`./skills/omo`)
- `sparv` - SPARV workflow (`./skills/sparv`)

375
README.md
View File

@@ -3,107 +3,290 @@
# Claude Code Multi-Agent Workflow System # Claude Code Multi-Agent Workflow System
[![Run in Smithery](https://smithery.ai/badge/skills/cexll)](https://smithery.ai/skills?ns=cexll&utm_source=github&utm_medium=badge) [![Run in Smithery](https://smithery.ai/badge/skills/cexll)](https://smithery.ai/skills?ns=cexll&utm_source=github&utm_medium=badge)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code) [![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-6.x-green)](https://github.com/cexll/myclaude) [![Version](https://img.shields.io/badge/Version-5.2-green)](https://github.com/cexll/myclaude)
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini/OpenCode) > AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
## Quick Start ## Core Concept: Multi-Backend Architecture
This system leverages a **dual-agent architecture** with pluggable AI backends:
| Role | Agent | Responsibility |
|------|-------|----------------|
| **Orchestrator** | Claude Code | Planning, context gathering, verification, user interaction |
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini backends) |
**Why this separation?**
- Claude Code excels at understanding context and orchestrating complex workflows
- Specialized backends (Codex for code, Claude for reasoning, Gemini for prototyping) excel at focused execution
- Backend selection via `--backend codex|claude|gemini` matches the model to the task
## Quick Start(Please execute in Powershell on Windows)
```bash ```bash
npx github:cexll/myclaude git clone https://github.com/cexll/myclaude.git
cd myclaude
python3 install.py --install-dir ~/.claude
``` ```
## Modules Overview ## Workflows Overview
| Module | Description | Documentation | ### 1. Dev Workflow (Recommended)
|--------|-------------|---------------|
| [do](skills/do/README.md) | **Recommended** - 7-phase feature development with codeagent orchestration | `/do` command | **The primary workflow for most development tasks.**
| [omo](skills/omo/README.md) | Multi-agent orchestration with intelligent routing | `/omo` command |
| [bmad](agents/bmad/README.md) | BMAD agile workflow with 6 specialized agents | `/bmad-pilot` command | ```bash
| [requirements](agents/requirements/README.md) | Lightweight requirements-to-code pipeline | `/requirements-pilot` command | /dev "implement user authentication with JWT"
| [essentials](agents/development-essentials/README.md) | Core development commands and utilities | `/code`, `/debug`, etc. | ```
| [sparv](skills/sparv/README.md) | SPARV workflow (Specify→Plan→Act→Review→Vault) | `/sparv` command |
| course | Course development (combines dev + product-requirements + test-cases) | Composite module | **6-Step Process:**
1. **Requirements Clarification** - Interactive Q&A to clarify scope
2. **Codex Deep Analysis** - Codebase exploration and architecture decisions
3. **Dev Plan Generation** - Structured task breakdown with test requirements
4. **Parallel Execution** - Codex executes tasks concurrently
5. **Coverage Validation** - Enforce ≥90% test coverage
6. **Completion Summary** - Report with file changes and coverage stats
**Key Features:**
- Claude Code orchestrates, Codex executes all code changes
- Automatic task parallelization for speed
- Mandatory 90% test coverage gate
- Rollback on failure
**Best For:** Feature development, refactoring, bug fixes with tests
---
### 2. BMAD Agile Workflow
**Full enterprise agile methodology with 6 specialized agents.**
```bash
/bmad-pilot "build e-commerce checkout system"
```
**Agents:**
| Agent | Role |
|-------|------|
| Product Owner | Requirements & user stories |
| Architect | System design & tech decisions |
| Tech Lead | Sprint planning & task breakdown |
| Developer | Implementation |
| Code Reviewer | Quality assurance |
| QA Engineer | Testing & validation |
**Process:**
```
Requirements → Architecture → Sprint Plan → Development → Review → QA
↓ ↓ ↓ ↓ ↓ ↓
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
```
**Best For:** Large features, team coordination, enterprise projects
---
### 3. Requirements-Driven Workflow
**Lightweight requirements-to-code pipeline.**
```bash
/requirements-pilot "implement API rate limiting"
```
**Process:**
1. Requirements generation with quality scoring
2. Implementation planning
3. Code generation
4. Review and testing
**Best For:** Quick prototypes, well-defined features
---
### 4. Development Essentials
**Direct commands for daily coding tasks.**
| Command | Purpose |
|---------|---------|
| `/code` | Implement a feature |
| `/debug` | Debug an issue |
| `/test` | Write tests |
| `/review` | Code review |
| `/optimize` | Performance optimization |
| `/refactor` | Code refactoring |
| `/docs` | Documentation |
**Best For:** Quick tasks, no workflow overhead needed
## Enterprise Workflow Features
- **Multi-backend execution:** `codeagent-wrapper --backend codex|claude|gemini` (default `codex`) so you can match the model to the task without changing workflows.
- **GitHub workflow commands:** `/gh-create-issue "short need"` creates structured issues; `/gh-issue-implement 123` pulls issue #123, drives development, and prepares the PR.
- **Skills + hooks activation:** .claude/hooks run automation (tests, reviews), while `.claude/skills/skill-rules.json` auto-suggests the right skills. Keep hooks enabled in `.claude/settings.json` to activate the enterprise workflow helpers.
---
## Installation ## Installation
### Modular Installation (Recommended)
```bash ```bash
# Interactive installer (recommended) # Install all enabled modules (dev + essentials by default)
npx github:cexll/myclaude python3 install.py --install-dir ~/.claude
# List installable items (modules / skills / wrapper) # Install specific module
npx github:cexll/myclaude --list python3 install.py --module dev
# Detect installed modules and update from GitHub # List available modules
npx github:cexll/myclaude --update python3 install.py --list-modules
# Custom install directory / overwrite # Force overwrite existing files
npx github:cexll/myclaude --install-dir ~/.claude --force python3 install.py --force
``` ```
`--update` detects already installed modules in the target install dir (defaults to `~/.claude`, via `installed_modules.json` when present) and updates them from GitHub (latest release) by overwriting the module files. ### Available Modules
### Module Configuration | Module | Default | Description |
|--------|---------|-------------|
| `dev` | ✓ Enabled | Dev workflow + Codex integration |
| `essentials` | ✓ Enabled | Core development commands |
| `bmad` | Disabled | Full BMAD agile workflow |
| `requirements` | Disabled | Requirements-driven workflow |
Edit `config.json` to enable/disable modules: ### What Gets Installed
```
~/.claude/
├── CLAUDE.md # Core instructions and role definition
├── commands/ # Slash commands (/dev, /code, etc.)
├── agents/ # Agent definitions
├── skills/
│ └── codex/
│ └── SKILL.md # Codex integration skill
└── installed_modules.json # Installation status
```
### Configuration
Edit `config.json` to customize:
```json ```json
{ {
"version": "1.0",
"install_dir": "~/.claude",
"modules": { "modules": {
"bmad": { "enabled": false }, "dev": {
"requirements": { "enabled": false }, "enabled": true,
"essentials": { "enabled": false }, "operations": [
"omo": { "enabled": false }, {"type": "merge_dir", "source": "dev-workflow"},
"sparv": { "enabled": false }, {"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
"do": { "enabled": true }, {"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
"course": { "enabled": false } {"type": "run_command", "command": "bash install.sh"}
]
}
} }
} }
``` ```
**Operation Types:**
| Type | Description |
|------|-------------|
| `merge_dir` | Merge subdirs (commands/, agents/) into install dir |
| `copy_dir` | Copy entire directory |
| `copy_file` | Copy single file to target path |
| `run_command` | Execute shell command |
---
## Codex Integration
The `codex` skill enables Claude Code to delegate code execution to Codex CLI.
### Usage in Workflows
```bash
# Codex is invoked via the skill
codeagent-wrapper - <<'EOF'
implement @src/auth.ts with JWT validation
EOF
```
### Parallel Execution
```bash
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: backend_api
workdir: /project/backend
---CONTENT---
implement REST endpoints for /api/users
---TASK---
id: frontend_ui
workdir: /project/frontend
dependencies: backend_api
---CONTENT---
create React components consuming the API
EOF
```
### Install Codex Wrapper
```bash
# Automatic (via dev module)
python3 install.py --module dev
# Manual
bash install.sh
```
#### Windows
Windows installs place `codeagent-wrapper.exe` in `%USERPROFILE%\bin`.
```powershell
# PowerShell (recommended)
powershell -ExecutionPolicy Bypass -File install.ps1
# Batch (cmd)
install.bat
```
**Add to PATH** (if installer doesn't detect it):
```powershell
# PowerShell - persistent for current user
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
# PowerShell - current session only
$Env:PATH = "$HOME\bin;$Env:PATH"
```
```batch
REM cmd.exe - persistent for current user
setx PATH "%USERPROFILE%\bin;%PATH%"
```
---
## Workflow Selection Guide ## Workflow Selection Guide
| Scenario | Recommended | | Scenario | Recommended Workflow |
|----------|-------------| |----------|---------------------|
| Feature development (default) | `/do` | | New feature with tests | `/dev` |
| Bug investigation + fix | `/omo` | | Quick bug fix | `/debug` or `/code` |
| Large enterprise project | `/bmad-pilot` | | Large multi-sprint feature | `/bmad-pilot` |
| Quick prototype | `/requirements-pilot` | | Prototype or POC | `/requirements-pilot` |
| Simple task | `/code`, `/debug` | | Code review | `/review` |
| Performance issue | `/optimize` |
## Core Architecture ---
| Role | Agent | Responsibility |
|------|-------|----------------|
| **Orchestrator** | Claude Code | Planning, context gathering, verification |
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini/OpenCode) |
## Backend CLI Requirements
| Backend | Required Features |
|---------|-------------------|
| Codex | `codex e`, `--json`, `-C`, `resume` |
| Claude | `--output-format stream-json`, `-r` |
| Gemini | `-o stream-json`, `-y`, `-r` |
## Directory Structure After Installation
```
~/.claude/
├── bin/codeagent-wrapper
├── CLAUDE.md
├── commands/
├── agents/
├── skills/
└── config.json
```
## Documentation
- [codeagent-wrapper](codeagent-wrapper/README.md)
- [Plugin System](PLUGIN_README.md)
## Troubleshooting ## Troubleshooting
@@ -111,41 +294,49 @@ Edit `config.json` to enable/disable modules:
**Codex wrapper not found:** **Codex wrapper not found:**
```bash ```bash
# Select: codeagent-wrapper # Check PATH
npx github:cexll/myclaude echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
# Reinstall
bash install.sh
```
**Permission denied:**
```bash
python3 install.py --install-dir ~/.claude --force
``` ```
**Module not loading:** **Module not loading:**
```bash ```bash
# Check installation status
cat ~/.claude/installed_modules.json cat ~/.claude/installed_modules.json
npx github:cexll/myclaude --force
# Reinstall specific module
python3 install.py --module dev --force
``` ```
**Backend CLI errors:** ---
```bash
which codex && codex --version
which claude && claude --version
which gemini && gemini --version
```
## FAQ ## Documentation
| Issue | Solution | ### Core Guides
|-------|----------| - **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
| "Unknown event format" | Logging display issue, can be ignored | - **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation
| Gemini can't read .gitignore files | Remove from .gitignore or use different backend |
| Codex permission denied | Set `approval_policy = "never"` in ~/.codex/config.yaml |
See [GitHub Issues](https://github.com/cexll/myclaude/issues) for more. ### Additional Resources
- **[Installation Log](install.log)** - Installation history and troubleshooting
---
## License ## License
AGPL-3.0 - see [LICENSE](LICENSE) AGPL-3.0 License - see [LICENSE](LICENSE)
### Commercial Licensing
For commercial use without AGPL obligations, contact: evanxian9@gmail.com
## Support ## Support
- [GitHub Issues](https://github.com/cexll/myclaude/issues) - **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
- **Documentation**: [docs/](docs/)
---
**Claude Code + Codex = Better Development** - Orchestration meets execution.

View File

@@ -2,116 +2,63 @@
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code) [![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-6.x-green)](https://github.com/cexll/myclaude) [![Version](https://img.shields.io/badge/Version-5.2-green)](https://github.com/cexll/myclaude)
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini/OpenCode) > AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
## 快速开始 ## 核心概念:多后端架构
```bash 本系统采用**双智能体架构**与可插拔 AI 后端:
npx github:cexll/myclaude
```
## 模块概览
| 模块 | 描述 | 文档 |
|------|------|------|
| [do](skills/do/README.md) | **推荐** - 7 阶段功能开发 + codeagent 编排 | `/do` 命令 |
| [omo](skills/omo/README.md) | 多智能体编排 + 智能路由 | `/omo` 命令 |
| [bmad](agents/bmad/README.md) | BMAD 敏捷工作流 + 6 个专业智能体 | `/bmad-pilot` 命令 |
| [requirements](agents/requirements/README.md) | 轻量级需求到代码流水线 | `/requirements-pilot` 命令 |
| [essentials](agents/development-essentials/README.md) | 核心开发命令和工具 | `/code`, `/debug` 等 |
| [sparv](skills/sparv/README.md) | SPARV 工作流 (Specify→Plan→Act→Review→Vault) | `/sparv` 命令 |
| course | 课程开发(组合 dev + product-requirements + test-cases | 组合模块 |
## 核心架构
| 角色 | 智能体 | 职责 | | 角色 | 智能体 | 职责 |
|------|-------|------| |------|-------|------|
| **编排者** | Claude Code | 规划、上下文收集、验证 | | **编排者** | Claude Code | 规划、上下文收集、验证、用户交互 |
| **执行者** | codeagent-wrapper | 代码编辑、测试执行Codex/Claude/Gemini/OpenCode 后端)| | **执行者** | codeagent-wrapper | 代码编辑、测试执行Codex/Claude/Gemini 后端)|
## 工作流详解 **为什么分离?**
- Claude Code 擅长理解上下文和编排复杂工作流
- 专业后端Codex 擅长代码、Claude 擅长推理、Gemini 擅长原型)专注执行
- 通过 `--backend codex|claude|gemini` 匹配模型与任务
### do 工作流(推荐 ## 快速开始windows上请在Powershell中执行
7 阶段功能开发,通过 codeagent-wrapper 编排多个智能体。**大多数功能开发任务的首选工作流。**
```bash ```bash
/do "添加用户登录功能" git clone https://github.com/cexll/myclaude.git
cd myclaude
python3 install.py --install-dir ~/.claude
``` ```
**7 阶段:** ## 工作流概览
| 阶段 | 名称 | 目标 |
|------|------|------|
| 1 | Discovery | 理解需求 |
| 2 | Exploration | 映射代码库模式 |
| 3 | Clarification | 解决歧义(**强制**|
| 4 | Architecture | 设计实现方案 |
| 5 | Implementation | 构建功能(**需审批**|
| 6 | Review | 捕获缺陷 |
| 7 | Summary | 记录结果 |
**智能体:** ### 1. Dev 工作流(推荐)
- `code-explorer` - 代码追踪、架构映射
- `code-architect` - 设计方案、文件规划 **大多数开发任务的首选工作流。**
- `code-reviewer` - 代码审查、简化建议
- `develop` - 实现代码、运行测试 ```bash
/dev "实现 JWT 用户认证"
```
**6 步流程:**
1. **需求澄清** - 交互式问答明确范围
2. **Codex 深度分析** - 代码库探索和架构决策
3. **开发计划生成** - 结构化任务分解和测试要求
4. **并行执行** - Codex 并发执行任务
5. **覆盖率验证** - 强制 ≥90% 测试覆盖率
6. **完成总结** - 文件变更和覆盖率报告
**核心特性:**
- Claude Code 编排Codex 执行所有代码变更
- 自动任务并行化提升速度
- 强制 90% 测试覆盖率门禁
- 失败自动回滚
**适用场景:** 功能开发、重构、带测试的 bug 修复
--- ---
### OmO 多智能体编排器 ### 2. BMAD 敏捷工作流
基于风险信号智能路由任务到专业智能体。 **包含 6 个专业智能体的完整企业敏捷方法论。**
```bash
/omo "分析并修复这个认证 bug"
```
**智能体层级:**
| 智能体 | 角色 | 后端 |
|-------|------|------|
| `oracle` | 技术顾问 | Claude |
| `librarian` | 外部研究 | Claude |
| `explore` | 代码库搜索 | OpenCode |
| `develop` | 代码实现 | Codex |
| `frontend-ui-ux-engineer` | UI/UX 专家 | Gemini |
| `document-writer` | 文档撰写 | Gemini |
**常用配方:**
- 解释代码:`explore`
- 位置已知的小修复:直接 `develop`
- Bug 修复(位置未知):`explore → develop`
- 跨模块重构:`explore → oracle → develop`
---
### SPARV 工作流
极简 5 阶段工作流Specify → Plan → Act → Review → Vault。
```bash
/sparv "实现订单导出功能"
```
**核心规则:**
- **10 分规格门**:得分 0-10必须 >=9 才能进入 Plan
- **2 动作保存**:每 2 次工具调用写入 journal.md
- **3 失败协议**:连续 3 次失败后停止并上报
- **EHRB**:高风险操作需明确确认
**评分维度(各 0-2 分):**
1. Value - 为什么做,可验证的收益
2. Scope - MVP + 不在范围内的内容
3. Acceptance - 可测试的验收标准
4. Boundaries - 错误/性能/兼容/安全边界
5. Risk - EHRB/依赖/未知 + 处理方式
---
### BMAD 敏捷工作流
完整企业敏捷方法论 + 6 个专业智能体。
```bash ```bash
/bmad-pilot "构建电商结账系统" /bmad-pilot "构建电商结账系统"
@@ -122,36 +69,43 @@ npx github:cexll/myclaude
|-------|------| |-------|------|
| Product Owner | 需求与用户故事 | | Product Owner | 需求与用户故事 |
| Architect | 系统设计与技术决策 | | Architect | 系统设计与技术决策 |
| Scrum Master | Sprint 规划与任务分解 | | Tech Lead | Sprint 规划与任务分解 |
| Developer | 实现 | | Developer | 实现 |
| Code Reviewer | 质量保证 | | Code Reviewer | 质量保证 |
| QA Engineer | 测试与验证 | | QA Engineer | 测试与验证 |
**审批门** **流程**
- PRD 完成后90+ 分)需用户审批 ```
- 架构完成后90+ 分)需用户审批 需求 → 架构 → Sprint计划 → 开发 → 审查 → QA
↓ ↓ ↓ ↓ ↓ ↓
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
```
**适用场景:** 大型功能、团队协作、企业项目
--- ---
### 需求驱动工作流 ### 3. 需求驱动工作流
轻量级需求到代码流水线。 **轻量级需求到代码流水线。**
```bash ```bash
/requirements-pilot "实现 API 限流" /requirements-pilot "实现 API 限流"
``` ```
**100 分质量评分** **流程**
- 功能清晰度30 分 1. 带质量评分的需求生成
- 技术具体性25 分 2. 实现规划
- 实现完整性25 分 3. 代码生成
- 业务上下文20 分 4. 审查和测试
**适用场景:** 快速原型、明确定义的功能
--- ---
### 开发基础命令 ### 4. 开发基础命令
日常编码任务的直接命令。 **日常编码任务的直接命令。**
| 命令 | 用途 | | 命令 | 用途 |
|------|------| |------|------|
@@ -163,94 +117,204 @@ npx github:cexll/myclaude
| `/refactor` | 代码重构 | | `/refactor` | 代码重构 |
| `/docs` | 编写文档 | | `/docs` | 编写文档 |
**适用场景:** 快速任务,无需工作流开销
--- ---
## 安装 ## 安装
### 模块化安装(推荐)
```bash ```bash
# 交互式安装器(推荐 # 安装所有启用的模块默认dev + essentials
npx github:cexll/myclaude python3 install.py --install-dir ~/.claude
# 列出可安装项module:* / skill:* / codeagent-wrapper # 安装特定模块
npx github:cexll/myclaude --list python3 install.py --module dev
# 检测已安装 modules 并从 GitHub 更新 # 列出可用模块
npx github:cexll/myclaude --update python3 install.py --list-modules
# 指定安装目录 / 强制覆盖 # 强制覆盖现有文件
npx github:cexll/myclaude --install-dir ~/.claude --force python3 install.py --force
``` ```
`--update` 会在目标安装目录(默认 `~/.claude`,优先读取 `installed_modules.json`)检测已安装 modules并从 GitHub 拉取最新发布版本覆盖更新。 ### 可用模块
### 模块配置 | 模块 | 默认 | 描述 |
|------|------|------|
| `dev` | ✓ 启用 | Dev 工作流 + Codex 集成 |
| `essentials` | ✓ 启用 | 核心开发命令 |
| `bmad` | 禁用 | 完整 BMAD 敏捷工作流 |
| `requirements` | 禁用 | 需求驱动工作流 |
编辑 `config.json` 启用/禁用模块: ### 安装内容
```
~/.claude/
├── CLAUDE.md # 核心指令和角色定义
├── commands/ # 斜杠命令 (/dev, /code 等)
├── agents/ # 智能体定义
├── skills/
│ └── codex/
│ └── SKILL.md # Codex 集成技能
└── installed_modules.json # 安装状态
```
### 配置
编辑 `config.json` 自定义:
```json ```json
{ {
"version": "1.0",
"install_dir": "~/.claude",
"modules": { "modules": {
"bmad": { "enabled": false }, "dev": {
"requirements": { "enabled": false }, "enabled": true,
"essentials": { "enabled": false }, "operations": [
"omo": { "enabled": false }, {"type": "merge_dir", "source": "dev-workflow"},
"sparv": { "enabled": false }, {"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
"do": { "enabled": true }, {"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
"course": { "enabled": false } {"type": "run_command", "command": "bash install.sh"}
]
}
} }
} }
``` ```
**操作类型:**
| 类型 | 描述 |
|------|------|
| `merge_dir` | 合并子目录 (commands/, agents/) 到安装目录 |
| `copy_dir` | 复制整个目录 |
| `copy_file` | 复制单个文件到目标路径 |
| `run_command` | 执行 shell 命令 |
---
## Codex 集成
`codex` 技能使 Claude Code 能够将代码执行委托给 Codex CLI。
### 工作流中的使用
```bash
# 通过技能调用 Codex
codeagent-wrapper - <<'EOF'
在 @src/auth.ts 中实现 JWT 验证
EOF
```
### 并行执行
```bash
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: backend_api
workdir: /project/backend
---CONTENT---
实现 /api/users 的 REST 端点
---TASK---
id: frontend_ui
workdir: /project/frontend
dependencies: backend_api
---CONTENT---
创建消费 API 的 React 组件
EOF
```
### 安装 Codex Wrapper
```bash
# 自动(通过 dev 模块)
python3 install.py --module dev
# 手动
bash install.sh
```
#### Windows 系统
Windows 系统会将 `codeagent-wrapper.exe` 安装到 `%USERPROFILE%\bin`
```powershell
# PowerShell推荐
powershell -ExecutionPolicy Bypass -File install.ps1
# 批处理cmd
install.bat
```
**添加到 PATH**(如果安装程序未自动检测):
```powershell
# PowerShell - 永久添加(当前用户)
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
# PowerShell - 仅当前会话
$Env:PATH = "$HOME\bin;$Env:PATH"
```
```batch
REM cmd.exe - 永久添加(当前用户)
setx PATH "%USERPROFILE%\bin;%PATH%"
```
---
## 工作流选择指南 ## 工作流选择指南
| 场景 | 推荐 | | 场景 | 推荐工作流 |
|------|------|
| 功能开发(默认) | `/do` |
| Bug 调查 + 修复 | `/omo` |
| 大型企业项目 | `/bmad-pilot` |
| 快速原型 | `/requirements-pilot` |
| 简单任务 | `/code`, `/debug` |
## 后端 CLI 要求
| 后端 | 必需功能 |
|------|----------| |------|----------|
| Codex | `codex e`, `--json`, `-C`, `resume` | | 带测试的新功能 | `/dev` |
| Claude | `--output-format stream-json`, `-r` | | 快速 bug 修复 | `/debug``/code` |
| Gemini | `-o stream-json`, `-y`, `-r` | | 大型多 Sprint 功能 | `/bmad-pilot` |
| 原型或 POC | `/requirements-pilot` |
| 代码审查 | `/review` |
| 性能问题 | `/optimize` |
---
## 故障排查 ## 故障排查
### 常见问题
**Codex wrapper 未找到:** **Codex wrapper 未找到:**
```bash ```bash
# 选择codeagent-wrapper # 检查 PATH
npx github:cexll/myclaude echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
# 重新安装
bash install.sh
```
**权限被拒绝:**
```bash
python3 install.py --install-dir ~/.claude --force
``` ```
**模块未加载:** **模块未加载:**
```bash ```bash
# 检查安装状态
cat ~/.claude/installed_modules.json cat ~/.claude/installed_modules.json
npx github:cexll/myclaude --force
# 重新安装特定模块
python3 install.py --module dev --force
``` ```
## FAQ ---
| 问题 | 解决方案 |
|------|----------|
| "Unknown event format" | 日志显示问题,可忽略 |
| Gemini 无法读取 .gitignore 文件 | 从 .gitignore 移除或使用其他后端 |
| Codex 权限拒绝 | 在 ~/.codex/config.yaml 设置 `approval_policy = "never"` |
更多问题请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues)。
## 许可证 ## 许可证
AGPL-3.0 - 查看 [LICENSE](LICENSE) AGPL-3.0 License - 查看 [LICENSE](LICENSE)
### 商业授权
如需商业授权(无需遵守 AGPL 义务请联系evanxian9@gmail.com
## 支持 ## 支持
- [GitHub Issues](https://github.com/cexll/myclaude/issues) - **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
- **文档**: [docs/](docs/)
---
**Claude Code + Codex = 更好的开发** - 编排遇见执行。

View File

@@ -1,9 +0,0 @@
{
"name": "bmad",
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
"version": "5.6.1",
"author": {
"name": "cexll",
"email": "cexll@cexll.com"
}
}

View File

@@ -1,109 +0,0 @@
# bmad - BMAD Agile Workflow
Full enterprise agile methodology with 6 specialized agents, UltraThink analysis, and repository-aware development.
## Installation
```bash
python install.py --module bmad
```
## Usage
```bash
/bmad-pilot <PROJECT_DESCRIPTION> [OPTIONS]
```
### Options
| Option | Description |
|--------|-------------|
| `--skip-tests` | Skip QA testing phase |
| `--direct-dev` | Skip SM planning, go directly to development |
| `--skip-scan` | Skip initial repository scanning |
## Workflow Phases
| Phase | Agent | Deliverable | Description |
|-------|-------|-------------|-------------|
| 0 | Orchestrator | `00-repo-scan.md` | Repository scanning with UltraThink analysis |
| 1 | Product Owner (PO) | `01-product-requirements.md` | PRD with 90+ quality score required |
| 2 | Architect | `02-system-architecture.md` | Technical design with 90+ score required |
| 3 | Scrum Master (SM) | `03-sprint-plan.md` | Sprint backlog with stories and estimates |
| 4 | Developer | Implementation code | Multi-sprint implementation |
| 4.5 | Reviewer | `04-dev-reviewed.md` | Code review (Pass/Pass with Risk/Fail) |
| 5 | QA Engineer | Test suite | Comprehensive testing and validation |
## Agents
| Agent | Role |
|-------|------|
| `bmad-orchestrator` | Repository scanning, workflow coordination |
| `bmad-po` | Requirements gathering, PRD creation |
| `bmad-architect` | System design, technology decisions |
| `bmad-sm` | Sprint planning, task breakdown |
| `bmad-dev` | Code implementation |
| `bmad-review` | Code review, quality assessment |
| `bmad-qa` | Testing, validation |
## Approval Gates
Two mandatory stop points require explicit user approval:
1. **After PRD** (Phase 1 → 2): User must approve requirements before architecture
2. **After Architecture** (Phase 2 → 3): User must approve design before implementation
## Output Structure
```
.claude/specs/{feature_name}/
├── 00-repo-scan.md
├── 01-product-requirements.md
├── 02-system-architecture.md
├── 03-sprint-plan.md
└── 04-dev-reviewed.md
```
## UltraThink Methodology
Applied throughout the workflow for deep analysis:
1. **Hypothesis Generation** - Form hypotheses about the problem
2. **Evidence Collection** - Gather evidence from codebase
3. **Pattern Recognition** - Identify recurring patterns
4. **Synthesis** - Create comprehensive understanding
5. **Validation** - Cross-check findings
## Interactive Confirmation Flow
PO and Architect phases use iterative refinement:
1. Agent produces initial draft + quality score
2. Orchestrator presents to user with clarification questions
3. User provides responses
4. Agent refines until quality >= 90
5. User confirms to save deliverable
## When to Use
- Large multi-sprint features
- Enterprise projects requiring documentation
- Team coordination scenarios
- Projects needing formal approval gates
## Directory Structure
```
agents/bmad/
├── README.md
├── commands/
│ └── bmad-pilot.md
└── agents/
├── bmad-orchestrator.md
├── bmad-po.md
├── bmad-architect.md
├── bmad-sm.md
├── bmad-dev.md
├── bmad-review.md
└── bmad-qa.md
```

View File

@@ -1,9 +0,0 @@
{
"name": "essentials",
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
"version": "5.6.1",
"author": {
"name": "cexll",
"email": "cexll@cexll.com"
}
}

View File

@@ -1,9 +0,0 @@
{
"name": "requirements",
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
"version": "5.6.1",
"author": {
"name": "cexll",
"email": "cexll@cexll.com"
}
}

View File

@@ -1,90 +0,0 @@
# requirements - Requirements-Driven Workflow
Lightweight requirements-to-code pipeline with interactive quality gates.
## Installation
```bash
python install.py --module requirements
```
## Usage
```bash
/requirements-pilot <FEATURE_DESCRIPTION> [OPTIONS]
```
### Options
| Option | Description |
|--------|-------------|
| `--skip-tests` | Skip testing phase entirely |
| `--skip-scan` | Skip initial repository scanning |
## Workflow Phases
| Phase | Description | Output |
|-------|-------------|--------|
| 0 | Repository scanning | `00-repository-context.md` |
| 1 | Requirements confirmation | `requirements-confirm.md` (90+ score required) |
| 2 | Implementation | Code + `requirements-spec.md` |
## Quality Scoring (100-point system)
| Category | Points | Focus |
|----------|--------|-------|
| Functional Clarity | 30 | Input/output specs, success criteria |
| Technical Specificity | 25 | Integration points, constraints |
| Implementation Completeness | 25 | Edge cases, error handling |
| Business Context | 20 | User value, priority |
## Sub-Agents
| Agent | Role |
|-------|------|
| `requirements-generate` | Create technical specifications |
| `requirements-code` | Implement functionality |
| `requirements-review` | Code quality evaluation |
| `requirements-testing` | Test case creation |
## Approval Gate
One mandatory stop point after Phase 1:
- Requirements must achieve 90+ quality score
- User must explicitly approve before implementation begins
## Testing Decision
After code review passes (≥90%):
- `--skip-tests`: Complete without testing
- No option: Interactive prompt with smart recommendations based on task complexity
## Output Structure
```
.claude/specs/{feature_name}/
├── 00-repository-context.md
├── requirements-confirm.md
└── requirements-spec.md
```
## When to Use
- Quick prototypes
- Well-defined features
- Smaller scope tasks
- When full BMAD workflow is overkill
## Directory Structure
```
agents/requirements/
├── README.md
├── commands/
│ └── requirements-pilot.md
└── agents/
├── requirements-generate.md
├── requirements-code.md
├── requirements-review.md
└── requirements-testing.md
```

1122
bin/cli.js

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"name": "bmad-agile-workflow",
"source": "./",
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
"version": "1.0.0",
"author": {
"name": "Claude Code Dev Workflows",
"url": "https://github.com/cexll/myclaude"
},
"homepage": "https://github.com/cexll/myclaude",
"repository": "https://github.com/cexll/myclaude",
"license": "MIT",
"keywords": [
"bmad",
"agile",
"scrum",
"product-owner",
"architect",
"developer",
"qa",
"workflow-orchestration"
],
"category": "workflows",
"strict": false,
"commands": [
"./commands/bmad-pilot.md"
],
"agents": [
"./agents/bmad-po.md",
"./agents/bmad-architect.md",
"./agents/bmad-sm.md",
"./agents/bmad-dev.md",
"./agents/bmad-qa.md",
"./agents/bmad-orchestrator.md",
"./agents/bmad-review.md"
]
}

View File

@@ -1,47 +0,0 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.21", "1.22"]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Test
run: make test
- name: Build
run: make build
- name: Verify version
run: ./codeagent-wrapper --version
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Lint
run: make lint

View File

@@ -1,9 +1,6 @@
# Build artifacts # Build artifacts
bin/ codeagent-wrapper
codeagent codeagent-wrapper.exe
codeagent.exe
/codeagent-wrapper
/codeagent-wrapper.exe
*.test *.test
# Coverage reports # Coverage reports
@@ -12,12 +9,3 @@ coverage*.out
cover.out cover.out
cover_*.out cover_*.out
coverage.html coverage.html
# Logs
*.log
# Temp files
*.tmp
*.swp
*~
.DS_Store

View File

@@ -1,37 +0,0 @@
GO ?= go
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -ldflags "-X codeagent-wrapper/internal/app.version=$(VERSION)"
TOOLS_BIN := $(CURDIR)/bin
TOOLCHAIN ?= go1.22.0
GOLANGCI_LINT_VERSION := v1.56.2
STATICCHECK_VERSION := v0.4.7
GOLANGCI_LINT := $(TOOLS_BIN)/golangci-lint
STATICCHECK := $(TOOLS_BIN)/staticcheck
.PHONY: build test lint clean install
build:
$(GO) build $(LDFLAGS) -o codeagent-wrapper ./cmd/codeagent-wrapper
test:
$(GO) test ./...
$(GOLANGCI_LINT):
@mkdir -p $(TOOLS_BIN)
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
$(STATICCHECK):
@mkdir -p $(TOOLS_BIN)
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
lint: $(GOLANGCI_LINT) $(STATICCHECK)
GOTOOLCHAIN=$(TOOLCHAIN) $(GOLANGCI_LINT) run ./...
GOTOOLCHAIN=$(TOOLCHAIN) $(STATICCHECK) ./...
clean:
@python3 -c 'import glob, os; paths=["codeagent","codeagent.exe","codeagent-wrapper","codeagent-wrapper.exe","coverage.out","cover.out","coverage.html"]; paths += glob.glob("coverage*.out") + glob.glob("cover_*.out") + glob.glob("*.test"); [os.remove(p) for p in paths if os.path.exists(p)]'
install:
$(GO) install $(LDFLAGS) ./cmd/codeagent-wrapper

View File

@@ -1,157 +0,0 @@
# codeagent-wrapper
`codeagent-wrapper` 是一个用 Go 编写的“多后端 AI 代码代理”命令行包装器:用统一的 CLI 入口封装不同的 AI 工具后端Codex / Claude / Gemini / Opencode并提供一致的参数、配置与会话恢复体验。
入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)和 `cmd/codeagent-wrapper/main.go`(生成二进制名:`codeagent-wrapper`)。两者行为一致。
## 功能特性
- 多后端支持:`codex` / `claude` / `gemini` / `opencode`
- 统一命令行:`codeagent [flags] <task>` / `codeagent resume <session_id> <task> [workdir]`
- 自动 stdin遇到换行/特殊字符/超长任务自动走 stdin避免 shell quoting 地狱;也可显式使用 `-`
- 配置合并:支持配置文件与 `CODEAGENT_*` 环境变量viper
- Agent 预设:从 `~/.codeagent/models.json` 读取 backend/model/prompt 等预设
- 并行执行:`--parallel` 从 stdin 读取多任务配置,支持依赖拓扑并发执行
- 日志清理:`codeagent cleanup` 清理旧日志(日志写入系统临时目录)
## 安装
要求Go 1.21+。
在仓库根目录执行:
```bash
go install ./cmd/codeagent
go install ./cmd/codeagent-wrapper
```
安装后确认:
```bash
codeagent version
codeagent-wrapper version
```
## 使用示例
最简单用法(默认后端:`codex`
```bash
codeagent "分析 internal/app/cli.go 的入口逻辑,给出改进建议"
```
指定后端:
```bash
codeagent --backend claude "解释 internal/executor/parallel_config.go 的并行配置格式"
```
指定工作目录(第 2 个位置参数):
```bash
codeagent "在当前 repo 下搜索潜在数据竞争" .
```
显式从 stdin 读取 task使用 `-`
```bash
cat task.txt | codeagent -
```
恢复会话:
```bash
codeagent resume <session_id> "继续上次任务"
```
并行模式(从 stdin 读取任务配置;禁止位置参数):
```bash
codeagent --parallel <<'EOF'
---TASK---
id: t1
workdir: .
backend: codex
---CONTENT---
列出本项目的主要模块以及它们的职责。
---TASK---
id: t2
dependencies: t1
backend: claude
---CONTENT---
基于 t1 的结论,提出重构风险点与建议。
EOF
```
## 配置说明
### 配置文件
默认查找路径(当 `--config` 为空时):
- `$HOME/.codeagent/config.(yaml|yml|json|toml|...)`
示例YAML
```yaml
backend: codex
model: gpt-4.1
skip-permissions: false
```
也可以通过 `--config /path/to/config.yaml` 显式指定。
### 环境变量(`CODEAGENT_*`
通过 viper 读取并自动映射 `-``_`,常用项:
- `CODEAGENT_BACKEND``codex|claude|gemini|opencode`
- `CODEAGENT_MODEL`
- `CODEAGENT_AGENT`
- `CODEAGENT_PROMPT_FILE`
- `CODEAGENT_REASONING_EFFORT`
- `CODEAGENT_SKIP_PERMISSIONS`
- `CODEAGENT_FULL_OUTPUT`(并行模式 legacy 输出)
- `CODEAGENT_MAX_PARALLEL_WORKERS`0 表示不限制,上限 100
### Agent 预设(`~/.codeagent/models.json`
可在 `~/.codeagent/models.json` 定义 agent → backend/model/prompt 等映射,用 `--agent <name>` 选择:
```json
{
"default_backend": "opencode",
"default_model": "opencode/grok-code",
"agents": {
"develop": {
"backend": "codex",
"model": "gpt-4.1",
"prompt_file": "~/.codeagent/prompts/develop.md",
"description": "Code development"
}
}
}
```
## 支持的后端
该项目本身不内置模型能力,依赖你本机安装并可在 `PATH` 中找到对应 CLI
- `codex`:执行 `codex e ...`(默认会添加 `--dangerously-bypass-approvals-and-sandbox`;如需关闭请设置 `CODEX_BYPASS_SANDBOX=false`
- `claude`:执行 `claude -p ... --output-format stream-json`(默认会跳过权限提示;如需开启请设置 `CODEAGENT_SKIP_PERMISSIONS=false`
- `gemini`:执行 `gemini ... -o stream-json`(可从 `~/.gemini/.env` 加载环境变量)
- `opencode`:执行 `opencode run --format json`
## 开发
```bash
make build
make test
make lint
make clean
```
## 故障排查
- macOS 下如果看到临时目录相关的 `permission denied`(例如临时可执行文件无法在 `/var/folders/.../T` 执行),可设置一个可执行的临时目录:`CODEAGENT_TMPDIR=$HOME/.codeagent/tmp`
- `claude` 后端的 `base_url/api_key`(来自 `~/.codeagent/models.json`)会注入到子进程环境变量:`ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`。若 `base_url` 指向本地代理(如 `localhost:23001`),请确认代理进程在运行。

View File

@@ -0,0 +1,78 @@
package main
// Backend defines the contract for invoking different AI CLI backends.
// Each backend is responsible for supplying the executable command and
// building the argument list based on the wrapper config.
type Backend interface {
Name() string
BuildArgs(cfg *Config, targetArg string) []string
Command() string
}
type CodexBackend struct{}
func (CodexBackend) Name() string { return "codex" }
func (CodexBackend) Command() string {
return "codex"
}
func (CodexBackend) BuildArgs(cfg *Config, targetArg string) []string {
return buildCodexArgs(cfg, targetArg)
}
type ClaudeBackend struct{}
func (ClaudeBackend) Name() string { return "claude" }
func (ClaudeBackend) Command() string {
return "claude"
}
func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-p", "--dangerously-skip-permissions"}
// Only skip permissions when explicitly requested
// if cfg.SkipPermissions {
// args = append(args, "--dangerously-skip-permissions")
// }
// Prevent infinite recursion: disable all setting sources (user, project, local)
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
args = append(args, "--setting-sources", "")
if cfg.Mode == "resume" {
if cfg.SessionID != "" {
// Claude CLI uses -r <session_id> for resume.
args = append(args, "-r", cfg.SessionID)
}
}
// Note: claude CLI doesn't support -C flag; workdir set via cmd.Dir
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
return args
}
type GeminiBackend struct{}
func (GeminiBackend) Name() string { return "gemini" }
func (GeminiBackend) Command() string {
return "gemini"
}
func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-o", "stream-json", "-y"}
if cfg.Mode == "resume" {
if cfg.SessionID != "" {
args = append(args, "-r", cfg.SessionID)
}
}
// Note: gemini CLI doesn't support -C flag; workdir set via cmd.Dir
args = append(args, "-p", targetArg)
return args
}

View File

@@ -0,0 +1,122 @@
package main
import (
"reflect"
"testing"
)
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
backend := ClaudeBackend{}
t.Run("new mode uses workdir without skip by default", func(t *testing.T) {
cfg := &Config{Mode: "new", WorkDir: "/repo"}
got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("new mode opt-in skip permissions with default workdir", func(t *testing.T) {
cfg := &Config{Mode: "new", SkipPermissions: true}
got := backend.BuildArgs(cfg, "-")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode uses session id and omits workdir", func(t *testing.T) {
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "resume-task")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "follow-up")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("nil config returns nil", func(t *testing.T) {
if backend.BuildArgs(nil, "ignored") != nil {
t.Fatalf("nil config should return nil args")
}
})
}
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "new", WorkDir: "/workspace"}
got := backend.BuildArgs(cfg, "task")
want := []string{"-o", "stream-json", "-y", "-p", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode uses session id", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "resume", SessionID: "sid-999"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "-p", "resume"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "resume"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "-p", "resume"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini nil config returns nil", func(t *testing.T) {
backend := GeminiBackend{}
if backend.BuildArgs(nil, "ignored") != nil {
t.Fatalf("nil config should return nil args")
}
})
t.Run("codex build args passthrough remains intact", func(t *testing.T) {
backend := CodexBackend{}
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
}
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
tests := []struct {
backend Backend
name string
command string
}{
{backend: CodexBackend{}, name: "codex", command: "codex"},
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
}
for _, tt := range tests {
if got := tt.backend.Name(); got != tt.name {
t.Fatalf("Name() = %s, want %s", got, tt.name)
}
if got := tt.backend.Command(); got != tt.command {
t.Fatalf("Command() = %s, want %s", got, tt.command)
}
}
}

View File

@@ -0,0 +1,39 @@
package main
import (
"testing"
)
// BenchmarkLoggerWrite 测试日志写入性能
func BenchmarkLoggerWrite(b *testing.B) {
logger, err := NewLogger()
if err != nil {
b.Fatal(err)
}
defer logger.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Info("benchmark log message")
}
b.StopTimer()
logger.Flush()
}
// BenchmarkLoggerConcurrentWrite 测试并发日志写入性能
func BenchmarkLoggerConcurrentWrite(b *testing.B) {
logger, err := NewLogger()
if err != nil {
b.Fatal(err)
}
defer logger.Close()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
logger.Info("concurrent benchmark log message")
}
})
b.StopTimer()
logger.Flush()
}

View File

@@ -1,7 +0,0 @@
package main
import app "codeagent-wrapper/internal/app"
func main() {
app.Run()
}

View File

@@ -1,4 +1,4 @@
package wrapper package main
import ( import (
"bufio" "bufio"
@@ -11,29 +11,8 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/goccy/go-json"
) )
func stripTimestampPrefix(line string) string {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "{") {
var evt struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(line), &evt); err == nil && evt.Message != "" {
return evt.Message
}
}
if !strings.HasPrefix(line, "[") {
return line
}
if idx := strings.Index(line, "] "); idx >= 0 {
return line[idx+2:]
}
return line
}
// TestConcurrentStressLogger 高并发压力测试 // TestConcurrentStressLogger 高并发压力测试
func TestConcurrentStressLogger(t *testing.T) { func TestConcurrentStressLogger(t *testing.T) {
if testing.Short() { if testing.Short() {
@@ -100,8 +79,7 @@ func TestConcurrentStressLogger(t *testing.T) {
// 验证日志格式(纯文本,无前缀) // 验证日志格式(纯文本,无前缀)
formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`) formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
for i, line := range lines[:min(10, len(lines))] { for i, line := range lines[:min(10, len(lines))] {
msg := stripTimestampPrefix(line) if !formatRE.MatchString(line) {
if !formatRE.MatchString(msg) {
t.Errorf("line %d has invalid format: %s", i, line) t.Errorf("line %d has invalid format: %s", i, line)
} }
} }
@@ -313,7 +291,7 @@ func TestLoggerOrderPreservation(t *testing.T) {
sequences := make(map[int][]int) // goroutine ID -> sequence numbers sequences := make(map[int][]int) // goroutine ID -> sequence numbers
for scanner.Scan() { for scanner.Scan() {
line := stripTimestampPrefix(scanner.Text()) line := scanner.Text()
var gid, seq int var gid, seq int
// Parse format: G0-SEQ0001 (without INFO: prefix) // Parse format: G0-SEQ0001 (without INFO: prefix)
_, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq) _, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)

273
codeagent-wrapper/config.go Normal file
View File

@@ -0,0 +1,273 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"strconv"
"strings"
)
// Config holds CLI configuration
type Config struct {
Mode string // "new" or "resume"
Task string
SessionID string
WorkDir string
ExplicitStdin bool
Timeout int
Backend string
SkipPermissions bool
MaxParallelWorkers int
}
// ParallelConfig defines the JSON schema for parallel execution
type ParallelConfig struct {
Tasks []TaskSpec `json:"tasks"`
GlobalBackend string `json:"backend,omitempty"`
}
// TaskSpec describes an individual task entry in the parallel config
type TaskSpec struct {
ID string `json:"id"`
Task string `json:"task"`
WorkDir string `json:"workdir,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SessionID string `json:"session_id,omitempty"`
Backend string `json:"backend,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`
}
// TaskResult captures the execution outcome of a task
type TaskResult struct {
TaskID string `json:"task_id"`
ExitCode int `json:"exit_code"`
Message string `json:"message"`
SessionID string `json:"session_id"`
Error string `json:"error"`
LogPath string `json:"log_path"`
sharedLog bool
}
var backendRegistry = map[string]Backend{
"codex": CodexBackend{},
"claude": ClaudeBackend{},
"gemini": GeminiBackend{},
}
func selectBackend(name string) (Backend, error) {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
key = defaultBackendName
}
if backend, ok := backendRegistry[key]; ok {
return backend, nil
}
return nil, fmt.Errorf("unsupported backend %q", name)
}
func envFlagEnabled(key string) bool {
val, ok := os.LookupEnv(key)
if !ok {
return false
}
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}
func parseBoolFlag(val string, defaultValue bool) bool {
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return defaultValue
}
}
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, fmt.Errorf("parallel config is empty")
}
tasks := strings.Split(string(trimmed), "---TASK---")
var cfg ParallelConfig
seen := make(map[string]struct{})
taskIndex := 0
for _, taskBlock := range tasks {
taskBlock = strings.TrimSpace(taskBlock)
if taskBlock == "" {
continue
}
taskIndex++
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
}
meta := strings.TrimSpace(parts[0])
content := strings.TrimSpace(parts[1])
task := TaskSpec{WorkDir: defaultWorkdir}
for _, line := range strings.Split(meta, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "id":
task.ID = value
case "workdir":
task.WorkDir = value
case "session_id":
task.SessionID = value
task.Mode = "resume"
case "backend":
task.Backend = value
case "dependencies":
for _, dep := range strings.Split(value, ",") {
dep = strings.TrimSpace(dep)
if dep != "" {
task.Dependencies = append(task.Dependencies, dep)
}
}
}
}
if task.Mode == "" {
task.Mode = "new"
}
if task.ID == "" {
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
}
if content == "" {
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
}
if _, exists := seen[task.ID]; exists {
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
}
task.Task = content
cfg.Tasks = append(cfg.Tasks, task)
seen[task.ID] = struct{}{}
}
if len(cfg.Tasks) == 0 {
return nil, fmt.Errorf("no tasks found")
}
return &cfg, nil
}
func parseArgs() (*Config, error) {
args := os.Args[1:]
if len(args) == 0 {
return nil, fmt.Errorf("task required")
}
backendName := defaultBackendName
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
filtered := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--backend":
if i+1 >= len(args) {
return nil, fmt.Errorf("--backend flag requires a value")
}
backendName = args[i+1]
i++
continue
case strings.HasPrefix(arg, "--backend="):
value := strings.TrimPrefix(arg, "--backend=")
if value == "" {
return nil, fmt.Errorf("--backend flag requires a value")
}
backendName = value
continue
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
skipPermissions = true
continue
case strings.HasPrefix(arg, "--skip-permissions="):
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
continue
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
continue
}
filtered = append(filtered, arg)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("task required")
}
args = filtered
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, SkipPermissions: skipPermissions}
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
if args[0] == "resume" {
if len(args) < 3 {
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
}
cfg.Mode = "resume"
cfg.SessionID = args[1]
cfg.Task = args[2]
cfg.ExplicitStdin = (args[2] == "-")
if len(args) > 3 {
cfg.WorkDir = args[3]
}
} else {
cfg.Mode = "new"
cfg.Task = args[0]
cfg.ExplicitStdin = (args[0] == "-")
if len(args) > 1 {
cfg.WorkDir = args[1]
}
}
return cfg, nil
}
const maxParallelWorkersLimit = 100
func resolveMaxParallelWorkers() int {
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
return 0
}
if value > maxParallelWorkersLimit {
logWarn(fmt.Sprintf("CODEAGENT_MAX_PARALLEL_WORKERS=%d exceeds limit, capping at %d", value, maxParallelWorkersLimit))
return maxParallelWorkersLimit
}
return value
}

View File

@@ -0,0 +1,982 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// commandRunner abstracts exec.Cmd for testability
type commandRunner interface {
Start() error
Wait() error
StdoutPipe() (io.ReadCloser, error)
StdinPipe() (io.WriteCloser, error)
SetStderr(io.Writer)
SetDir(string)
Process() processHandle
}
// processHandle abstracts os.Process for testability
type processHandle interface {
Pid() int
Kill() error
Signal(os.Signal) error
}
// realCmd implements commandRunner using exec.Cmd
type realCmd struct {
cmd *exec.Cmd
}
func (r *realCmd) Start() error {
if r.cmd == nil {
return errors.New("command is nil")
}
return r.cmd.Start()
}
func (r *realCmd) Wait() error {
if r.cmd == nil {
return errors.New("command is nil")
}
return r.cmd.Wait()
}
func (r *realCmd) StdoutPipe() (io.ReadCloser, error) {
if r.cmd == nil {
return nil, errors.New("command is nil")
}
return r.cmd.StdoutPipe()
}
func (r *realCmd) StdinPipe() (io.WriteCloser, error) {
if r.cmd == nil {
return nil, errors.New("command is nil")
}
return r.cmd.StdinPipe()
}
func (r *realCmd) SetStderr(w io.Writer) {
if r.cmd != nil {
r.cmd.Stderr = w
}
}
func (r *realCmd) SetDir(dir string) {
if r.cmd != nil {
r.cmd.Dir = dir
}
}
func (r *realCmd) Process() processHandle {
if r == nil || r.cmd == nil || r.cmd.Process == nil {
return nil
}
return &realProcess{proc: r.cmd.Process}
}
// realProcess implements processHandle using os.Process
type realProcess struct {
proc *os.Process
}
func (p *realProcess) Pid() int {
if p == nil || p.proc == nil {
return 0
}
return p.proc.Pid
}
func (p *realProcess) Kill() error {
if p == nil || p.proc == nil {
return nil
}
return p.proc.Kill()
}
func (p *realProcess) Signal(sig os.Signal) error {
if p == nil || p.proc == nil {
return nil
}
return p.proc.Signal(sig)
}
// newCommandRunner creates a new commandRunner (test hook injection point)
var newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &realCmd{cmd: commandContext(ctx, name, args...)}
}
type parseResult struct {
message string
threadID string
}
type taskLoggerContextKey struct{}
func withTaskLogger(ctx context.Context, logger *Logger) context.Context {
if ctx == nil || logger == nil {
return ctx
}
return context.WithValue(ctx, taskLoggerContextKey{}, logger)
}
func taskLoggerFromContext(ctx context.Context) *Logger {
if ctx == nil {
return nil
}
logger, _ := ctx.Value(taskLoggerContextKey{}).(*Logger)
return logger
}
type taskLoggerHandle struct {
logger *Logger
path string
shared bool
closeFn func()
}
func newTaskLoggerHandle(taskID string) taskLoggerHandle {
taskLogger, err := NewLoggerWithSuffix(taskID)
if err == nil {
return taskLoggerHandle{
logger: taskLogger,
path: taskLogger.Path(),
closeFn: func() { _ = taskLogger.Close() },
}
}
msg := fmt.Sprintf("Failed to create task logger for %s: %v, using main logger", taskID, err)
mainLogger := activeLogger()
if mainLogger != nil {
logWarn(msg)
return taskLoggerHandle{
logger: mainLogger,
path: mainLogger.Path(),
shared: true,
}
}
fmt.Fprintln(os.Stderr, msg)
return taskLoggerHandle{}
}
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset)
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
if task.WorkDir == "" {
task.WorkDir = defaultWorkdir
}
if task.Mode == "" {
task.Mode = "new"
}
if task.UseStdin || shouldUseStdin(task.Task, false) {
task.UseStdin = true
}
backendName := task.Backend
if backendName == "" {
backendName = defaultBackendName
}
backend, err := selectBackendFn(backendName)
if err != nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: err.Error()}
}
task.Backend = backend.Name()
parentCtx := task.Context
if parentCtx == nil {
parentCtx = context.Background()
}
return runCodexTaskWithContext(parentCtx, task, backend, nil, false, true, timeout)
}
var runCodexTaskFn = defaultRunCodexTaskFn
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
idToTask := make(map[string]TaskSpec, len(tasks))
indegree := make(map[string]int, len(tasks))
adj := make(map[string][]string, len(tasks))
for _, task := range tasks {
idToTask[task.ID] = task
indegree[task.ID] = 0
}
for _, task := range tasks {
for _, dep := range task.Dependencies {
if _, ok := idToTask[dep]; !ok {
return nil, fmt.Errorf("dependency %q not found for task %q", dep, task.ID)
}
indegree[task.ID]++
adj[dep] = append(adj[dep], task.ID)
}
}
queue := make([]string, 0, len(tasks))
for _, task := range tasks {
if indegree[task.ID] == 0 {
queue = append(queue, task.ID)
}
}
layers := make([][]TaskSpec, 0)
processed := 0
for len(queue) > 0 {
current := queue
queue = nil
layer := make([]TaskSpec, len(current))
for i, id := range current {
layer[i] = idToTask[id]
processed++
}
layers = append(layers, layer)
next := make([]string, 0)
for _, id := range current {
for _, neighbor := range adj[id] {
indegree[neighbor]--
if indegree[neighbor] == 0 {
next = append(next, neighbor)
}
}
}
queue = append(queue, next...)
}
if processed != len(tasks) {
cycleIDs := make([]string, 0)
for id, deg := range indegree {
if deg > 0 {
cycleIDs = append(cycleIDs, id)
}
}
sort.Strings(cycleIDs)
return nil, fmt.Errorf("cycle detected involving tasks: %s", strings.Join(cycleIDs, ","))
}
return layers, nil
}
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
maxWorkers := resolveMaxParallelWorkers()
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
}
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
totalTasks := 0
for _, layer := range layers {
totalTasks += len(layer)
}
results := make([]TaskResult, 0, totalTasks)
failed := make(map[string]TaskResult, totalTasks)
resultsCh := make(chan TaskResult, totalTasks)
var startPrintMu sync.Mutex
bannerPrinted := false
printTaskStart := func(taskID, logPath string, shared bool) {
if logPath == "" {
return
}
startPrintMu.Lock()
if !bannerPrinted {
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
bannerPrinted = true
}
label := "Log"
if shared {
label = "Log (shared)"
}
fmt.Fprintf(os.Stderr, "Task %s: %s: %s\n", taskID, label, logPath)
startPrintMu.Unlock()
}
ctx := parentCtx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
workerLimit := maxWorkers
if workerLimit < 0 {
workerLimit = 0
}
var sem chan struct{}
if workerLimit > 0 {
sem = make(chan struct{}, workerLimit)
}
logConcurrencyPlanning(workerLimit, totalTasks)
acquireSlot := func() bool {
if sem == nil {
return true
}
select {
case sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
releaseSlot := func() {
if sem == nil {
return
}
select {
case <-sem:
default:
}
}
var activeWorkers int64
for _, layer := range layers {
var wg sync.WaitGroup
executed := 0
for _, task := range layer {
if skip, reason := shouldSkipTask(task, failed); skip {
res := TaskResult{TaskID: task.ID, ExitCode: 1, Error: reason}
results = append(results, res)
failed[task.ID] = res
continue
}
if ctx.Err() != nil {
res := cancelledTaskResult(task.ID, ctx)
results = append(results, res)
failed[task.ID] = res
continue
}
executed++
wg.Add(1)
go func(ts TaskSpec) {
defer wg.Done()
var taskLogPath string
handle := taskLoggerHandle{}
defer func() {
if r := recover(); r != nil {
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r), LogPath: taskLogPath, sharedLog: handle.shared}
}
}()
if !acquireSlot() {
resultsCh <- cancelledTaskResult(ts.ID, ctx)
return
}
defer releaseSlot()
current := atomic.AddInt64(&activeWorkers, 1)
logConcurrencyState("start", ts.ID, int(current), workerLimit)
defer func() {
after := atomic.AddInt64(&activeWorkers, -1)
logConcurrencyState("done", ts.ID, int(after), workerLimit)
}()
handle = newTaskLoggerHandle(ts.ID)
taskLogPath = handle.path
if handle.closeFn != nil {
defer handle.closeFn()
}
taskCtx := ctx
if handle.logger != nil {
taskCtx = withTaskLogger(ctx, handle.logger)
}
ts.Context = taskCtx
printTaskStart(ts.ID, taskLogPath, handle.shared)
res := runCodexTaskFn(ts, timeout)
if taskLogPath != "" {
if res.LogPath == "" || (handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path()) {
res.LogPath = taskLogPath
}
}
// 只有当最终的 LogPath 确实是共享 logger 的路径时才标记为 shared
if handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path() {
res.sharedLog = true
}
resultsCh <- res
}(task)
}
wg.Wait()
for i := 0; i < executed; i++ {
res := <-resultsCh
results = append(results, res)
if res.ExitCode != 0 || res.Error != "" {
failed[res.TaskID] = res
}
}
}
return results
}
func cancelledTaskResult(taskID string, ctx context.Context) TaskResult {
exitCode := 130
msg := "execution cancelled"
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
exitCode = 124
msg = "execution timeout"
}
return TaskResult{TaskID: taskID, ExitCode: exitCode, Error: msg}
}
func shouldSkipTask(task TaskSpec, failed map[string]TaskResult) (bool, string) {
if len(task.Dependencies) == 0 {
return false, ""
}
var blocked []string
for _, dep := range task.Dependencies {
if _, ok := failed[dep]; ok {
blocked = append(blocked, dep)
}
}
if len(blocked) == 0 {
return false, ""
}
return true, fmt.Sprintf("skipped due to failed dependencies: %s", strings.Join(blocked, ","))
}
func generateFinalOutput(results []TaskResult) string {
var sb strings.Builder
success := 0
failed := 0
for _, res := range results {
if res.ExitCode == 0 && res.Error == "" {
success++
} else {
failed++
}
}
sb.WriteString(fmt.Sprintf("=== Parallel Execution Summary ===\n"))
sb.WriteString(fmt.Sprintf("Total: %d | Success: %d | Failed: %d\n\n", len(results), success, failed))
for _, res := range results {
sb.WriteString(fmt.Sprintf("--- Task: %s ---\n", res.TaskID))
if res.Error != "" {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\nError: %s\n", res.ExitCode, res.Error))
} else if res.ExitCode != 0 {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\n", res.ExitCode))
} else {
sb.WriteString("Status: SUCCESS\n")
}
if res.SessionID != "" {
sb.WriteString(fmt.Sprintf("Session: %s\n", res.SessionID))
}
if res.LogPath != "" {
if res.sharedLog {
sb.WriteString(fmt.Sprintf("Log: %s (shared)\n", res.LogPath))
} else {
sb.WriteString(fmt.Sprintf("Log: %s\n", res.LogPath))
}
}
if res.Message != "" {
sb.WriteString(fmt.Sprintf("\n%s\n", res.Message))
}
sb.WriteString("\n")
}
return sb.String()
}
func buildCodexArgs(cfg *Config, targetArg string) []string {
if cfg.Mode == "resume" {
return []string{
"e",
"--skip-git-repo-check",
"--json",
"resume",
cfg.SessionID,
targetArg,
}
}
return []string{
"e",
"--skip-git-repo-check",
"-C", cfg.WorkDir,
"--json",
targetArg,
}
}
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
return runCodexTaskWithContext(context.Background(), taskSpec, nil, nil, false, silent, timeoutSec)
}
func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) {
res := runCodexTaskWithContext(parentCtx, TaskSpec{Task: taskText, WorkDir: defaultWorkdir, Mode: "new", UseStdin: useStdin}, nil, codexArgs, true, false, timeoutSec)
return res.Message, res.SessionID, res.ExitCode
}
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
if parentCtx == nil {
parentCtx = taskSpec.Context
}
if parentCtx == nil {
parentCtx = context.Background()
}
result := TaskResult{TaskID: taskSpec.ID}
injectedLogger := taskLoggerFromContext(parentCtx)
logger := injectedLogger
cfg := &Config{
Mode: taskSpec.Mode,
Task: taskSpec.Task,
SessionID: taskSpec.SessionID,
WorkDir: taskSpec.WorkDir,
Backend: defaultBackendName,
}
commandName := codexCommand
argsBuilder := buildCodexArgsFn
if backend != nil {
commandName = backend.Command()
argsBuilder = backend.BuildArgs
cfg.Backend = backend.Name()
} else if taskSpec.Backend != "" {
cfg.Backend = taskSpec.Backend
} else if commandName != "" {
cfg.Backend = commandName
}
if cfg.Mode == "" {
cfg.Mode = "new"
}
if cfg.WorkDir == "" {
cfg.WorkDir = defaultWorkdir
}
useStdin := taskSpec.UseStdin
targetArg := taskSpec.Task
if useStdin {
targetArg = "-"
}
var codexArgs []string
if useCustomArgs {
codexArgs = customArgs
} else {
codexArgs = argsBuilder(cfg, targetArg)
}
prefixMsg := func(msg string) string {
if taskSpec.ID == "" {
return msg
}
return fmt.Sprintf("[Task: %s] %s", taskSpec.ID, msg)
}
var logInfoFn func(string)
var logWarnFn func(string)
var logErrorFn func(string)
if silent {
// Silent mode: only persist to file when available; avoid stderr noise.
logInfoFn = func(msg string) {
if logger != nil {
logger.Info(prefixMsg(msg))
}
}
logWarnFn = func(msg string) {
if logger != nil {
logger.Warn(prefixMsg(msg))
}
}
logErrorFn = func(msg string) {
if logger != nil {
logger.Error(prefixMsg(msg))
}
}
} else {
logInfoFn = func(msg string) { logInfo(prefixMsg(msg)) }
logWarnFn = func(msg string) { logWarn(prefixMsg(msg)) }
logErrorFn = func(msg string) { logError(prefixMsg(msg)) }
}
stderrBuf := &tailBuffer{limit: stderrCaptureLimit}
var stdoutLogger *logWriter
var stderrLogger *logWriter
var tempLogger *Logger
if logger == nil && silent && activeLogger() == nil {
if l, err := NewLogger(); err == nil {
setLogger(l)
tempLogger = l
logger = l
}
}
defer func() {
if tempLogger != nil {
_ = closeLogger()
}
}()
defer func() {
if result.LogPath != "" || logger == nil {
return
}
result.LogPath = logger.Path()
}()
if logger == nil {
logger = activeLogger()
}
if logger != nil {
result.LogPath = logger.Path()
}
if !silent {
// Note: Empty prefix ensures backend output is logged as-is without any wrapper format.
// This preserves the original stdout/stderr content from codex/claude/gemini backends.
// Trade-off: Reduces distinguishability between stdout/stderr in logs, but maintains
// output fidelity which is critical for debugging backend-specific issues.
stdoutLogger = newLogWriter("", codexLogLineLimit)
stderrLogger = newLogWriter("", codexLogLineLimit)
}
ctx := parentCtx
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
attachStderr := func(msg string) string {
return fmt.Sprintf("%s; stderr: %s", msg, stderrBuf.String())
}
cmd := newCommandRunner(ctx, commandName, codexArgs...)
// For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir
// Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts
if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" {
cmd.SetDir(cfg.WorkDir)
}
stderrWriters := []io.Writer{stderrBuf}
if stderrLogger != nil {
stderrWriters = append(stderrWriters, stderrLogger)
}
// For gemini backend, filter noisy stderr output
var stderrFilter *filteringWriter
if !silent {
stderrOut := io.Writer(os.Stderr)
if cfg.Backend == "gemini" {
stderrFilter = newFilteringWriter(os.Stderr, geminiNoisePatterns)
stderrOut = stderrFilter
defer stderrFilter.Flush()
}
stderrWriters = append([]io.Writer{stderrOut}, stderrWriters...)
}
if len(stderrWriters) == 1 {
cmd.SetStderr(stderrWriters[0])
} else {
cmd.SetStderr(io.MultiWriter(stderrWriters...))
}
var stdinPipe io.WriteCloser
var err error
if useStdin {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
logErrorFn("Failed to create stdin pipe: " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to create stdin pipe: " + err.Error())
return result
}
}
stdout, err := cmd.StdoutPipe()
if err != nil {
logErrorFn("Failed to create stdout pipe: " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to create stdout pipe: " + err.Error())
return result
}
stdoutReader := io.Reader(stdout)
if stdoutLogger != nil {
stdoutReader = io.TeeReader(stdout, stdoutLogger)
}
// Start parse goroutine BEFORE starting the command to avoid race condition
// where fast-completing commands close stdout before parser starts reading
messageSeen := make(chan struct{}, 1)
parseCh := make(chan parseResult, 1)
go func() {
msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() {
select {
case messageSeen <- struct{}{}:
default:
}
})
parseCh <- parseResult{message: msg, threadID: tid}
}()
logInfoFn(fmt.Sprintf("Starting %s with args: %s %s...", commandName, commandName, strings.Join(codexArgs[:min(5, len(codexArgs))], " ")))
if err := cmd.Start(); err != nil {
if strings.Contains(err.Error(), "executable file not found") {
msg := fmt.Sprintf("%s command not found in PATH", commandName)
logErrorFn(msg)
result.ExitCode = 127
result.Error = attachStderr(msg)
return result
}
logErrorFn("Failed to start " + commandName + ": " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to start " + commandName + ": " + err.Error())
return result
}
logInfoFn(fmt.Sprintf("Starting %s with PID: %d", commandName, cmd.Process().Pid()))
if logger != nil {
logInfoFn(fmt.Sprintf("Log capturing to: %s", logger.Path()))
}
if useStdin && stdinPipe != nil {
logInfoFn(fmt.Sprintf("Writing %d chars to stdin...", len(taskSpec.Task)))
go func(data string) {
defer stdinPipe.Close()
_, _ = io.WriteString(stdinPipe, data)
}(taskSpec.Task)
logInfoFn("Stdin closed")
}
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
var waitErr error
var forceKillTimer *forceKillTimer
var ctxCancelled bool
select {
case waitErr = <-waitCh:
case <-ctx.Done():
ctxCancelled = true
logErrorFn(cancelReason(commandName, ctx))
forceKillTimer = terminateCommandFn(cmd)
waitErr = <-waitCh
}
if forceKillTimer != nil {
forceKillTimer.Stop()
}
var parsed parseResult
if ctxCancelled {
closeWithReason(stdout, stdoutCloseReasonCtx)
parsed = <-parseCh
} else {
drainTimer := time.NewTimer(stdoutDrainTimeout)
defer drainTimer.Stop()
select {
case parsed = <-parseCh:
closeWithReason(stdout, stdoutCloseReasonWait)
case <-messageSeen:
closeWithReason(stdout, stdoutCloseReasonWait)
parsed = <-parseCh
case <-drainTimer.C:
closeWithReason(stdout, stdoutCloseReasonDrain)
parsed = <-parseCh
}
}
if ctxErr := ctx.Err(); ctxErr != nil {
if errors.Is(ctxErr, context.DeadlineExceeded) {
result.ExitCode = 124
result.Error = attachStderr(fmt.Sprintf("%s execution timeout", commandName))
return result
}
result.ExitCode = 130
result.Error = attachStderr("execution cancelled")
return result
}
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
code := exitErr.ExitCode()
logErrorFn(fmt.Sprintf("%s exited with status %d", commandName, code))
result.ExitCode = code
result.Error = attachStderr(fmt.Sprintf("%s exited with status %d", commandName, code))
return result
}
logErrorFn(commandName + " error: " + waitErr.Error())
result.ExitCode = 1
result.Error = attachStderr(commandName + " error: " + waitErr.Error())
return result
}
message := parsed.message
threadID := parsed.threadID
if message == "" {
logErrorFn(fmt.Sprintf("%s completed without agent_message output", commandName))
result.ExitCode = 1
result.Error = attachStderr(fmt.Sprintf("%s completed without agent_message output", commandName))
return result
}
if stdoutLogger != nil {
stdoutLogger.Flush()
}
if stderrLogger != nil {
stderrLogger.Flush()
}
result.ExitCode = 0
result.Message = message
result.SessionID = threadID
if result.LogPath == "" && injectedLogger != nil {
result.LogPath = injectedLogger.Path()
}
return result
}
func forwardSignals(ctx context.Context, cmd commandRunner, logErrorFn func(string)) {
notify := signalNotifyFn
stop := signalStopFn
if notify == nil {
notify = signal.Notify
}
if stop == nil {
stop = signal.Stop
}
sigCh := make(chan os.Signal, 1)
notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
defer stop(sigCh)
select {
case sig := <-sigCh:
logErrorFn(fmt.Sprintf("Received signal: %v", sig))
if proc := cmd.Process(); proc != nil {
_ = proc.Signal(syscall.SIGTERM)
time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
})
}
case <-ctx.Done():
}
}()
}
func cancelReason(commandName string, ctx context.Context) string {
if ctx == nil {
return "Context cancelled"
}
if commandName == "" {
commandName = codexCommand
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Sprintf("%s execution timeout", commandName)
}
return fmt.Sprintf("Execution cancelled, terminating %s process", commandName)
}
type stdoutReasonCloser interface {
CloseWithReason(string) error
}
func closeWithReason(rc io.ReadCloser, reason string) {
if rc == nil {
return
}
if c, ok := rc.(stdoutReasonCloser); ok {
_ = c.CloseWithReason(reason)
return
}
_ = rc.Close()
}
type forceKillTimer struct {
timer *time.Timer
done chan struct{}
stopped atomic.Bool
drained atomic.Bool
}
func (t *forceKillTimer) Stop() {
if t == nil || t.timer == nil {
return
}
if !t.timer.Stop() {
<-t.done
t.drained.Store(true)
}
t.stopped.Store(true)
}
func terminateCommand(cmd commandRunner) *forceKillTimer {
if cmd == nil {
return nil
}
proc := cmd.Process()
if proc == nil {
return nil
}
_ = proc.Signal(syscall.SIGTERM)
done := make(chan struct{}, 1)
timer := time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
close(done)
})
return &forceKillTimer{timer: timer, done: done}
}
func terminateProcess(cmd commandRunner) *time.Timer {
if cmd == nil {
return nil
}
proc := cmd.Process()
if proc == nil {
return nil
}
_ = proc.Signal(syscall.SIGTERM)
return time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
})
}

View File

@@ -1,4 +1,4 @@
package wrapper package main
import ( import (
"bufio" "bufio"
@@ -10,15 +10,12 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"testing" "testing"
"time" "time"
executor "codeagent-wrapper/internal/executor"
) )
var executorTestTaskCounter atomic.Int64 var executorTestTaskCounter atomic.Int64
@@ -34,12 +31,7 @@ type execFakeProcess struct {
mu sync.Mutex mu sync.Mutex
} }
func (p *execFakeProcess) Pid() int { func (p *execFakeProcess) Pid() int { return p.pid }
if runtime.GOOS == "windows" {
return 0
}
return p.pid
}
func (p *execFakeProcess) Kill() error { func (p *execFakeProcess) Kill() error {
p.killed.Add(1) p.killed.Add(1)
return nil return nil
@@ -91,16 +83,13 @@ func (rc *reasonReadCloser) record(reason string) {
type execFakeRunner struct { type execFakeRunner struct {
stdout io.ReadCloser stdout io.ReadCloser
stderr io.ReadCloser process processHandle
process executor.ProcessHandle
stdin io.WriteCloser stdin io.WriteCloser
dir string dir string
env map[string]string
waitErr error waitErr error
waitDelay time.Duration waitDelay time.Duration
startErr error startErr error
stdoutErr error stdoutErr error
stderrErr error
stdinErr error stdinErr error
allowNilProcess bool allowNilProcess bool
started atomic.Bool started atomic.Bool
@@ -128,15 +117,6 @@ func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
} }
return f.stdout, nil return f.stdout, nil
} }
func (f *execFakeRunner) StderrPipe() (io.ReadCloser, error) {
if f.stderrErr != nil {
return nil, f.stderrErr
}
if f.stderr == nil {
f.stderr = io.NopCloser(strings.NewReader(""))
}
return f.stderr, nil
}
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) { func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
if f.stdinErr != nil { if f.stdinErr != nil {
return nil, f.stdinErr return nil, f.stdinErr
@@ -148,18 +128,7 @@ func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
} }
func (f *execFakeRunner) SetStderr(io.Writer) {} func (f *execFakeRunner) SetStderr(io.Writer) {}
func (f *execFakeRunner) SetDir(dir string) { f.dir = dir } func (f *execFakeRunner) SetDir(dir string) { f.dir = dir }
func (f *execFakeRunner) SetEnv(env map[string]string) { func (f *execFakeRunner) Process() processHandle {
if len(env) == 0 {
return
}
if f.env == nil {
f.env = make(map[string]string, len(env))
}
for k, v := range env {
f.env[k] = v
}
}
func (f *execFakeRunner) Process() executor.ProcessHandle {
if f.process != nil { if f.process != nil {
return f.process return f.process
} }
@@ -169,32 +138,175 @@ func (f *execFakeRunner) Process() executor.ProcessHandle {
return &execFakeProcess{pid: 1} return &execFakeProcess{pid: 1}
} }
func TestExecutorRunCodexTaskWithContext(t *testing.T) { func TestExecutorHelperCoverage(t *testing.T) {
defer resetTestHooks() t.Run("realCmdAndProcess", func(t *testing.T) {
rc := &realCmd{}
if err := rc.Start(); err == nil {
t.Fatalf("expected error for nil command")
}
if err := rc.Wait(); err == nil {
t.Fatalf("expected error for nil command")
}
if _, err := rc.StdoutPipe(); err == nil {
t.Fatalf("expected error for nil command")
}
if _, err := rc.StdinPipe(); err == nil {
t.Fatalf("expected error for nil command")
}
rc.SetStderr(io.Discard)
if rc.Process() != nil {
t.Fatalf("expected nil process")
}
rcWithCmd := &realCmd{cmd: &exec.Cmd{}}
rcWithCmd.SetStderr(io.Discard)
rcWithCmd.SetDir("/tmp")
if rcWithCmd.cmd.Dir != "/tmp" {
t.Fatalf("expected SetDir to set cmd.Dir, got %q", rcWithCmd.cmd.Dir)
}
echoCmd := exec.Command("echo", "ok")
rcProc := &realCmd{cmd: echoCmd}
stdoutPipe, err := rcProc.StdoutPipe()
if err != nil {
t.Fatalf("StdoutPipe error: %v", err)
}
stdinPipe, err := rcProc.StdinPipe()
if err != nil {
t.Fatalf("StdinPipe error: %v", err)
}
rcProc.SetStderr(io.Discard)
if err := rcProc.Start(); err != nil {
t.Fatalf("Start failed: %v", err)
}
_, _ = stdinPipe.Write([]byte{})
_ = stdinPipe.Close()
procHandle := rcProc.Process()
if procHandle == nil {
t.Fatalf("expected process handle")
}
_ = procHandle.Signal(syscall.SIGTERM)
_ = procHandle.Kill()
_ = rcProc.Wait()
_, _ = io.ReadAll(stdoutPipe)
t.Run("resumeMissingSessionID", func(t *testing.T) { rp := &realProcess{}
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { if rp.Pid() != 0 {
t.Fatalf("unexpected command execution for invalid resume config") t.Fatalf("nil process should have pid 0")
return nil }
}) if rp.Kill() != nil {
t.Cleanup(func() { executor.SetNewCommandRunner(nil) }) t.Fatalf("nil process Kill should be nil")
}
if rp.Signal(syscall.SIGTERM) != nil {
t.Fatalf("nil process Signal should be nil")
}
rpLive := &realProcess{proc: &os.Process{Pid: 99}}
if rpLive.Pid() != 99 {
t.Fatalf("expected pid 99, got %d", rpLive.Pid())
}
_ = rpLive.Kill()
_ = rpLive.Signal(syscall.SIGTERM)
})
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", Mode: "resume"}, nil, nil, false, false, 1) t.Run("topologicalSortAndSkip", func(t *testing.T) {
if res.ExitCode == 0 || !strings.Contains(res.Error, "session_id") { layers, err := topologicalSort([]TaskSpec{{ID: "root"}, {ID: "child", Dependencies: []string{"root"}}})
t.Fatalf("expected validation error, got %+v", res) if err != nil || len(layers) != 2 {
t.Fatalf("unexpected topological sort result: layers=%d err=%v", len(layers), err)
}
if _, err := topologicalSort([]TaskSpec{{ID: "cycle", Dependencies: []string{"cycle"}}}); err == nil {
t.Fatalf("expected cycle detection error")
}
failed := map[string]TaskResult{"root": {ExitCode: 1}}
if skip, _ := shouldSkipTask(TaskSpec{ID: "child", Dependencies: []string{"root"}}, failed); !skip {
t.Fatalf("should skip when dependency failed")
}
if skip, _ := shouldSkipTask(TaskSpec{ID: "leaf"}, failed); skip {
t.Fatalf("should not skip task without dependencies")
}
if skip, _ := shouldSkipTask(TaskSpec{ID: "child-ok", Dependencies: []string{"root"}}, map[string]TaskResult{}); skip {
t.Fatalf("should not skip when dependencies succeeded")
} }
}) })
t.Run("cancelledTaskResult", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := cancelledTaskResult("t1", ctx)
if res.ExitCode != 130 {
t.Fatalf("expected cancel exit code, got %d", res.ExitCode)
}
timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 0)
defer timeoutCancel()
res = cancelledTaskResult("t2", timeoutCtx)
if res.ExitCode != 124 {
t.Fatalf("expected timeout exit code, got %d", res.ExitCode)
}
})
t.Run("generateFinalOutputAndArgs", func(t *testing.T) {
out := generateFinalOutput([]TaskResult{
{TaskID: "ok", ExitCode: 0},
{TaskID: "fail", ExitCode: 1, Error: "boom"},
})
if !strings.Contains(out, "ok") || !strings.Contains(out, "fail") {
t.Fatalf("unexpected summary output: %s", out)
}
out = generateFinalOutput([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}})
if !strings.Contains(out, "Session: sess") || !strings.Contains(out, "Log: /tmp/log") || !strings.Contains(out, "hello") {
t.Fatalf("rich output missing fields: %s", out)
}
args := buildCodexArgs(&Config{Mode: "new", WorkDir: "/tmp"}, "task")
if len(args) == 0 || args[3] != "/tmp" {
t.Fatalf("unexpected codex args: %+v", args)
}
args = buildCodexArgs(&Config{Mode: "resume", SessionID: "sess"}, "target")
if args[3] != "resume" || args[4] != "sess" {
t.Fatalf("unexpected resume args: %+v", args)
}
})
t.Run("executeConcurrentWrapper", func(t *testing.T) {
orig := runCodexTaskFn
defer func() { runCodexTaskFn = orig }()
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "done"}
}
os.Setenv("CODEAGENT_MAX_PARALLEL_WORKERS", "1")
defer os.Unsetenv("CODEAGENT_MAX_PARALLEL_WORKERS")
results := executeConcurrent([][]TaskSpec{{{ID: "wrap"}}}, 1)
if len(results) != 1 || results[0].TaskID != "wrap" {
t.Fatalf("unexpected wrapper results: %+v", results)
}
unbounded := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: "unbounded"}}}, 1, 0)
if len(unbounded) != 1 || unbounded[0].ExitCode != 0 {
t.Fatalf("unexpected unbounded result: %+v", unbounded)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cancelled := executeConcurrentWithContext(ctx, [][]TaskSpec{{{ID: "cancel"}}}, 1, 1)
if cancelled[0].ExitCode == 0 {
t.Fatalf("expected cancelled result, got %+v", cancelled[0])
}
})
}
func TestExecutorRunCodexTaskWithContext(t *testing.T) {
origRunner := newCommandRunner
defer func() { newCommandRunner = origRunner }()
t.Run("success", func(t *testing.T) { t.Run("success", func(t *testing.T) {
var firstStdout *reasonReadCloser var firstStdout *reasonReadCloser
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`) rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`)
if firstStdout == nil { if firstStdout == nil {
firstStdout = rc firstStdout = rc
} }
return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}} return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}}
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-1", Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-1", Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.Error != "" || res.Message != "hello" || res.ExitCode != 0 { if res.Error != "" || res.Message != "hello" || res.ExitCode != 0 {
@@ -224,18 +336,17 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("startErrors", func(t *testing.T) { t.Run("startErrors", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}} return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode != 127 { if res.ExitCode != 127 {
t.Fatalf("expected missing executable exit code, got %d", res.ExitCode) t.Fatalf("expected missing executable exit code, got %d", res.ExitCode)
} }
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}} return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}}
}) }
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on start failure") t.Fatalf("expected non-zero exit on start failure")
@@ -243,14 +354,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("timeoutAndPipes", func(t *testing.T) { t.Run("timeoutAndPipes", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`),
process: &execFakeProcess{pid: 5}, process: &execFakeProcess{pid: 5},
waitDelay: 20 * time.Millisecond, waitDelay: 20 * time.Millisecond,
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected timeout result, got %+v", res) t.Fatalf("expected timeout result, got %+v", res)
@@ -258,18 +368,17 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("pipeErrors", func(t *testing.T) { t.Run("pipeErrors", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}} return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected failure on stdout pipe error") t.Fatalf("expected failure on stdout pipe error")
} }
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{stdinErr: errors.New("stdin fail"), process: &execFakeProcess{pid: 7}} return &execFakeRunner{stdinErr: errors.New("stdin fail"), process: &execFakeProcess{pid: 7}}
}) }
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 1) res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 1)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected failure on stdin pipe error") t.Fatalf("expected failure on stdin pipe error")
@@ -282,14 +391,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
if exitErr == nil { if exitErr == nil {
t.Fatalf("expected exec.ExitError") t.Fatalf("expected exec.ExitError")
} }
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`),
process: &execFakeProcess{pid: 8}, process: &execFakeProcess{pid: 8},
waitErr: exitErr, waitErr: exitErr,
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on wait error") t.Fatalf("expected non-zero exit on wait error")
@@ -297,14 +405,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("contextCancelled", func(t *testing.T) { t.Run("contextCancelled", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`),
process: &execFakeProcess{pid: 9}, process: &execFakeProcess{pid: 9},
waitDelay: 10 * time.Millisecond, waitDelay: 10 * time.Millisecond,
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
@@ -314,13 +421,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("silentLogger", func(t *testing.T) { t.Run("silentLogger", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`),
process: &execFakeProcess{pid: 10}, process: &execFakeProcess{pid: 10},
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger() _ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath == "" { if res.ExitCode != 0 || res.LogPath == "" {
@@ -330,13 +436,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("injectedLogger", func(t *testing.T) { t.Run("injectedLogger", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"injected"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"injected"}}`),
process: &execFakeProcess{pid: 12}, process: &execFakeProcess{pid: 12},
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger() _ = closeLogger()
injected, err := NewLoggerWithSuffix("executor-injected") injected, err := NewLoggerWithSuffix("executor-injected")
@@ -348,7 +453,7 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
_ = os.Remove(injected.Path()) _ = os.Remove(injected.Path())
}() }()
ctx := executor.WithTaskLogger(context.Background(), injected) ctx := withTaskLogger(context.Background(), injected)
res := runCodexTaskWithContext(ctx, TaskSpec{ID: "task-injected", Task: "payload", WorkDir: "."}, nil, nil, false, true, 1) res := runCodexTaskWithContext(ctx, TaskSpec{ID: "task-injected", Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != injected.Path() { if res.ExitCode != 0 || res.LogPath != injected.Path() {
t.Fatalf("expected injected logger path, got %+v", res) t.Fatalf("expected injected logger path, got %+v", res)
@@ -368,13 +473,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
}) })
t.Run("contextLoggerWithoutParent", func(t *testing.T) { t.Run("contextLoggerWithoutParent", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ctx"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ctx"}}`),
process: &execFakeProcess{pid: 14}, process: &execFakeProcess{pid: 14},
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger() _ = closeLogger()
taskLogger, err := NewLoggerWithSuffix("executor-taskctx") taskLogger, err := NewLoggerWithSuffix("executor-taskctx")
@@ -386,8 +490,8 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
_ = os.Remove(taskLogger.Path()) _ = os.Remove(taskLogger.Path())
}) })
ctx := executor.WithTaskLogger(context.Background(), taskLogger) ctx := withTaskLogger(context.Background(), taskLogger)
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1) res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != taskLogger.Path() { if res.ExitCode != 0 || res.LogPath != taskLogger.Path() {
t.Fatalf("expected task logger to be reused from spec context, got %+v", res) t.Fatalf("expected task logger to be reused from spec context, got %+v", res)
} }
@@ -407,17 +511,16 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
t.Run("backendSetsDirAndNilContext", func(t *testing.T) { t.Run("backendSetsDirAndNilContext", func(t *testing.T) {
var rc *execFakeRunner var rc *execFakeRunner
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
rc = &execFakeRunner{ rc = &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"backend"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"backend"}}`),
process: &execFakeProcess{pid: 13}, process: &execFakeProcess{pid: 13},
} }
return rc return rc
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger() _ = closeLogger()
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1) res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1)
if res.ExitCode != 0 || res.Message != "backend" { if res.ExitCode != 0 || res.Message != "backend" {
t.Fatalf("unexpected result: %+v", res) t.Fatalf("unexpected result: %+v", res)
} }
@@ -426,36 +529,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
} }
}) })
t.Run("claudeSkipPermissionsPropagatesFromTaskSpec", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
var gotArgs []string
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
gotArgs = append([]string(nil), args...)
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`),
process: &execFakeProcess{pid: 15},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-skip", Task: "payload", WorkDir: ".", SkipPermissions: true}, ClaudeBackend{}, nil, false, false, 1)
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected result: %+v", res)
}
if !slices.Contains(gotArgs, "--dangerously-skip-permissions") {
t.Fatalf("expected --dangerously-skip-permissions in args, got %v", gotArgs)
}
})
t.Run("missingMessage", func(t *testing.T) { t.Run("missingMessage", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{ return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`), stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`),
process: &execFakeProcess{pid: 11}, process: &execFakeProcess{pid: 11},
} }
}) }
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1) res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 { if res.ExitCode == 0 {
t.Fatalf("expected failure when no agent_message returned") t.Fatalf("expected failure when no agent_message returned")
@@ -481,7 +561,7 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
origRun := runCodexTaskFn origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context) logger := taskLoggerFromContext(task.Context)
if logger == nil { if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"} return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
} }
@@ -505,7 +585,7 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
os.Stderr = stderrW os.Stderr = stderrW
defer func() { os.Stderr = oldStderr }() defer func() { os.Stderr = oldStderr }()
results := executeConcurrentWithContext(context.TODO(), [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1) results := executeConcurrentWithContext(nil, [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1)
_ = stderrW.Close() _ = stderrW.Close()
os.Stderr = oldStderr os.Stderr = oldStderr
@@ -567,10 +647,11 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
} }
func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) { func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
setTempDirEnv(t, t.TempDir()) tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
oldArgs := os.Args oldArgs := os.Args
os.Args = []string{wrapperName} os.Args = []string{defaultWrapperName}
t.Cleanup(func() { os.Args = oldArgs }) t.Cleanup(func() { os.Args = oldArgs })
mainLogger, err := NewLoggerWithSuffix("concurrent-main") mainLogger, err := NewLoggerWithSuffix("concurrent-main")
@@ -616,7 +697,7 @@ func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
readyCh <- struct{}{} readyCh <- struct{}{}
logger := executor.TaskLoggerFromContext(task.Context) logger := taskLoggerFromContext(task.Context)
loggerCh <- taskLoggerInfo{taskID: task.ID, logger: logger} loggerCh <- taskLoggerInfo{taskID: task.ID, logger: logger}
if logger == nil { if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"} return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
@@ -703,9 +784,15 @@ func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
} }
for taskID, logger := range loggers { for taskID, logger := range loggers {
if !logger.IsClosed() { if !logger.closed.Load() {
t.Fatalf("expected task logger to be closed for %q", taskID) t.Fatalf("expected task logger to be closed for %q", taskID)
} }
if logger.file == nil {
t.Fatalf("expected task logger file to be non-nil for %q", taskID)
}
if _, err := logger.file.Write([]byte("x")); err == nil {
t.Fatalf("expected task logger file to be closed for %q", taskID)
}
} }
mainLogger.Flush() mainLogger.Flush()
@@ -775,10 +862,10 @@ func parseTaskIDFromLogLine(line string) (string, bool) {
} }
func TestExecutorTaskLoggerContext(t *testing.T) { func TestExecutorTaskLoggerContext(t *testing.T) {
if executor.TaskLoggerFromContext(context.TODO()) != nil { if taskLoggerFromContext(nil) != nil {
t.Fatalf("expected nil logger from TODO context") t.Fatalf("expected nil logger from nil context")
} }
if executor.TaskLoggerFromContext(context.Background()) != nil { if taskLoggerFromContext(context.Background()) != nil {
t.Fatalf("expected nil logger when context has no logger") t.Fatalf("expected nil logger when context has no logger")
} }
@@ -791,12 +878,12 @@ func TestExecutorTaskLoggerContext(t *testing.T) {
_ = os.Remove(logger.Path()) _ = os.Remove(logger.Path())
}() }()
ctx := executor.WithTaskLogger(context.Background(), logger) ctx := withTaskLogger(context.Background(), logger)
if got := executor.TaskLoggerFromContext(ctx); got != logger { if got := taskLoggerFromContext(ctx); got != logger {
t.Fatalf("expected logger roundtrip, got %v", got) t.Fatalf("expected logger roundtrip, got %v", got)
} }
if executor.TaskLoggerFromContext(executor.WithTaskLogger(context.Background(), nil)) != nil { if taskLoggerFromContext(withTaskLogger(context.Background(), nil)) != nil {
t.Fatalf("expected nil logger when injected logger is nil") t.Fatalf("expected nil logger when injected logger is nil")
} }
} }
@@ -928,7 +1015,8 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) { t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) {
// Create a writable temp dir for the main logger, then flip TMPDIR to a read-only // Create a writable temp dir for the main logger, then flip TMPDIR to a read-only
// location so task-specific loggers fail to open. // location so task-specific loggers fail to open.
writable := setTempDirEnv(t, t.TempDir()) writable := t.TempDir()
t.Setenv("TMPDIR", writable)
mainLogger, err := NewLoggerWithSuffix("shared-main") mainLogger, err := NewLoggerWithSuffix("shared-main")
if err != nil { if err != nil {
@@ -941,18 +1029,18 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
_ = os.Remove(mainLogger.Path()) _ = os.Remove(mainLogger.Path())
}) })
notDir := filepath.Join(writable, "not-a-dir") noWrite := filepath.Join(writable, "ro")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil { if err := os.Mkdir(noWrite, 0o500); err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create read-only temp dir: %v", err)
} }
setTempDirEnv(t, notDir) t.Setenv("TMPDIR", noWrite)
taskA := nextExecutorTestTaskID("shared-a") taskA := nextExecutorTestTaskID("shared-a")
taskB := nextExecutorTestTaskID("shared-b") taskB := nextExecutorTestTaskID("shared-b")
orig := runCodexTaskFn orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context) logger := taskLoggerFromContext(task.Context)
if logger != mainLogger { if logger != mainLogger {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "unexpected logger"} return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "unexpected logger"}
} }
@@ -986,15 +1074,17 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
if res.LogPath != mainLogger.Path() { if res.LogPath != mainLogger.Path() {
t.Fatalf("shared log path mismatch: got %q want %q", res.LogPath, mainLogger.Path()) t.Fatalf("shared log path mismatch: got %q want %q", res.LogPath, mainLogger.Path())
} }
if !res.sharedLog {
t.Fatalf("expected sharedLog flag for %+v", res)
}
if !strings.Contains(stderrOut, "Log (shared)") { if !strings.Contains(stderrOut, "Log (shared)") {
t.Fatalf("stderr missing shared marker: %s", stderrOut) t.Fatalf("stderr missing shared marker: %s", stderrOut)
} }
} }
// Test full output mode for shared marker (summary mode doesn't show it) summary := generateFinalOutput(results)
summary := generateFinalOutputWithMode(results, false)
if !strings.Contains(summary, "(shared)") { if !strings.Contains(summary, "(shared)") {
t.Fatalf("full output missing shared marker: %s", summary) t.Fatalf("summary missing shared marker: %s", summary)
} }
mainLogger.Flush() mainLogger.Flush()
@@ -1009,11 +1099,12 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
}) })
t.Run("TestSanitizeTaskID", func(t *testing.T) { t.Run("TestSanitizeTaskID", func(t *testing.T) {
setTempDirEnv(t, t.TempDir()) tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
orig := runCodexTaskFn orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context) logger := taskLoggerFromContext(task.Context)
if logger == nil { if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing logger"} return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing logger"}
} }
@@ -1051,14 +1142,7 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read log %q: %v", res.LogPath, err) t.Fatalf("failed to read log %q: %v", res.LogPath, err)
} }
found := false if !strings.Contains(string(data), "TASK="+res.TaskID) {
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(stripTimestampPrefix(line), "TASK="+res.TaskID) {
found = true
break
}
}
if !found {
t.Fatalf("log for %q missing task marker, content: %s", res.TaskID, string(data)) t.Fatalf("log for %q missing task marker, content: %s", res.TaskID, string(data))
} }
_ = os.Remove(res.LogPath) _ = os.Remove(res.LogPath)
@@ -1066,6 +1150,147 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
}) })
} }
func TestExecutorSignalAndTermination(t *testing.T) {
forceKillDelay.Store(0)
defer forceKillDelay.Store(5)
proc := &execFakeProcess{pid: 42}
cmd := &execFakeRunner{process: proc}
origNotify := signalNotifyFn
origStop := signalStopFn
defer func() {
signalNotifyFn = origNotify
signalStopFn = origStop
}()
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {
go func() { c <- syscall.SIGINT }()
}
signalStopFn = func(c chan<- os.Signal) {}
forwardSignals(context.Background(), cmd, func(string) {})
time.Sleep(20 * time.Millisecond)
proc.mu.Lock()
signalled := len(proc.signals)
proc.mu.Unlock()
if signalled == 0 {
t.Fatalf("process did not receive signal")
}
if proc.killed.Load() == 0 {
t.Fatalf("process was not killed after signal")
}
timer := terminateProcess(cmd)
if timer == nil {
t.Fatalf("terminateProcess returned nil timer")
}
timer.Stop()
ft := terminateCommand(cmd)
if ft == nil {
t.Fatalf("terminateCommand returned nil")
}
ft.Stop()
cmdKill := &execFakeRunner{process: &execFakeProcess{pid: 50}}
ftKill := terminateCommand(cmdKill)
time.Sleep(10 * time.Millisecond)
if p, ok := cmdKill.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
t.Fatalf("terminateCommand did not kill process")
}
ftKill.Stop()
cmdKill2 := &execFakeRunner{process: &execFakeProcess{pid: 51}}
timer2 := terminateProcess(cmdKill2)
time.Sleep(10 * time.Millisecond)
if p, ok := cmdKill2.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
t.Fatalf("terminateProcess did not kill process")
}
timer2.Stop()
if terminateCommand(nil) != nil {
t.Fatalf("terminateCommand should return nil for nil cmd")
}
if terminateCommand(&execFakeRunner{allowNilProcess: true}) != nil {
t.Fatalf("terminateCommand should return nil when process is nil")
}
if terminateProcess(nil) != nil {
t.Fatalf("terminateProcess should return nil for nil cmd")
}
if terminateProcess(&execFakeRunner{allowNilProcess: true}) != nil {
t.Fatalf("terminateProcess should return nil when process is nil")
}
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {}
ctxDone, cancelDone := context.WithCancel(context.Background())
cancelDone()
forwardSignals(ctxDone, &execFakeRunner{process: &execFakeProcess{pid: 70}}, func(string) {})
}
func TestExecutorCancelReasonAndCloseWithReason(t *testing.T) {
if reason := cancelReason("", nil); !strings.Contains(reason, "Context") {
t.Fatalf("unexpected cancelReason for nil ctx: %s", reason)
}
ctx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
if !strings.Contains(cancelReason("cmd", ctx), "timeout") {
t.Fatalf("expected timeout reason")
}
cancelCtx, cancelFn := context.WithCancel(context.Background())
cancelFn()
if !strings.Contains(cancelReason("cmd", cancelCtx), "Execution cancelled") {
t.Fatalf("expected cancellation reason")
}
if !strings.Contains(cancelReason("", cancelCtx), "codex") {
t.Fatalf("expected default command name in cancel reason")
}
rc := &reasonReadCloser{r: strings.NewReader("data"), closedC: make(chan struct{}, 1)}
closeWithReason(rc, "why")
select {
case <-rc.closedC:
default:
t.Fatalf("CloseWithReason was not called")
}
plain := io.NopCloser(strings.NewReader("x"))
closeWithReason(plain, "noop")
closeWithReason(nil, "noop")
}
func TestExecutorForceKillTimerStop(t *testing.T) {
done := make(chan struct{}, 1)
ft := &forceKillTimer{timer: time.AfterFunc(50*time.Millisecond, func() { done <- struct{}{} }), done: done}
ft.Stop()
done2 := make(chan struct{}, 1)
ft2 := &forceKillTimer{timer: time.AfterFunc(0, func() { done2 <- struct{}{} }), done: done2}
time.Sleep(10 * time.Millisecond)
ft2.Stop()
var nilTimer *forceKillTimer
nilTimer.Stop()
(&forceKillTimer{}).Stop()
}
func TestExecutorForwardSignalsDefaults(t *testing.T) {
origNotify := signalNotifyFn
origStop := signalStopFn
signalNotifyFn = nil
signalStopFn = nil
defer func() {
signalNotifyFn = origNotify
signalStopFn = origStop
}()
ctx, cancel := context.WithCancel(context.Background())
cancel()
forwardSignals(ctx, &execFakeRunner{process: &execFakeProcess{pid: 80}}, func(string) {})
time.Sleep(10 * time.Millisecond)
}
func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) { func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil { if err != nil {
@@ -1078,7 +1303,8 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
_ = devNull.Close() _ = devNull.Close()
}) })
tempDir := setTempDirEnv(t, t.TempDir()) tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
// Setup: 创建主 logger // Setup: 创建主 logger
mainLogger, err := NewLoggerWithSuffix("shared-main") mainLogger, err := NewLoggerWithSuffix("shared-main")
@@ -1094,11 +1320,11 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
// 模拟场景task logger 创建失败(通过设置只读的 TMPDIR // 模拟场景task logger 创建失败(通过设置只读的 TMPDIR
// 回退到主 loggerhandle.shared=true // 回退到主 loggerhandle.shared=true
// 但 runCodexTaskFn 返回自定义的 LogPath不等于主 logger 的路径) // 但 runCodexTaskFn 返回自定义的 LogPath不等于主 logger 的路径)
notDir := filepath.Join(tempDir, "not-a-dir") roDir := filepath.Join(tempDir, "ro")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil { if err := os.Mkdir(roDir, 0o500); err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create read-only dir: %v", err)
} }
setTempDirEnv(t, notDir) t.Setenv("TMPDIR", roDir)
orig := runCodexTaskFn orig := runCodexTaskFn
customLogPath := "/custom/path/to.log" customLogPath := "/custom/path/to.log"
@@ -1120,9 +1346,10 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
} }
res := results[0] res := results[0]
out := generateFinalOutputWithMode(results, false) // 关键断言:即使 handle.shared=true因为 task logger 创建失败),
if strings.Contains(out, "(shared)") { // 但因为 LogPath 不等于主 logger 的路径sharedLog 应为 false
t.Fatalf("did not expect shared marker when LogPath differs from shared logger, got: %s", out) if res.sharedLog {
t.Fatalf("expected sharedLog=false when LogPath differs from shared logger, got true")
} }
// 验证 LogPath 确实是自定义的 // 验证 LogPath 确实是自定义的

View File

@@ -1,4 +1,4 @@
package executor package main
import ( import (
"bytes" "bytes"
@@ -18,12 +18,6 @@ var geminiNoisePatterns = []string{
"YOLO mode is enabled", "YOLO mode is enabled",
} }
// codexNoisePatterns contains stderr patterns to filter for codex backend
var codexNoisePatterns = []string{
"ERROR codex_core::codex: needs_follow_up:",
"ERROR codex_core::skills::loader:",
}
// filteringWriter wraps an io.Writer and filters out lines matching patterns // filteringWriter wraps an io.Writer and filters out lines matching patterns
type filteringWriter struct { type filteringWriter struct {
w io.Writer w io.Writer
@@ -45,7 +39,7 @@ func (f *filteringWriter) Write(p []byte) (n int, err error) {
break break
} }
if !f.shouldFilter(line) { if !f.shouldFilter(line) {
_, _ = f.w.Write([]byte(line)) f.w.Write([]byte(line))
} }
} }
return len(p), nil return len(p), nil
@@ -65,7 +59,7 @@ func (f *filteringWriter) Flush() {
if f.buf.Len() > 0 { if f.buf.Len() > 0 {
remaining := f.buf.String() remaining := f.buf.String()
if !f.shouldFilter(remaining) { if !f.shouldFilter(remaining) {
_, _ = f.w.Write([]byte(remaining)) f.w.Write([]byte(remaining))
} }
f.buf.Reset() f.buf.Reset()
} }

View File

@@ -1,4 +1,4 @@
package executor package main
import ( import (
"bytes" "bytes"
@@ -48,7 +48,7 @@ func TestFilteringWriter(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
fw := newFilteringWriter(&buf, tt.patterns) fw := newFilteringWriter(&buf, tt.patterns)
_, _ = fw.Write([]byte(tt.input)) fw.Write([]byte(tt.input))
fw.Flush() fw.Flush()
if got := buf.String(); got != tt.want { if got := buf.String(); got != tt.want {
@@ -63,8 +63,8 @@ func TestFilteringWriterPartialLines(t *testing.T) {
fw := newFilteringWriter(&buf, geminiNoisePatterns) fw := newFilteringWriter(&buf, geminiNoisePatterns)
// Write partial line // Write partial line
_, _ = fw.Write([]byte("Hello ")) fw.Write([]byte("Hello "))
_, _ = fw.Write([]byte("World\n")) fw.Write([]byte("World\n"))
fw.Flush() fw.Flush()
if got := buf.String(); got != "Hello World\n" { if got := buf.String(); got != "Hello World\n" {

View File

@@ -1,43 +1,3 @@
module codeagent-wrapper module codeagent-wrapper
go 1.21 go 1.21
require (
github.com/goccy/go-json v0.10.5
github.com/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,117 +0,0 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,150 +0,0 @@
package wrapper
import (
"context"
"os"
"path/filepath"
"testing"
"time"
config "codeagent-wrapper/internal/config"
executor "codeagent-wrapper/internal/executor"
)
func TestValidateAgentName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "simple", input: "develop", wantErr: false},
{name: "upper", input: "ABC", wantErr: false},
{name: "digits", input: "a1", wantErr: false},
{name: "dash underscore", input: "a-b_c", wantErr: false},
{name: "empty", input: "", wantErr: true},
{name: "space", input: "a b", wantErr: true},
{name: "slash", input: "a/b", wantErr: true},
{name: "dotdot", input: "../evil", wantErr: true},
{name: "unicode", input: "中文", wantErr: true},
{name: "symbol", input: "a$b", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := config.ValidateAgentName(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("validateAgentName(%q) err=%v, wantErr=%v", tt.input, err, tt.wantErr)
}
})
}
}
func TestParseArgs_InvalidAgentNameRejected(t *testing.T) {
defer resetTestHooks()
os.Args = []string{"codeagent-wrapper", "--agent", "../evil", "task"}
if _, err := parseArgs(); err == nil {
t.Fatalf("expected parseArgs to reject invalid agent name")
}
}
func TestParseParallelConfig_InvalidAgentNameRejected(t *testing.T) {
input := `---TASK---
id: task-1
agent: ../evil
---CONTENT---
do something`
if _, err := parseParallelConfig([]byte(input)); err == nil {
t.Fatalf("expected parseParallelConfig to reject invalid agent name")
}
}
func TestParseParallelConfig_ResolvesAgentPromptFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(config.ResetModelsConfigCacheForTest)
config.ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"default_backend": "codex",
"default_model": "gpt-test",
"agents": {
"custom-agent": {
"backend": "codex",
"model": "gpt-test",
"prompt_file": "~/.claude/prompt.md"
}
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
input := `---TASK---
id: task-1
agent: custom-agent
---CONTENT---
do something`
cfg, err := parseParallelConfig([]byte(input))
if err != nil {
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
}
if len(cfg.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
}
if got := cfg.Tasks[0].PromptFile; got != "~/.claude/prompt.md" {
t.Fatalf("PromptFile = %q, want %q", got, "~/.claude/prompt.md")
}
}
func TestDefaultRunCodexTaskFn_AppliesAgentPromptFile(t *testing.T) {
defer resetTestHooks()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
claudeDir := filepath.Join(home, ".claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(claudeDir, "prompt.md"), []byte("P\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
fake := newFakeCmd(fakeCmdConfig{
StdoutPlan: []fakeStdoutEvent{
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}` + "\n"},
},
WaitDelay: 2 * time.Millisecond,
})
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
_ = executor.SetSelectBackendFn(func(name string) (Backend, error) {
return testBackend{
name: name,
command: "fake-cmd",
argsFn: func(cfg *Config, targetArg string) []string {
return []string{targetArg}
},
}, nil
})
res := defaultRunCodexTaskFn(TaskSpec{
ID: "t",
Task: "do",
Backend: "codex",
PromptFile: "~/.claude/prompt.md",
}, 5)
if res.ExitCode != 0 {
t.Fatalf("unexpected result: %+v", res)
}
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
if got := fake.StdinContents(); got != want {
t.Fatalf("stdin mismatch:\n got=%q\nwant=%q", got, want)
}
}

View File

@@ -1,279 +0,0 @@
package wrapper
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
var version = "dev"
const (
defaultWorkdir = "."
defaultTimeout = 7200 // seconds (2 hours)
defaultCoverageTarget = 90.0
codexLogLineLimit = 1000
stdinSpecialChars = "\n\\\"'`$"
stderrCaptureLimit = 4 * 1024
defaultBackendName = "codex"
defaultCodexCommand = "codex"
// stdout close reasons
stdoutCloseReasonWait = "wait-done"
stdoutCloseReasonDrain = "drain-timeout"
stdoutCloseReasonCtx = "context-cancel"
stdoutDrainTimeout = 500 * time.Millisecond
)
// Test hooks for dependency injection
var (
stdinReader io.Reader = os.Stdin
isTerminalFn = defaultIsTerminal
codexCommand = defaultCodexCommand
cleanupHook func()
startupCleanupAsync = true
buildCodexArgsFn = buildCodexArgs
selectBackendFn = selectBackend
cleanupLogsFn = cleanupOldLogs
defaultBuildArgsFn = buildCodexArgs
runTaskFn = runCodexTask
exitFn = os.Exit
)
func runStartupCleanup() {
if cleanupLogsFn == nil {
return
}
defer func() {
if r := recover(); r != nil {
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
}
}()
if _, err := cleanupLogsFn(); err != nil {
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
}
}
func scheduleStartupCleanup() {
if !startupCleanupAsync {
runStartupCleanup()
return
}
if cleanupLogsFn == nil {
return
}
fn := cleanupLogsFn
go func() {
defer func() {
if r := recover(); r != nil {
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
}
}()
if _, err := fn(); err != nil {
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
}
}()
}
func runCleanupMode() int {
if cleanupLogsFn == nil {
fmt.Fprintln(os.Stderr, "Cleanup failed: log cleanup function not configured")
return 1
}
stats, err := cleanupLogsFn()
if err != nil {
fmt.Fprintf(os.Stderr, "Cleanup failed: %v\n", err)
return 1
}
fmt.Println("Cleanup completed")
fmt.Printf("Files scanned: %d\n", stats.Scanned)
fmt.Printf("Files deleted: %d\n", stats.Deleted)
if len(stats.DeletedFiles) > 0 {
for _, f := range stats.DeletedFiles {
fmt.Printf(" - %s\n", f)
}
}
fmt.Printf("Files kept: %d\n", stats.Kept)
if len(stats.KeptFiles) > 0 {
for _, f := range stats.KeptFiles {
fmt.Printf(" - %s\n", f)
}
}
if stats.Errors > 0 {
fmt.Printf("Deletion errors: %d\n", stats.Errors)
}
return 0
}
func readAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error) {
raw := strings.TrimSpace(path)
if raw == "" {
return "", nil
}
expanded := raw
if raw == "~" || strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, "~\\") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if raw == "~" {
expanded = home
} else {
expanded = home + raw[1:]
}
}
absPath, err := filepath.Abs(expanded)
if err != nil {
return "", err
}
absPath = filepath.Clean(absPath)
home, err := os.UserHomeDir()
if err != nil {
if !allowOutsideClaudeDir {
return "", err
}
logWarn(fmt.Sprintf("Failed to resolve home directory for prompt file validation: %v; proceeding without restriction", err))
} else {
allowedDirs := []string{
filepath.Clean(filepath.Join(home, ".claude")),
filepath.Clean(filepath.Join(home, ".codeagent", "agents")),
}
for i := range allowedDirs {
allowedAbs, err := filepath.Abs(allowedDirs[i])
if err == nil {
allowedDirs[i] = filepath.Clean(allowedAbs)
}
}
isWithinDir := func(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
if rel == ".." {
return false
}
prefix := ".." + string(os.PathSeparator)
return !strings.HasPrefix(rel, prefix)
}
if !allowOutsideClaudeDir {
withinAllowed := false
for _, dir := range allowedDirs {
if isWithinDir(absPath, dir) {
withinAllowed = true
break
}
}
if !withinAllowed {
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
}
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
if errPath == nil {
resolvedPath = filepath.Clean(resolvedPath)
resolvedAllowed := make([]string, 0, len(allowedDirs))
for _, dir := range allowedDirs {
resolvedBase, errBase := filepath.EvalSymlinks(dir)
if errBase != nil {
continue
}
resolvedAllowed = append(resolvedAllowed, filepath.Clean(resolvedBase))
}
if len(resolvedAllowed) > 0 {
withinResolved := false
for _, dir := range resolvedAllowed {
if isWithinDir(resolvedPath, dir) {
withinResolved = true
break
}
}
if !withinResolved {
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s) (resolved): %s", strings.Join(resolvedAllowed, ", "), resolvedPath))
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
}
}
}
} else {
withinAllowed := false
for _, dir := range allowedDirs {
if isWithinDir(absPath, dir) {
withinAllowed = true
break
}
}
if !withinAllowed {
logWarn(fmt.Sprintf("Reading prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
}
}
}
data, err := os.ReadFile(absPath)
if err != nil {
return "", err
}
return strings.TrimRight(string(data), "\r\n"), nil
}
func wrapTaskWithAgentPrompt(prompt string, task string) string {
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
}
func runCleanupHook() {
if logger := activeLogger(); logger != nil {
logger.Flush()
}
if cleanupHook != nil {
cleanupHook()
}
}
func printHelp() {
name := currentWrapperName()
help := fmt.Sprintf(`%[1]s - Go wrapper for AI CLI backends
Usage:
%[1]s "task" [workdir]
%[1]s --backend claude "task" [workdir]
%[1]s --prompt-file /path/to/prompt.md "task" [workdir]
%[1]s - [workdir] Read task from stdin
%[1]s resume <session_id> "task" [workdir]
%[1]s resume <session_id> - [workdir]
%[1]s --parallel Run tasks in parallel (config from stdin)
%[1]s --parallel --full-output Run tasks in parallel with full output (legacy)
%[1]s --version
%[1]s --help
Parallel mode examples:
%[1]s --parallel < tasks.txt
echo '...' | %[1]s --parallel
%[1]s --parallel --full-output < tasks.txt
%[1]s --parallel <<'EOF'
Environment Variables:
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
CODEAGENT_ASCII_MODE Use ASCII symbols instead of Unicode (PASS/WARN/FAIL)
Exit Codes:
0 Success
1 General error (missing args, no output)
124 Timeout
127 backend command not found
130 Interrupted (Ctrl+C)
* Passthrough from backend process`, name)
fmt.Println(help)
}

View File

@@ -1,9 +0,0 @@
package wrapper
import backend "codeagent-wrapper/internal/backend"
type Backend = backend.Backend
type CodexBackend = backend.CodexBackend
type ClaudeBackend = backend.ClaudeBackend
type GeminiBackend = backend.GeminiBackend
type OpencodeBackend = backend.OpencodeBackend

View File

@@ -1,7 +0,0 @@
package wrapper
import backend "codeagent-wrapper/internal/backend"
func init() {
backend.SetLogFuncs(logWarn, logError)
}

View File

@@ -1,5 +0,0 @@
package wrapper
import backend "codeagent-wrapper/internal/backend"
func selectBackend(name string) (Backend, error) { return backend.Select(name) }

View File

@@ -1,116 +0,0 @@
package wrapper
import (
"bytes"
"os"
"path/filepath"
"testing"
config "codeagent-wrapper/internal/config"
)
var (
benchCmdSink any
benchConfigSink *Config
benchMessageSink string
benchThreadIDSink string
)
// BenchmarkStartup_NewRootCommand measures CLI startup overhead (command+flags construction).
func BenchmarkStartup_NewRootCommand(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
benchCmdSink = newRootCommand()
}
}
// BenchmarkConfigParse_ParseArgs measures config parsing from argv/env (steady-state).
func BenchmarkConfigParse_ParseArgs(b *testing.B) {
home := b.TempDir()
b.Setenv("HOME", home)
b.Setenv("USERPROFILE", home)
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
b.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"agents": {
"develop": { "backend": "codex", "model": "gpt-test" }
}
}`), 0o644); err != nil {
b.Fatal(err)
}
config.ResetModelsConfigCacheForTest()
b.Cleanup(config.ResetModelsConfigCacheForTest)
origArgs := os.Args
os.Args = []string{"codeagent-wrapper", "--agent", "develop", "task"}
b.Cleanup(func() { os.Args = origArgs })
if _, err := parseArgs(); err != nil {
b.Fatalf("warmup parseArgs() error: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cfg, err := parseArgs()
if err != nil {
b.Fatalf("parseArgs() error: %v", err)
}
benchConfigSink = cfg
}
}
// BenchmarkJSONParse_ParseJSONStreamInternal measures line-delimited JSON stream parsing.
func BenchmarkJSONParse_ParseJSONStreamInternal(b *testing.B) {
stream := []byte(
`{"type":"thread.started","thread_id":"t"}` + "\n" +
`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}` + "\n" +
`{"type":"thread.completed","thread_id":"t"}` + "\n",
)
b.SetBytes(int64(len(stream)))
b.ReportAllocs()
for i := 0; i < b.N; i++ {
message, threadID := parseJSONStreamInternal(bytes.NewReader(stream), nil, nil, nil, nil)
benchMessageSink = message
benchThreadIDSink = threadID
}
}
// BenchmarkLoggerWrite 测试日志写入性能
func BenchmarkLoggerWrite(b *testing.B) {
logger, err := NewLogger()
if err != nil {
b.Fatal(err)
}
defer logger.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Info("benchmark log message")
}
b.StopTimer()
logger.Flush()
}
// BenchmarkLoggerConcurrentWrite 测试并发日志写入性能
func BenchmarkLoggerConcurrentWrite(b *testing.B) {
logger, err := NewLogger()
if err != nil {
b.Fatal(err)
}
defer logger.Close()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
logger.Info("concurrent benchmark log message")
}
})
b.StopTimer()
logger.Flush()
}

View File

@@ -1,669 +0,0 @@
package wrapper
import (
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
config "codeagent-wrapper/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type exitError struct {
code int
}
func (e exitError) Error() string {
return fmt.Sprintf("exit %d", e.code)
}
type cliOptions struct {
Backend string
Model string
ReasoningEffort string
Agent string
PromptFile string
SkipPermissions bool
Parallel bool
FullOutput bool
Cleanup bool
Version bool
ConfigFile string
}
func Main() {
Run()
}
// Run is the program entrypoint for cmd/codeagent/main.go.
func Run() {
exitFn(run())
}
func run() int {
cmd := newRootCommand()
cmd.SetArgs(os.Args[1:])
if err := cmd.Execute(); err != nil {
var ee exitError
if errors.As(err, &ee) {
return ee.code
}
return 1
}
return 0
}
func newRootCommand() *cobra.Command {
name := currentWrapperName()
opts := &cliOptions{}
cmd := &cobra.Command{
Use: fmt.Sprintf("%s [flags] <task>|resume <session_id> <task> [workdir]", name),
Short: "Go wrapper for AI CLI backends",
SilenceErrors: true,
SilenceUsage: true,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Version {
fmt.Printf("%s version %s\n", name, version)
return nil
}
if opts.Cleanup {
code := runCleanupMode()
if code == 0 {
return nil
}
return exitError{code: code}
}
exitCode := runWithLoggerAndCleanup(func() int {
v, err := config.NewViper(opts.ConfigFile)
if err != nil {
logError(err.Error())
return 1
}
if opts.Parallel {
return runParallelMode(cmd, args, opts, v, name)
}
logInfo("Script started")
cfg, err := buildSingleConfig(cmd, args, os.Args[1:], opts, v)
if err != nil {
logError(err.Error())
return 1
}
logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d, backend=%s", cfg.Mode, len(cfg.Task), cfg.Backend))
return runSingleMode(cfg, name)
})
if exitCode == 0 {
return nil
}
return exitError{code: exitCode}
},
}
cmd.CompletionOptions.DisableDefaultCmd = true
addRootFlags(cmd.Flags(), opts)
cmd.AddCommand(newVersionCommand(name), newCleanupCommand())
return cmd
}
func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
fs.StringVar(&opts.ConfigFile, "config", "", "Config file path (default: $HOME/.codeagent/config.*)")
fs.BoolVarP(&opts.Version, "version", "v", false, "Print version and exit")
fs.BoolVar(&opts.Cleanup, "cleanup", false, "Clean up old logs and exit")
fs.BoolVar(&opts.Parallel, "parallel", false, "Run tasks in parallel (config from stdin)")
fs.BoolVar(&opts.FullOutput, "full-output", false, "Parallel mode: include full task output (legacy)")
fs.StringVar(&opts.Backend, "backend", defaultBackendName, "Backend to use (codex, claude, gemini, opencode)")
fs.StringVar(&opts.Model, "model", "", "Model override")
fs.StringVar(&opts.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)")
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path")
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
}
func newVersionCommand(name string) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version and exit",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("%s version %s\n", name, version)
return nil
},
}
}
func newCleanupCommand() *cobra.Command {
return &cobra.Command{
Use: "cleanup",
Short: "Clean up old logs and exit",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
code := runCleanupMode()
if code == 0 {
return nil
}
return exitError{code: code}
},
}
}
func runWithLoggerAndCleanup(fn func() int) (exitCode int) {
ensureExecutableTempDir()
logger, err := NewLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
return 1
}
setLogger(logger)
defer func() {
logger := activeLogger()
if logger != nil {
logger.Flush()
}
if err := closeLogger(); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to close logger: %v\n", err)
}
if logger == nil {
return
}
if exitCode != 0 {
if entries := logger.ExtractRecentErrors(10); len(entries) > 0 {
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
for _, entry := range entries {
fmt.Fprintln(os.Stderr, entry)
}
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
}
}
_ = logger.RemoveLogFile()
}()
defer runCleanupHook()
// Clean up stale logs from previous runs.
scheduleStartupCleanup()
return fn()
}
func parseArgs() (*Config, error) {
opts := &cliOptions{}
cmd := &cobra.Command{SilenceErrors: true, SilenceUsage: true, Args: cobra.ArbitraryArgs}
addRootFlags(cmd.Flags(), opts)
rawArgv := os.Args[1:]
if err := cmd.ParseFlags(rawArgv); err != nil {
return nil, err
}
args := cmd.Flags().Args()
v, err := config.NewViper(opts.ConfigFile)
if err != nil {
return nil, err
}
return buildSingleConfig(cmd, args, rawArgv, opts, v)
}
func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts *cliOptions, v *viper.Viper) (*Config, error) {
backendName := defaultBackendName
model := ""
reasoningEffort := ""
agentName := ""
promptFile := ""
promptFileExplicit := false
yolo := false
if cmd.Flags().Changed("agent") {
agentName = strings.TrimSpace(opts.Agent)
if agentName == "" {
return nil, fmt.Errorf("--agent flag requires a value")
}
if err := config.ValidateAgentName(agentName); err != nil {
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
}
} else {
agentName = strings.TrimSpace(v.GetString("agent"))
if agentName != "" {
if err := config.ValidateAgentName(agentName); err != nil {
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
}
}
}
var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string
if agentName != "" {
var resolvedYolo bool
var err error
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, err = config.ResolveAgentConfig(agentName)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err)
}
yolo = resolvedYolo
}
if cmd.Flags().Changed("prompt-file") {
promptFile = strings.TrimSpace(opts.PromptFile)
if promptFile == "" {
return nil, fmt.Errorf("--prompt-file flag requires a value")
}
promptFileExplicit = true
} else if val := strings.TrimSpace(v.GetString("prompt-file")); val != "" {
promptFile = val
promptFileExplicit = true
} else {
promptFile = resolvedPromptFile
}
agentFlagChanged := cmd.Flags().Changed("agent")
backendFlagChanged := cmd.Flags().Changed("backend")
if backendFlagChanged {
backendName = strings.TrimSpace(opts.Backend)
if backendName == "" {
return nil, fmt.Errorf("--backend flag requires a value")
}
}
switch {
case agentFlagChanged && backendFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "backend"):
backendName = resolvedBackend
case !backendFlagChanged && agentName != "":
backendName = resolvedBackend
case !backendFlagChanged:
if val := strings.TrimSpace(v.GetString("backend")); val != "" {
backendName = val
}
}
modelFlagChanged := cmd.Flags().Changed("model")
if modelFlagChanged {
model = strings.TrimSpace(opts.Model)
if model == "" {
return nil, fmt.Errorf("--model flag requires a value")
}
}
switch {
case agentFlagChanged && modelFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "model"):
model = strings.TrimSpace(resolvedModel)
case !modelFlagChanged && agentName != "":
model = strings.TrimSpace(resolvedModel)
case !modelFlagChanged:
model = strings.TrimSpace(v.GetString("model"))
}
if cmd.Flags().Changed("reasoning-effort") {
reasoningEffort = strings.TrimSpace(opts.ReasoningEffort)
if reasoningEffort == "" {
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
}
} else if val := strings.TrimSpace(v.GetString("reasoning-effort")); val != "" {
reasoningEffort = val
} else if agentName != "" {
reasoningEffort = strings.TrimSpace(resolvedReasoning)
}
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
skipPermissions := false
if skipChanged {
skipPermissions = opts.SkipPermissions
} else {
skipPermissions = v.GetBool("skip-permissions")
}
if len(args) == 0 {
return nil, fmt.Errorf("task required")
}
cfg := &Config{
WorkDir: defaultWorkdir,
Backend: backendName,
Agent: agentName,
PromptFile: promptFile,
PromptFileExplicit: promptFileExplicit,
SkipPermissions: skipPermissions,
Yolo: yolo,
Model: model,
ReasoningEffort: reasoningEffort,
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
}
if args[0] == "resume" {
if len(args) < 3 {
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
}
cfg.Mode = "resume"
cfg.SessionID = strings.TrimSpace(args[1])
if cfg.SessionID == "" {
return nil, fmt.Errorf("resume mode requires non-empty session_id")
}
cfg.Task = args[2]
cfg.ExplicitStdin = (args[2] == "-")
if len(args) > 3 {
if args[3] == "-" {
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
}
cfg.WorkDir = args[3]
}
} else {
cfg.Mode = "new"
cfg.Task = args[0]
cfg.ExplicitStdin = (args[0] == "-")
if len(args) > 1 {
if args[1] == "-" {
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
}
cfg.WorkDir = args[1]
}
}
return cfg, nil
}
func lastFlagIndex(argv []string, name string) int {
if len(argv) == 0 {
return -1
}
name = strings.TrimSpace(name)
if name == "" {
return -1
}
needle := "--" + name
prefix := needle + "="
last := -1
for i, arg := range argv {
if arg == needle || strings.HasPrefix(arg, prefix) {
last = i
}
}
return last
}
func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *viper.Viper, name string) int {
if len(args) > 0 {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; no positional arguments are allowed.")
fmt.Fprintln(os.Stderr, "Usage examples:")
fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name)
fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name)
fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name)
fmt.Fprintf(os.Stderr, " %s --parallel --full-output <<'EOF' # include full task output\n", name)
return 1
}
if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.")
return 1
}
backendName := defaultBackendName
if cmd.Flags().Changed("backend") {
backendName = strings.TrimSpace(opts.Backend)
if backendName == "" {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
return 1
}
} else if val := strings.TrimSpace(v.GetString("backend")); val != "" {
backendName = val
}
model := ""
if cmd.Flags().Changed("model") {
model = strings.TrimSpace(opts.Model)
if model == "" {
fmt.Fprintln(os.Stderr, "ERROR: --model flag requires a value")
return 1
}
} else {
model = strings.TrimSpace(v.GetString("model"))
}
fullOutput := opts.FullOutput
if !cmd.Flags().Changed("full-output") && v.IsSet("full-output") {
fullOutput = v.GetBool("full-output")
}
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
skipPermissions := false
if skipChanged {
skipPermissions = opts.SkipPermissions
} else {
skipPermissions = v.GetBool("skip-permissions")
}
backend, err := selectBackendFn(backendName)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
backendName = backend.Name()
data, err := io.ReadAll(stdinReader)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to read stdin: %v\n", err)
return 1
}
cfg, err := parseParallelConfig(data)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
cfg.GlobalBackend = backendName
model = strings.TrimSpace(model)
for i := range cfg.Tasks {
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
cfg.Tasks[i].Backend = backendName
}
if strings.TrimSpace(cfg.Tasks[i].Model) == "" && model != "" {
cfg.Tasks[i].Model = model
}
cfg.Tasks[i].SkipPermissions = cfg.Tasks[i].SkipPermissions || skipPermissions
}
timeoutSec := resolveTimeout()
layers, err := topologicalSort(cfg.Tasks)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
results := executeConcurrent(layers, timeoutSec)
for i := range results {
results[i].CoverageTarget = defaultCoverageTarget
if results[i].Message == "" {
continue
}
lines := strings.Split(results[i].Message, "\n")
results[i].Coverage = extractCoverageFromLines(lines)
results[i].CoverageNum = extractCoverageNum(results[i].Coverage)
results[i].FilesChanged = extractFilesChangedFromLines(lines)
results[i].TestsPassed, results[i].TestsFailed = extractTestResultsFromLines(lines)
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
}
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
exitCode := 0
for _, res := range results {
if res.ExitCode != 0 {
exitCode = res.ExitCode
}
}
return exitCode
}
func runSingleMode(cfg *Config, name string) int {
backend, err := selectBackendFn(cfg.Backend)
if err != nil {
logError(err.Error())
return 1
}
cfg.Backend = backend.Name()
cmdInjected := codexCommand != defaultCodexCommand
argsInjected := buildCodexArgsFn != nil && reflect.ValueOf(buildCodexArgsFn).Pointer() != reflect.ValueOf(defaultBuildArgsFn).Pointer()
if backend.Name() != defaultBackendName || !cmdInjected {
codexCommand = backend.Command()
}
if backend.Name() != defaultBackendName || !argsInjected {
buildCodexArgsFn = backend.BuildArgs
}
logInfo(fmt.Sprintf("Selected backend: %s", backend.Name()))
timeoutSec := resolveTimeout()
logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec))
cfg.Timeout = timeoutSec
var taskText string
var piped bool
if cfg.ExplicitStdin {
logInfo("Explicit stdin mode: reading task from stdin")
data, err := io.ReadAll(stdinReader)
if err != nil {
logError("Failed to read stdin: " + err.Error())
return 1
}
taskText = string(data)
if taskText == "" {
logError("Explicit stdin mode requires task input from stdin")
return 1
}
piped = !isTerminal()
} else {
pipedTask, err := readPipedTask()
if err != nil {
logError("Failed to read piped stdin: " + err.Error())
return 1
}
piped = pipedTask != ""
if piped {
taskText = pipedTask
} else {
taskText = cfg.Task
}
}
if strings.TrimSpace(cfg.PromptFile) != "" {
prompt, err := readAgentPromptFile(cfg.PromptFile, cfg.PromptFileExplicit)
if err != nil {
logError("Failed to read prompt file: " + err.Error())
return 1
}
taskText = wrapTaskWithAgentPrompt(prompt, taskText)
}
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
targetArg := taskText
if useStdin {
targetArg = "-"
}
codexArgs := buildCodexArgsFn(cfg, targetArg)
logger := activeLogger()
if logger == nil {
fmt.Fprintln(os.Stderr, "ERROR: logger is not initialized")
return 1
}
fmt.Fprintf(os.Stderr, "[%s]\n", name)
fmt.Fprintf(os.Stderr, " Backend: %s\n", cfg.Backend)
fmt.Fprintf(os.Stderr, " Command: %s %s\n", codexCommand, strings.Join(codexArgs, " "))
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
if useStdin {
var reasons []string
if piped {
reasons = append(reasons, "piped input")
}
if cfg.ExplicitStdin {
reasons = append(reasons, "explicit \"-\"")
}
if strings.Contains(taskText, "\n") {
reasons = append(reasons, "newline")
}
if strings.Contains(taskText, "\\") {
reasons = append(reasons, "backslash")
}
if strings.Contains(taskText, "\"") {
reasons = append(reasons, "double-quote")
}
if strings.Contains(taskText, "'") {
reasons = append(reasons, "single-quote")
}
if strings.Contains(taskText, "`") {
reasons = append(reasons, "backtick")
}
if strings.Contains(taskText, "$") {
reasons = append(reasons, "dollar")
}
if len(taskText) > 800 {
reasons = append(reasons, "length>800")
}
if len(reasons) > 0 {
logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", ")))
}
}
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
taskSpec := TaskSpec{
Task: taskText,
WorkDir: cfg.WorkDir,
Mode: cfg.Mode,
SessionID: cfg.SessionID,
Backend: cfg.Backend,
Model: cfg.Model,
ReasoningEffort: cfg.ReasoningEffort,
Agent: cfg.Agent,
SkipPermissions: cfg.SkipPermissions,
UseStdin: useStdin,
}
result := runTaskFn(taskSpec, false, cfg.Timeout)
if result.ExitCode != 0 {
return result.ExitCode
}
// Validate that we got a meaningful output message
if strings.TrimSpace(result.Message) == "" {
logError(fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend))
return 1
}
fmt.Println(result.Message)
if result.SessionID != "" {
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
}
return 0
}

View File

@@ -1,7 +0,0 @@
package wrapper
import config "codeagent-wrapper/internal/config"
// Keep the existing Config name throughout the codebase, but source the
// implementation from internal/config.
type Config = config.Config

View File

@@ -1,54 +0,0 @@
package wrapper
import (
"context"
backend "codeagent-wrapper/internal/backend"
config "codeagent-wrapper/internal/config"
executor "codeagent-wrapper/internal/executor"
)
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset).
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
return executor.DefaultRunCodexTaskFn(task, timeout)
}
var runCodexTaskFn = defaultRunCodexTaskFn
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
return executor.TopologicalSort(tasks)
}
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
maxWorkers := config.ResolveMaxParallelWorkers()
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
}
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
return executor.ExecuteConcurrentWithContext(parentCtx, layers, timeout, maxWorkers, runCodexTaskFn)
}
func generateFinalOutput(results []TaskResult) string {
return executor.GenerateFinalOutput(results)
}
func generateFinalOutputWithMode(results []TaskResult, summaryOnly bool) string {
return executor.GenerateFinalOutputWithMode(results, summaryOnly)
}
func buildCodexArgs(cfg *Config, targetArg string) []string {
return backend.BuildCodexArgs(cfg, targetArg)
}
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
return runCodexTaskWithContext(context.Background(), taskSpec, nil, nil, false, silent, timeoutSec)
}
func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) {
res := runCodexTaskWithContext(parentCtx, TaskSpec{Task: taskText, WorkDir: defaultWorkdir, Mode: "new", UseStdin: useStdin}, nil, codexArgs, true, false, timeoutSec)
return res.Message, res.SessionID, res.ExitCode
}
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
return executor.RunCodexTaskWithContext(parentCtx, taskSpec, backend, codexCommand, buildCodexArgsFn, customArgs, useCustomArgs, silent, timeoutSec)
}

View File

@@ -1,26 +0,0 @@
package wrapper
import ilogger "codeagent-wrapper/internal/logger"
type Logger = ilogger.Logger
type CleanupStats = ilogger.CleanupStats
func NewLogger() (*Logger, error) { return ilogger.NewLogger() }
func NewLoggerWithSuffix(suffix string) (*Logger, error) { return ilogger.NewLoggerWithSuffix(suffix) }
func setLogger(l *Logger) { ilogger.SetLogger(l) }
func closeLogger() error { return ilogger.CloseLogger() }
func activeLogger() *Logger { return ilogger.ActiveLogger() }
func logInfo(msg string) { ilogger.LogInfo(msg) }
func logWarn(msg string) { ilogger.LogWarn(msg) }
func logError(msg string) { ilogger.LogError(msg) }
func cleanupOldLogs() (CleanupStats, error) { return ilogger.CleanupOldLogs() }
func sanitizeLogSuffix(raw string) string { return ilogger.SanitizeLogSuffix(raw) }

View File

@@ -1,46 +0,0 @@
package wrapper
import (
"os"
"testing"
)
func TestParseArgs_Workdir_OSPaths(t *testing.T) {
oldArgv := os.Args
t.Cleanup(func() { os.Args = oldArgv })
workdirs := []struct {
name string
path string
}{
{name: "windows drive forward slashes", path: "D:/repo/path"},
{name: "windows drive backslashes", path: `C:\repo\path`},
{name: "windows UNC", path: `\\server\share\repo`},
{name: "unix absolute", path: "/home/user/repo"},
{name: "relative", path: "./relative/repo"},
}
for _, wd := range workdirs {
t.Run("new mode: "+wd.name, func(t *testing.T) {
os.Args = []string{"codeagent-wrapper", "task", wd.path}
cfg, err := parseArgs()
if err != nil {
t.Fatalf("parseArgs() error: %v", err)
}
if cfg.Mode != "new" || cfg.Task != "task" || cfg.WorkDir != wd.path {
t.Fatalf("cfg mismatch: got mode=%q task=%q workdir=%q, want mode=%q task=%q workdir=%q", cfg.Mode, cfg.Task, cfg.WorkDir, "new", "task", wd.path)
}
})
t.Run("resume mode: "+wd.name, func(t *testing.T) {
os.Args = []string{"codeagent-wrapper", "resume", "sid-1", "task", wd.path}
cfg, err := parseArgs()
if err != nil {
t.Fatalf("parseArgs() error: %v", err)
}
if cfg.Mode != "resume" || cfg.SessionID != "sid-1" || cfg.Task != "task" || cfg.WorkDir != wd.path {
t.Fatalf("cfg mismatch: got mode=%q sid=%q task=%q workdir=%q, want mode=%q sid=%q task=%q workdir=%q", cfg.Mode, cfg.SessionID, cfg.Task, cfg.WorkDir, "resume", "sid-1", "task", wd.path)
}
})
}
}

View File

@@ -1,9 +0,0 @@
package wrapper
import (
executor "codeagent-wrapper/internal/executor"
)
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
return executor.ParseParallelConfig(data)
}

View File

@@ -1,34 +0,0 @@
package wrapper
import (
"bufio"
"io"
parser "codeagent-wrapper/internal/parser"
"github.com/goccy/go-json"
)
func parseJSONStream(r io.Reader) (message, threadID string) {
return parseJSONStreamWithLog(r, logWarn, logInfo)
}
func parseJSONStreamWithWarn(r io.Reader, warnFn func(string)) (message, threadID string) {
return parseJSONStreamWithLog(r, warnFn, logInfo)
}
func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) {
return parseJSONStreamInternal(r, warnFn, infoFn, nil, nil)
}
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
return parser.ParseJSONStreamInternal(r, warnFn, infoFn, onMessage, onComplete)
}
func hasKey(m map[string]json.RawMessage, key string) bool { return parser.HasKey(m, key) }
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
return parser.DiscardInvalidJSON(decoder, reader)
}
func normalizeText(text interface{}) string { return parser.NormalizeText(text) }

View File

@@ -1,119 +0,0 @@
package wrapper
import (
"strings"
"testing"
)
func TestRunSingleMode_UseStdin_TargetArgAndTaskText(t *testing.T) {
defer resetTestHooks()
setTempDirEnv(t, t.TempDir())
logger, err := NewLogger()
if err != nil {
t.Fatalf("NewLogger(): %v", err)
}
setLogger(logger)
t.Cleanup(func() { _ = closeLogger() })
type testCase struct {
name string
cfgTask string
explicit bool
stdinData string
isTerminal bool
wantUseStdin bool
wantTarget string
wantTaskText string
}
longTask := strings.Repeat("a", 801)
tests := []testCase{
{
name: "piped input forces stdin mode",
cfgTask: "cli-task",
stdinData: "piped task text",
isTerminal: false,
wantUseStdin: true,
wantTarget: "-",
wantTaskText: "piped task text",
},
{
name: "explicit dash forces stdin mode",
cfgTask: "-",
explicit: true,
stdinData: "explicit task text",
isTerminal: true,
wantUseStdin: true,
wantTarget: "-",
wantTaskText: "explicit task text",
},
{
name: "special char backslash forces stdin mode",
cfgTask: `C:\repo\file.go`,
isTerminal: true,
wantUseStdin: true,
wantTarget: "-",
wantTaskText: `C:\repo\file.go`,
},
{
name: "length>800 forces stdin mode",
cfgTask: longTask,
isTerminal: true,
wantUseStdin: true,
wantTarget: "-",
wantTaskText: longTask,
},
{
name: "simple task uses argv target",
cfgTask: "analyze code",
isTerminal: true,
wantUseStdin: false,
wantTarget: "analyze code",
wantTaskText: "analyze code",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotTarget string
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
gotTarget = targetArg
return []string{targetArg}
}
var gotTask TaskSpec
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
gotTask = task
return TaskResult{ExitCode: 0, Message: "ok"}
}
stdinReader = strings.NewReader(tt.stdinData)
isTerminalFn = func() bool { return tt.isTerminal }
cfg := &Config{
Mode: "new",
Task: tt.cfgTask,
WorkDir: defaultWorkdir,
Backend: defaultBackendName,
ExplicitStdin: tt.explicit,
}
if code := runSingleMode(cfg, "codeagent-wrapper"); code != 0 {
t.Fatalf("runSingleMode() = %d, want 0", code)
}
if gotTarget != tt.wantTarget {
t.Fatalf("targetArg = %q, want %q", gotTarget, tt.wantTarget)
}
if gotTask.UseStdin != tt.wantUseStdin {
t.Fatalf("taskSpec.UseStdin = %v, want %v", gotTask.UseStdin, tt.wantUseStdin)
}
if gotTask.Task != tt.wantTaskText {
t.Fatalf("taskSpec.Task = %q, want %q", gotTask.Task, tt.wantTaskText)
}
})
}
}

View File

@@ -1,8 +0,0 @@
package wrapper
import executor "codeagent-wrapper/internal/executor"
// Type aliases to keep existing names in the wrapper package.
type ParallelConfig = executor.ParallelConfig
type TaskSpec = executor.TaskSpec
type TaskResult = executor.TaskResult

View File

@@ -1,30 +0,0 @@
package wrapper
import (
"os"
"testing"
)
func TestDefaultIsTerminalCoverage(t *testing.T) {
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
if err != nil {
t.Fatalf("os.CreateTemp() error = %v", err)
}
defer os.Remove(f.Name())
os.Stdin = f
if got := defaultIsTerminal(); got {
t.Fatalf("defaultIsTerminal() = %v, want false for regular file", got)
}
if err := f.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
os.Stdin = f
if got := defaultIsTerminal(); !got {
t.Fatalf("defaultIsTerminal() = %v, want true when Stat fails", got)
}
}

View File

@@ -1,134 +0,0 @@
package wrapper
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const tmpDirEnvOverrideKey = "CODEAGENT_TMPDIR"
var tmpDirExecutableCheckFn = canExecuteInDir
func ensureExecutableTempDir() {
// Windows doesn't execute scripts via shebang, and os.TempDir semantics differ.
if runtime.GOOS == "windows" {
return
}
if override := strings.TrimSpace(os.Getenv(tmpDirEnvOverrideKey)); override != "" {
if resolved, err := resolvePathWithTilde(override); err == nil {
if err := os.MkdirAll(resolved, 0o700); err == nil {
if ok, _ := tmpDirExecutableCheckFn(resolved); ok {
setTempEnv(resolved)
return
}
}
}
// Invalid override should not block execution; fall back to default behavior.
}
current := currentTempDirFromEnv()
if current == "" {
current = "/tmp"
}
ok, _ := tmpDirExecutableCheckFn(current)
if ok {
return
}
fallback := defaultFallbackTempDir()
if fallback == "" {
return
}
if err := os.MkdirAll(fallback, 0o700); err != nil {
return
}
if ok, _ := tmpDirExecutableCheckFn(fallback); !ok {
return
}
setTempEnv(fallback)
fmt.Fprintf(os.Stderr, "INFO: temp dir is not executable; set TMPDIR=%s\n", fallback)
}
func setTempEnv(dir string) {
_ = os.Setenv("TMPDIR", dir)
_ = os.Setenv("TMP", dir)
_ = os.Setenv("TEMP", dir)
}
func defaultFallbackTempDir() string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return ""
}
return filepath.Clean(filepath.Join(home, ".codeagent", "tmp"))
}
func currentTempDirFromEnv() string {
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
}
return ""
}
func resolvePathWithTilde(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", errors.New("empty path")
}
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
if err == nil {
err = errors.New("empty home directory")
}
return "", fmt.Errorf("resolve ~: %w", err)
}
if p == "~" {
return home, nil
}
return filepath.Clean(home + p[1:]), nil
}
return filepath.Clean(p), nil
}
func canExecuteInDir(dir string) (bool, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return false, errors.New("empty dir")
}
f, err := os.CreateTemp(dir, "codeagent-tmp-exec-*")
if err != nil {
return false, err
}
path := f.Name()
defer func() { _ = os.Remove(path) }()
if _, err := f.WriteString("#!/bin/sh\nexit 0\n"); err != nil {
_ = f.Close()
return false, err
}
if err := f.Close(); err != nil {
return false, err
}
if err := os.Chmod(path, 0o700); err != nil {
return false, err
}
if err := exec.Command(path).Run(); err != nil {
return false, err
}
return true, nil
}

View File

@@ -1,96 +0,0 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureExecutableTempDir_Override(t *testing.T) {
restore := captureTempEnv()
t.Cleanup(restore)
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
orig := tmpDirExecutableCheckFn
tmpDirExecutableCheckFn = func(string) (bool, error) { return true, nil }
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
override := filepath.Join(t.TempDir(), "mytmp")
t.Setenv(tmpDirEnvOverrideKey, override)
ensureExecutableTempDir()
if got := os.Getenv("TMPDIR"); got != override {
t.Fatalf("TMPDIR=%q, want %q", got, override)
}
if got := os.Getenv("TMP"); got != override {
t.Fatalf("TMP=%q, want %q", got, override)
}
if got := os.Getenv("TEMP"); got != override {
t.Fatalf("TEMP=%q, want %q", got, override)
}
if st, err := os.Stat(override); err != nil || !st.IsDir() {
t.Fatalf("override dir not created: stat=%v err=%v", st, err)
}
}
func TestEnsureExecutableTempDir_FallbackWhenCurrentNotExecutable(t *testing.T) {
restore := captureTempEnv()
t.Cleanup(restore)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
cur := filepath.Join(t.TempDir(), "cur-tmp")
if err := os.MkdirAll(cur, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("TMPDIR", cur)
fallback := filepath.Join(home, ".codeagent", "tmp")
orig := tmpDirExecutableCheckFn
tmpDirExecutableCheckFn = func(dir string) (bool, error) {
if filepath.Clean(dir) == filepath.Clean(cur) {
return false, nil
}
if filepath.Clean(dir) == filepath.Clean(fallback) {
return true, nil
}
return true, nil
}
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
ensureExecutableTempDir()
if got := os.Getenv("TMPDIR"); filepath.Clean(got) != filepath.Clean(fallback) {
t.Fatalf("TMPDIR=%q, want %q", got, fallback)
}
if st, err := os.Stat(fallback); err != nil || !st.IsDir() {
t.Fatalf("fallback dir not created: stat=%v err=%v", st, err)
}
}
func captureTempEnv() func() {
type entry struct {
set bool
val string
}
snapshot := make(map[string]entry, 3)
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
v, ok := os.LookupEnv(k)
snapshot[k] = entry{set: ok, val: v}
}
return func() {
for k, e := range snapshot {
if !e.set {
_ = os.Unsetenv(k)
continue
}
_ = os.Setenv(k, e.val)
}
}
}

View File

@@ -1,523 +0,0 @@
package wrapper
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
utils "codeagent-wrapper/internal/utils"
)
func resolveTimeout() int {
raw := os.Getenv("CODEX_TIMEOUT")
if raw == "" {
return defaultTimeout
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout))
return defaultTimeout
}
if parsed > 10000 {
return parsed / 1000
}
return parsed
}
func readPipedTask() (string, error) {
if isTerminal() {
logInfo("Stdin is tty, skipping pipe read")
return "", nil
}
logInfo("Reading from stdin pipe...")
data, err := io.ReadAll(stdinReader)
if err != nil {
return "", fmt.Errorf("read stdin: %w", err)
}
if len(data) == 0 {
logInfo("Stdin pipe returned empty data")
return "", nil
}
logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data)))
return string(data), nil
}
func shouldUseStdin(taskText string, piped bool) bool {
if piped {
return true
}
if len(taskText) > 800 {
return true
}
return strings.ContainsAny(taskText, stdinSpecialChars)
}
func defaultIsTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return true
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func isTerminal() bool {
return isTerminalFn()
}
func getEnv(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
type logWriter struct {
prefix string
maxLen int
buf bytes.Buffer
dropped bool
}
func newLogWriter(prefix string, maxLen int) *logWriter {
if maxLen <= 0 {
maxLen = codexLogLineLimit
}
return &logWriter{prefix: prefix, maxLen: maxLen}
}
func (lw *logWriter) Write(p []byte) (int, error) {
if lw == nil {
return len(p), nil
}
total := len(p)
for len(p) > 0 {
if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
lw.writeLimited(p[:idx])
lw.logLine(true)
p = p[idx+1:]
continue
}
lw.writeLimited(p)
break
}
return total, nil
}
func (lw *logWriter) Flush() {
if lw == nil || lw.buf.Len() == 0 {
return
}
lw.logLine(false)
}
func (lw *logWriter) logLine(force bool) {
if lw == nil {
return
}
line := lw.buf.String()
dropped := lw.dropped
lw.dropped = false
lw.buf.Reset()
if line == "" && !force {
return
}
if lw.maxLen > 0 {
if dropped {
if lw.maxLen > 3 {
line = line[:min(len(line), lw.maxLen-3)] + "..."
} else {
line = line[:min(len(line), lw.maxLen)]
}
} else if len(line) > lw.maxLen {
cutoff := lw.maxLen
if cutoff > 3 {
line = line[:cutoff-3] + "..."
} else {
line = line[:cutoff]
}
}
}
logInfo(lw.prefix + line)
}
func (lw *logWriter) writeLimited(p []byte) {
if lw == nil || len(p) == 0 {
return
}
if lw.maxLen <= 0 {
lw.buf.Write(p)
return
}
remaining := lw.maxLen - lw.buf.Len()
if remaining <= 0 {
lw.dropped = true
return
}
if len(p) <= remaining {
lw.buf.Write(p)
return
}
lw.buf.Write(p[:remaining])
lw.dropped = true
}
type tailBuffer struct {
limit int
data []byte
}
func (b *tailBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
return len(p), nil
}
if len(p) >= b.limit {
b.data = append(b.data[:0], p[len(p)-b.limit:]...)
return len(p), nil
}
total := len(b.data) + len(p)
if total <= b.limit {
b.data = append(b.data, p...)
return len(p), nil
}
overflow := total - b.limit
b.data = append(b.data[overflow:], p...)
return len(p), nil
}
func (b *tailBuffer) String() string {
return string(b.data)
}
func truncate(s string, maxLen int) string {
return utils.Truncate(s, maxLen)
}
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
func safeTruncate(s string, maxLen int) string {
return utils.SafeTruncate(s, maxLen)
}
// sanitizeOutput removes ANSI escape sequences and control characters.
func sanitizeOutput(s string) string {
return utils.SanitizeOutput(s)
}
func min(a, b int) int {
return utils.Min(a, b)
}
func hello() string {
return "hello world"
}
func greet(name string) string {
return "hello " + name
}
func farewell(name string) string {
return "goodbye " + name
}
// extractCoverageFromLines extracts coverage from pre-split lines.
func extractCoverageFromLines(lines []string) string {
if len(lines) == 0 {
return ""
}
end := len(lines)
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
end--
}
if end == 1 {
trimmed := strings.TrimSpace(lines[0])
if strings.HasSuffix(trimmed, "%") {
if num, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, "%"), 64); err == nil && num >= 0 && num <= 100 {
return trimmed
}
}
}
coverageKeywords := []string{"file", "stmt", "branch", "line", "coverage", "total"}
for _, line := range lines[:end] {
lower := strings.ToLower(line)
hasKeyword := false
tokens := strings.FieldsFunc(lower, func(r rune) bool { return r < 'a' || r > 'z' })
for _, token := range tokens {
for _, kw := range coverageKeywords {
if strings.HasPrefix(token, kw) {
hasKeyword = true
break
}
}
if hasKeyword {
break
}
}
if !hasKeyword {
continue
}
if !strings.Contains(line, "%") {
continue
}
// Extract percentage pattern: number followed by %
for i := 0; i < len(line); i++ {
if line[i] == '%' && i > 0 {
// Walk back to find the number
j := i - 1
for j >= 0 && (line[j] == '.' || (line[j] >= '0' && line[j] <= '9')) {
j--
}
if j < i-1 {
numStr := line[j+1 : i]
// Validate it's a reasonable percentage
if num, err := strconv.ParseFloat(numStr, 64); err == nil && num >= 0 && num <= 100 {
return numStr + "%"
}
}
}
}
}
return ""
}
// extractCoverage extracts coverage percentage from task output
// Supports common formats: "Coverage: 92%", "92% coverage", "coverage 92%", "TOTAL 92%"
func extractCoverage(message string) string {
if message == "" {
return ""
}
return extractCoverageFromLines(strings.Split(message, "\n"))
}
// extractCoverageNum extracts coverage as a numeric value for comparison
func extractCoverageNum(coverage string) float64 {
if coverage == "" {
return 0
}
// Remove % sign and parse
numStr := strings.TrimSuffix(coverage, "%")
if num, err := strconv.ParseFloat(numStr, 64); err == nil {
return num
}
return 0
}
// extractFilesChangedFromLines extracts files from pre-split lines.
func extractFilesChangedFromLines(lines []string) []string {
if len(lines) == 0 {
return nil
}
var files []string
seen := make(map[string]bool)
exts := []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".java", ".vue", ".css", ".scss", ".md", ".json", ".yaml", ".yml", ".toml"}
for _, line := range lines {
line = strings.TrimSpace(line)
// Pattern 1: "Modified: path/to/file.ts" or "Created: path/to/file.ts"
matchedPrefix := false
for _, prefix := range []string{"Modified:", "Created:", "Updated:", "Edited:", "Wrote:", "Changed:"} {
if strings.HasPrefix(line, prefix) {
file := strings.TrimSpace(strings.TrimPrefix(line, prefix))
file = strings.Trim(file, "`\"'()[],:")
file = strings.TrimPrefix(file, "@")
if file != "" && !seen[file] {
files = append(files, file)
seen[file] = true
}
matchedPrefix = true
break
}
}
if matchedPrefix {
continue
}
// Pattern 2: Tokens that look like file paths (allow root files, strip @ prefix).
parts := strings.Fields(line)
for _, part := range parts {
part = strings.Trim(part, "`\"'()[],:")
part = strings.TrimPrefix(part, "@")
for _, ext := range exts {
if strings.HasSuffix(part, ext) && !seen[part] {
files = append(files, part)
seen[part] = true
break
}
}
}
}
// Limit to first 10 files to avoid bloat
if len(files) > 10 {
files = files[:10]
}
return files
}
// extractFilesChanged extracts list of changed files from task output
// Looks for common patterns like "Modified: file.ts", "Created: file.ts", file paths in output
func extractFilesChanged(message string) []string {
if message == "" {
return nil
}
return extractFilesChangedFromLines(strings.Split(message, "\n"))
}
// extractTestResultsFromLines extracts test results from pre-split lines.
func extractTestResultsFromLines(lines []string) (passed, failed int) {
if len(lines) == 0 {
return 0, 0
}
// Common patterns:
// pytest: "12 passed, 2 failed"
// jest: "Tests: 2 failed, 12 passed"
// go: "ok ... 12 tests"
for _, line := range lines {
line = strings.ToLower(line)
// Look for test result lines
if !strings.Contains(line, "pass") && !strings.Contains(line, "fail") && !strings.Contains(line, "test") {
continue
}
// Extract numbers near "passed" or "pass"
if idx := strings.Index(line, "pass"); idx != -1 {
// Look for number before "pass"
num := extractNumberBefore(line, idx)
if num > 0 {
passed = num
}
}
// Extract numbers near "failed" or "fail"
if idx := strings.Index(line, "fail"); idx != -1 {
num := extractNumberBefore(line, idx)
if num > 0 {
failed = num
}
}
// go test style: "ok ... 12 tests"
if passed == 0 {
if idx := strings.Index(line, "test"); idx != -1 {
num := extractNumberBefore(line, idx)
if num > 0 {
passed = num
}
}
}
// If we found both, stop
if passed > 0 && failed > 0 {
break
}
}
return passed, failed
}
// extractTestResults extracts test pass/fail counts from task output
func extractTestResults(message string) (passed, failed int) {
if message == "" {
return 0, 0
}
return extractTestResultsFromLines(strings.Split(message, "\n"))
}
// extractNumberBefore extracts a number that appears before the given index
func extractNumberBefore(s string, idx int) int {
if idx <= 0 {
return 0
}
// Walk backwards to find digits
end := idx - 1
for end >= 0 && (s[end] == ' ' || s[end] == ':' || s[end] == ',') {
end--
}
if end < 0 {
return 0
}
start := end
for start >= 0 && s[start] >= '0' && s[start] <= '9' {
start--
}
start++
if start > end {
return 0
}
numStr := s[start : end+1]
if num, err := strconv.Atoi(numStr); err == nil {
return num
}
return 0
}
// extractKeyOutputFromLines extracts key output from pre-split lines.
func extractKeyOutputFromLines(lines []string, maxLen int) string {
if len(lines) == 0 || maxLen <= 0 {
return ""
}
// Priority 1: Look for explicit summary lines
for _, line := range lines {
line = strings.TrimSpace(line)
lower := strings.ToLower(line)
if strings.HasPrefix(lower, "summary:") || strings.HasPrefix(lower, "completed:") ||
strings.HasPrefix(lower, "implemented:") || strings.HasPrefix(lower, "added:") ||
strings.HasPrefix(lower, "created:") || strings.HasPrefix(lower, "fixed:") {
content := line
for _, prefix := range []string{"Summary:", "Completed:", "Implemented:", "Added:", "Created:", "Fixed:",
"summary:", "completed:", "implemented:", "added:", "created:", "fixed:"} {
content = strings.TrimPrefix(content, prefix)
}
content = strings.TrimSpace(content)
if len(content) > 0 {
return safeTruncate(content, maxLen)
}
}
}
// Priority 2: First meaningful line (skip noise)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") ||
strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
// Skip very short lines (likely headers or markers)
if len(line) < 20 {
continue
}
return safeTruncate(line, maxLen)
}
// Fallback: truncate entire message
clean := strings.TrimSpace(strings.Join(lines, "\n"))
return safeTruncate(clean, maxLen)
}

View File

@@ -1,143 +0,0 @@
package wrapper
import (
"fmt"
"reflect"
"strings"
"testing"
)
func TestExtractCoverage(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"bare int", "92%", "92%"},
{"bare float", "92.5%", "92.5%"},
{"coverage prefix", "coverage: 92%", "92%"},
{"total prefix", "TOTAL 92%", "92%"},
{"all files", "All files 92%", "92%"},
{"empty", "", ""},
{"no number", "coverage: N/A", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractCoverage(tt.in); got != tt.want {
t.Fatalf("extractCoverage(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestExtractTestResults(t *testing.T) {
tests := []struct {
name string
in string
wantPassed int
wantFailed int
}{
{"pytest one line", "12 passed, 2 failed", 12, 2},
{"pytest split lines", "12 passed\n2 failed", 12, 2},
{"jest format", "Tests: 2 failed, 12 passed, 14 total", 12, 2},
{"go test style count", "ok\texample.com/foo\t0.12s\t12 tests", 12, 0},
{"zero counts", "0 passed, 0 failed", 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passed, failed := extractTestResults(tt.in)
if passed != tt.wantPassed || failed != tt.wantFailed {
t.Fatalf("extractTestResults(%q) = (%d, %d), want (%d, %d)", tt.in, passed, failed, tt.wantPassed, tt.wantFailed)
}
})
}
}
func TestExtractFilesChanged(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{"root file", "Modified: main.go\n", []string{"main.go"}},
{"path file", "Created: codeagent-wrapper/utils.go\n", []string{"codeagent-wrapper/utils.go"}},
{"at prefix", "Updated: @codeagent-wrapper/main.go\n", []string{"codeagent-wrapper/main.go"}},
{"token scan", "Files: @main.go, @codeagent-wrapper/utils.go\n", []string{"main.go", "codeagent-wrapper/utils.go"}},
{"space path", "Modified: dir/with space/file.go\n", []string{"dir/with space/file.go"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractFilesChanged(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("extractFilesChanged(%q) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
t.Run("limits to first 10", func(t *testing.T) {
var b strings.Builder
for i := 0; i < 12; i++ {
fmt.Fprintf(&b, "Modified: file%d.go\n", i)
}
got := extractFilesChanged(b.String())
if len(got) != 10 {
t.Fatalf("len(files)=%d, want 10: %#v", len(got), got)
}
for i := 0; i < 10; i++ {
want := fmt.Sprintf("file%d.go", i)
if got[i] != want {
t.Fatalf("files[%d]=%q, want %q", i, got[i], want)
}
}
})
}
func TestSafeTruncate(t *testing.T) {
tests := []struct {
name string
in string
maxLen int
want string
}{
{"empty", "", 4, ""},
{"zero maxLen", "hello", 0, ""},
{"one rune", "你好", 1, "你"},
{"two runes no truncate", "你好", 2, "你好"},
{"three runes no truncate", "你好", 3, "你好"},
{"two runes truncates long", "你好世界", 2, "你"},
{"three runes truncates long", "你好世界", 3, "你"},
{"four with ellipsis", "你好世界啊", 4, "你..."},
{"emoji", "🙂🙂🙂🙂🙂", 4, "🙂..."},
{"no truncate", "你好世界", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := safeTruncate(tt.in, tt.maxLen); got != tt.want {
t.Fatalf("safeTruncate(%q, %d) = %q, want %q", tt.in, tt.maxLen, got, tt.want)
}
})
}
}
func TestSanitizeOutput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"ansi", "\x1b[31mred\x1b[0m", "red"},
{"control chars", "a\x07b\r\nc\t", "ab\nc\t"},
{"normal", "hello\nworld\t!", "hello\nworld\t!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeOutput(tt.in); got != tt.want {
t.Fatalf("sanitizeOutput(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -1,9 +0,0 @@
package wrapper
import ilogger "codeagent-wrapper/internal/logger"
const wrapperName = ilogger.WrapperName
func currentWrapperName() string { return ilogger.CurrentWrapperName() }
func primaryLogPrefix() string { return ilogger.PrimaryLogPrefix() }

View File

@@ -1,33 +0,0 @@
package backend
import config "codeagent-wrapper/internal/config"
// Backend defines the contract for invoking different AI CLI backends.
// Each backend is responsible for supplying the executable command and
// building the argument list based on the wrapper config.
type Backend interface {
Name() string
BuildArgs(cfg *config.Config, targetArg string) []string
Command() string
Env(baseURL, apiKey string) map[string]string
}
var (
logWarnFn = func(string) {}
logErrorFn = func(string) {}
)
// SetLogFuncs configures optional logging hooks used by some backends.
// Callers can safely pass nil to disable the hook.
func SetLogFuncs(warnFn, errorFn func(string)) {
if warnFn != nil {
logWarnFn = warnFn
} else {
logWarnFn = func(string) {}
}
if errorFn != nil {
logErrorFn = errorFn
} else {
logErrorFn = func(string) {}
}
}

View File

@@ -1,322 +0,0 @@
package backend
import (
"bytes"
"os"
"path/filepath"
"reflect"
"testing"
config "codeagent-wrapper/internal/config"
)
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
backend := ClaudeBackend{}
t.Run("new mode omits skip-permissions when env disabled", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
cfg := &config.Config{Mode: "new", WorkDir: "/repo"}
got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("new mode includes skip-permissions by default", func(t *testing.T) {
cfg := &config.Config{Mode: "new", SkipPermissions: false}
got := backend.BuildArgs(cfg, "-")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode includes session id", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "resume-task")
want := []string{"-p", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
cfg := &config.Config{Mode: "resume", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "follow-up")
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("resume mode can opt-in skip permissions", func(t *testing.T) {
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", SkipPermissions: true}
got := backend.BuildArgs(cfg, "resume-task")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("nil config returns nil", func(t *testing.T) {
if backend.BuildArgs(nil, "ignored") != nil {
t.Fatalf("nil config should return nil args")
}
})
}
func TestBackendBuildArgs_Model(t *testing.T) {
t.Run("claude includes --model when set", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
backend := ClaudeBackend{}
cfg := &config.Config{Mode: "new", Model: "opus"}
got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--setting-sources", "", "--model", "opus", "--output-format", "stream-json", "--verbose", "todo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini includes -m when set", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &config.Config{Mode: "new", Model: "gemini-3-pro-preview"}
got := backend.BuildArgs(cfg, "task")
want := []string{"-o", "stream-json", "-y", "-m", "gemini-3-pro-preview", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("codex includes --model when set", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "false")
backend := CodexBackend{}
cfg := &config.Config{Mode: "new", WorkDir: "/tmp", Model: "o3"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--model", "o3", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
}
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &config.Config{Mode: "new", WorkDir: "/workspace"}
got := backend.BuildArgs(cfg, "task")
want := []string{"-o", "stream-json", "-y", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode uses session id", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &config.Config{Mode: "resume", SessionID: "sid-999"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "resume"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &config.Config{Mode: "resume"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "resume"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini nil config returns nil", func(t *testing.T) {
backend := GeminiBackend{}
if backend.BuildArgs(nil, "ignored") != nil {
t.Fatalf("nil config should return nil args")
}
})
t.Run("gemini stdin mode uses -p flag", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &config.Config{Mode: "new"}
got := backend.BuildArgs(cfg, "-")
want := []string{"-o", "stream-json", "-y", "-p", "-"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("codex build args omits bypass flag by default", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "false")
backend := CodexBackend{}
cfg := &config.Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("codex build args includes bypass flag when enabled", func(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "true")
backend := CodexBackend{}
cfg := &config.Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
}
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
tests := []struct {
backend Backend
name string
command string
}{
{backend: CodexBackend{}, name: "codex", command: "codex"},
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
}
for _, tt := range tests {
if got := tt.backend.Name(); got != tt.name {
t.Fatalf("Name() = %s, want %s", got, tt.name)
}
if got := tt.backend.Command(); got != tt.command {
t.Fatalf("Command() = %s, want %s", got, tt.command)
}
}
}
func TestLoadMinimalEnvSettings(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Run("missing file returns empty", func(t *testing.T) {
if got := LoadMinimalEnvSettings(); len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
})
t.Run("valid env returns string map", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(dir, "settings.json")
data := []byte(`{"env":{"ANTHROPIC_API_KEY":"secret","FOO":"bar"}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := LoadMinimalEnvSettings()
if got["ANTHROPIC_API_KEY"] != "secret" || got["FOO"] != "bar" {
t.Fatalf("got %v, want keys present", got)
}
})
t.Run("non-string values are ignored", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
path := filepath.Join(dir, "settings.json")
data := []byte(`{"env":{"GOOD":"ok","BAD":123,"ALSO_BAD":true}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := LoadMinimalEnvSettings()
if got["GOOD"] != "ok" {
t.Fatalf("got %v, want GOOD=ok", got)
}
if _, ok := got["BAD"]; ok {
t.Fatalf("got %v, want BAD omitted", got)
}
if _, ok := got["ALSO_BAD"]; ok {
t.Fatalf("got %v, want ALSO_BAD omitted", got)
}
})
t.Run("oversized file returns empty", func(t *testing.T) {
dir := filepath.Join(home, ".claude")
path := filepath.Join(dir, "settings.json")
data := bytes.Repeat([]byte("a"), MaxClaudeSettingsBytes+1)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if got := LoadMinimalEnvSettings(); len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
})
}
func TestOpencodeBackend_BuildArgs(t *testing.T) {
backend := OpencodeBackend{}
t.Run("basic", func(t *testing.T) {
cfg := &config.Config{Mode: "new"}
got := backend.BuildArgs(cfg, "hello")
want := []string{"run", "--format", "json", "hello"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("with model", func(t *testing.T) {
cfg := &config.Config{Mode: "new", Model: "opencode/grok-code"}
got := backend.BuildArgs(cfg, "task")
want := []string{"run", "-m", "opencode/grok-code", "--format", "json", "task"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("resume mode", func(t *testing.T) {
cfg := &config.Config{Mode: "resume", SessionID: "ses_123", Model: "opencode/grok-code"}
got := backend.BuildArgs(cfg, "follow-up")
want := []string{"run", "-m", "opencode/grok-code", "-s", "ses_123", "--format", "json", "follow-up"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("resume without session", func(t *testing.T) {
cfg := &config.Config{Mode: "resume"}
got := backend.BuildArgs(cfg, "task")
want := []string{"run", "--format", "json", "task"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("stdin mode omits dash", func(t *testing.T) {
cfg := &config.Config{Mode: "new"}
got := backend.BuildArgs(cfg, "-")
want := []string{"run", "--format", "json"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
})
}
func TestOpencodeBackend_Interface(t *testing.T) {
backend := OpencodeBackend{}
if backend.Name() != "opencode" {
t.Errorf("Name() = %q, want %q", backend.Name(), "opencode")
}
if backend.Command() != "opencode" {
t.Errorf("Command() = %q, want %q", backend.Command(), "opencode")
}
}

View File

@@ -1,140 +0,0 @@
package backend
import (
"os"
"path/filepath"
"strings"
config "codeagent-wrapper/internal/config"
"github.com/goccy/go-json"
)
type ClaudeBackend struct{}
func (ClaudeBackend) Name() string { return "claude" }
func (ClaudeBackend) Command() string { return "claude" }
func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string {
baseURL = strings.TrimSpace(baseURL)
apiKey = strings.TrimSpace(apiKey)
if baseURL == "" && apiKey == "" {
return nil
}
env := make(map[string]string, 2)
if baseURL != "" {
env["ANTHROPIC_BASE_URL"] = baseURL
}
if apiKey != "" {
// Claude Code CLI uses ANTHROPIC_API_KEY for API-key based auth.
env["ANTHROPIC_API_KEY"] = apiKey
}
return env
}
func (ClaudeBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
return buildClaudeArgs(cfg, targetArg)
}
const MaxClaudeSettingsBytes = 1 << 20 // 1MB
type MinimalClaudeSettings struct {
Env map[string]string
Model string
}
// LoadMinimalClaudeSettings 从 ~/.claude/settings.json 只提取安全的最小子集:
// - env: 只接受字符串类型的值
// - model: 只接受字符串类型的值
// 文件缺失/解析失败/超限都返回空。
func LoadMinimalClaudeSettings() MinimalClaudeSettings {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return MinimalClaudeSettings{}
}
claudeDir := filepath.Clean(filepath.Join(home, ".claude"))
settingPath := filepath.Clean(filepath.Join(claudeDir, "settings.json"))
rel, err := filepath.Rel(claudeDir, settingPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return MinimalClaudeSettings{}
}
info, err := os.Stat(settingPath)
if err != nil || info.Size() > MaxClaudeSettingsBytes {
return MinimalClaudeSettings{}
}
data, err := os.ReadFile(settingPath) // #nosec G304 -- path is fixed under user home and validated to stay within claudeDir
if err != nil {
return MinimalClaudeSettings{}
}
var cfg struct {
Env map[string]any `json:"env"`
Model any `json:"model"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return MinimalClaudeSettings{}
}
out := MinimalClaudeSettings{}
if model, ok := cfg.Model.(string); ok {
out.Model = strings.TrimSpace(model)
}
if len(cfg.Env) == 0 {
return out
}
env := make(map[string]string, len(cfg.Env))
for k, v := range cfg.Env {
s, ok := v.(string)
if !ok {
continue
}
env[k] = s
}
if len(env) == 0 {
return out
}
out.Env = env
return out
}
func LoadMinimalEnvSettings() map[string]string {
settings := LoadMinimalClaudeSettings()
if len(settings.Env) == 0 {
return nil
}
return settings.Env
}
func buildClaudeArgs(cfg *config.Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-p"}
// Default to skip permissions unless CODEAGENT_SKIP_PERMISSIONS=false
if cfg.SkipPermissions || cfg.Yolo || config.EnvFlagDefaultTrue("CODEAGENT_SKIP_PERMISSIONS") {
args = append(args, "--dangerously-skip-permissions")
}
// Prevent infinite recursion: disable all setting sources (user, project, local)
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
args = append(args, "--setting-sources", "")
if model := strings.TrimSpace(cfg.Model); model != "" {
args = append(args, "--model", model)
}
if cfg.Mode == "resume" {
if cfg.SessionID != "" {
// Claude CLI uses -r <session_id> for resume.
args = append(args, "-r", cfg.SessionID)
}
}
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
return args
}

View File

@@ -1,79 +0,0 @@
package backend
import (
"strings"
config "codeagent-wrapper/internal/config"
)
type CodexBackend struct{}
func (CodexBackend) Name() string { return "codex" }
func (CodexBackend) Command() string { return "codex" }
func (CodexBackend) Env(baseURL, apiKey string) map[string]string {
baseURL = strings.TrimSpace(baseURL)
apiKey = strings.TrimSpace(apiKey)
if baseURL == "" && apiKey == "" {
return nil
}
env := make(map[string]string, 2)
if baseURL != "" {
env["OPENAI_BASE_URL"] = baseURL
}
if apiKey != "" {
env["OPENAI_API_KEY"] = apiKey
}
return env
}
func (CodexBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
return BuildCodexArgs(cfg, targetArg)
}
func BuildCodexArgs(cfg *config.Config, targetArg string) []string {
if cfg == nil {
panic("buildCodexArgs: nil config")
}
var resumeSessionID string
isResume := cfg.Mode == "resume"
if isResume {
resumeSessionID = strings.TrimSpace(cfg.SessionID)
if resumeSessionID == "" {
logErrorFn("invalid config: resume mode requires non-empty session_id")
isResume = false
}
}
args := []string{"e"}
// Default to bypass sandbox unless CODEX_BYPASS_SANDBOX=false
if cfg.Yolo || config.EnvFlagDefaultTrue("CODEX_BYPASS_SANDBOX") {
logWarnFn("YOLO mode or CODEX_BYPASS_SANDBOX enabled: running without approval/sandbox protection")
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
}
if model := strings.TrimSpace(cfg.Model); model != "" {
args = append(args, "--model", model)
}
if reasoningEffort := strings.TrimSpace(cfg.ReasoningEffort); reasoningEffort != "" {
args = append(args, "-c", "model_reasoning_effort="+reasoningEffort)
}
args = append(args, "--skip-git-repo-check")
if isResume {
return append(args,
"--json",
"resume",
resumeSessionID,
targetArg,
)
}
return append(args,
"-C", cfg.WorkDir,
"--json",
targetArg,
)
}

View File

@@ -1,54 +0,0 @@
package backend
import (
"reflect"
"testing"
config "codeagent-wrapper/internal/config"
)
func TestBuildCodexArgs_Workdir_OSPaths(t *testing.T) {
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
tests := []struct {
name string
workdir string
}{
{name: "windows drive forward slashes", workdir: "D:/repo/path"},
{name: "windows drive backslashes", workdir: `C:\repo\path`},
{name: "windows UNC", workdir: `\\server\share\repo`},
{name: "unix absolute", workdir: "/home/user/repo"},
{name: "relative", workdir: "./relative/repo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{Mode: "new", WorkDir: tt.workdir}
got := BuildCodexArgs(cfg, "task")
want := []string{"e", "--skip-git-repo-check", "-C", tt.workdir, "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
}
})
}
t.Run("new mode stdin target uses dash", func(t *testing.T) {
cfg := &config.Config{Mode: "new", WorkDir: `C:\repo\path`}
got := BuildCodexArgs(cfg, "-")
want := []string{"e", "--skip-git-repo-check", "-C", `C:\repo\path`, "--json", "-"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
}
})
}
func TestBuildCodexArgs_ResumeMode_OmitsWorkdir(t *testing.T) {
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: `C:\repo\path`}
got := BuildCodexArgs(cfg, "-")
want := []string{"e", "--skip-git-repo-check", "--json", "resume", "sid-123", "-"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
}
}

View File

@@ -1,110 +0,0 @@
package backend
import (
"os"
"path/filepath"
"strings"
config "codeagent-wrapper/internal/config"
)
type GeminiBackend struct{}
func (GeminiBackend) Name() string { return "gemini" }
func (GeminiBackend) Command() string { return "gemini" }
func (GeminiBackend) Env(baseURL, apiKey string) map[string]string {
baseURL = strings.TrimSpace(baseURL)
apiKey = strings.TrimSpace(apiKey)
if baseURL == "" && apiKey == "" {
return nil
}
env := make(map[string]string, 2)
if baseURL != "" {
env["GOOGLE_GEMINI_BASE_URL"] = baseURL
}
if apiKey != "" {
env["GEMINI_API_KEY"] = apiKey
}
return env
}
func (GeminiBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
return buildGeminiArgs(cfg, targetArg)
}
// LoadGeminiEnv loads environment variables from ~/.gemini/.env
// Supports GEMINI_API_KEY, GEMINI_MODEL, GOOGLE_GEMINI_BASE_URL
// Also sets GEMINI_API_KEY_AUTH_MECHANISM=bearer for third-party API compatibility
func LoadGeminiEnv() map[string]string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return nil
}
envDir := filepath.Clean(filepath.Join(home, ".gemini"))
envPath := filepath.Clean(filepath.Join(envDir, ".env"))
rel, err := filepath.Rel(envDir, envPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return nil
}
data, err := os.ReadFile(envPath) // #nosec G304 -- path is fixed under user home and validated to stay within envDir
if err != nil {
return nil
}
env := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
if key != "" && value != "" {
env[key] = value
}
}
// Set bearer auth mechanism for third-party API compatibility
if _, ok := env["GEMINI_API_KEY"]; ok {
if _, hasAuth := env["GEMINI_API_KEY_AUTH_MECHANISM"]; !hasAuth {
env["GEMINI_API_KEY_AUTH_MECHANISM"] = "bearer"
}
}
if len(env) == 0 {
return nil
}
return env
}
func buildGeminiArgs(cfg *config.Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-o", "stream-json", "-y"}
if model := strings.TrimSpace(cfg.Model); model != "" {
args = append(args, "-m", model)
}
if cfg.Mode == "resume" {
if cfg.SessionID != "" {
args = append(args, "-r", cfg.SessionID)
}
}
// Use positional argument instead of deprecated -p flag.
// For stdin mode ("-"), use -p to read from stdin.
if targetArg == "-" {
args = append(args, "-p", targetArg)
} else {
args = append(args, targetArg)
}
return args
}

View File

@@ -1,29 +0,0 @@
package backend
import (
"strings"
config "codeagent-wrapper/internal/config"
)
type OpencodeBackend struct{}
func (OpencodeBackend) Name() string { return "opencode" }
func (OpencodeBackend) Command() string { return "opencode" }
func (OpencodeBackend) Env(baseURL, apiKey string) map[string]string { return nil }
func (OpencodeBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
args := []string{"run"}
if cfg != nil {
if model := strings.TrimSpace(cfg.Model); model != "" {
args = append(args, "-m", model)
}
if cfg.Mode == "resume" && cfg.SessionID != "" {
args = append(args, "-s", cfg.SessionID)
}
}
args = append(args, "--format", "json")
if targetArg != "-" {
args = append(args, targetArg)
}
return args
}

View File

@@ -1,29 +0,0 @@
package backend
import (
"fmt"
"strings"
)
var registry = map[string]Backend{
"codex": CodexBackend{},
"claude": ClaudeBackend{},
"gemini": GeminiBackend{},
"opencode": OpencodeBackend{},
}
// Registry exposes the available backends. Intended for internal inspection/tests.
func Registry() map[string]Backend {
return registry
}
func Select(name string) (Backend, error) {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
key = "codex"
}
if backend, ok := registry[key]; ok {
return backend, nil
}
return nil, fmt.Errorf("unsupported backend %q", name)
}

View File

@@ -1,259 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/goccy/go-json"
)
type BackendConfig struct {
BaseURL string `json:"base_url,omitempty"`
APIKey string `json:"api_key,omitempty"`
}
type AgentModelConfig struct {
Backend string `json:"backend"`
Model string `json:"model"`
PromptFile string `json:"prompt_file,omitempty"`
Description string `json:"description,omitempty"`
Yolo bool `json:"yolo,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
BaseURL string `json:"base_url,omitempty"`
APIKey string `json:"api_key,omitempty"`
}
type ModelsConfig struct {
DefaultBackend string `json:"default_backend"`
DefaultModel string `json:"default_model"`
Agents map[string]AgentModelConfig `json:"agents"`
Backends map[string]BackendConfig `json:"backends,omitempty"`
}
var defaultModelsConfig = ModelsConfig{}
const modelsConfigTildePath = "~/.codeagent/models.json"
const modelsConfigExample = `{
"default_backend": "codex",
"default_model": "gpt-4.1",
"backends": {
"codex": { "api_key": "..." },
"claude": { "api_key": "..." }
},
"agents": {
"develop": {
"backend": "codex",
"model": "gpt-4.1",
"prompt_file": "~/.codeagent/prompts/develop.md",
"reasoning": "high",
"yolo": true
}
}
}`
var (
modelsConfigOnce sync.Once
modelsConfigCached *ModelsConfig
modelsConfigErr error
)
func modelsConfig() (*ModelsConfig, error) {
modelsConfigOnce.Do(func() {
modelsConfigCached, modelsConfigErr = loadModelsConfig()
})
return modelsConfigCached, modelsConfigErr
}
func modelsConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return "", fmt.Errorf("failed to resolve user home directory: %w", err)
}
configDir := filepath.Clean(filepath.Join(home, ".codeagent"))
configPath := filepath.Clean(filepath.Join(configDir, "models.json"))
rel, err := filepath.Rel(configDir, configPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath)
}
return configPath, nil
}
func modelsConfigHint(configPath string) string {
configPath = strings.TrimSpace(configPath)
if configPath == "" {
return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample)
}
return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample)
}
func loadModelsConfig() (*ModelsConfig, error) {
configPath, err := modelsConfigPath()
if err != nil {
return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint(""))
}
data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
}
return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
}
var cfg ModelsConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
}
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
// Normalize backend keys so lookups can be case-insensitive.
if len(cfg.Backends) > 0 {
normalized := make(map[string]BackendConfig, len(cfg.Backends))
for k, v := range cfg.Backends {
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
continue
}
normalized[key] = v
}
if len(normalized) > 0 {
cfg.Backends = normalized
} else {
cfg.Backends = nil
}
}
return &cfg, nil
}
func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
if err := ValidateAgentName(name); err != nil {
return AgentModelConfig{}, false
}
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return AgentModelConfig{}, false
}
absPath := filepath.Join(home, ".codeagent", "agents", name+".md")
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
return AgentModelConfig{}, false
}
return AgentModelConfig{PromptFile: "~/.codeagent/agents/" + name + ".md"}, true
}
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
cfg, err := modelsConfig()
if err != nil || cfg == nil {
return "", ""
}
resolved := resolveBackendConfig(cfg, backendName)
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
}
func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
if cfg == nil || len(cfg.Backends) == 0 {
return BackendConfig{}
}
key := strings.ToLower(strings.TrimSpace(backendName))
if key == "" {
key = strings.ToLower(strings.TrimSpace(cfg.DefaultBackend))
}
if key == "" {
return BackendConfig{}
}
if backend, ok := cfg.Backends[key]; ok {
return backend
}
return BackendConfig{}
}
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
if err := ValidateAgentName(agentName); err != nil {
return "", "", "", "", "", "", false, err
}
cfg, err := modelsConfig()
if err != nil {
return "", "", "", "", "", "", false, err
}
if cfg == nil {
return "", "", "", "", "", "", false, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint(""))
}
if agent, ok := cfg.Agents[agentName]; ok {
backend = strings.TrimSpace(agent.Backend)
if backend == "" {
backend = strings.TrimSpace(cfg.DefaultBackend)
if backend == "" {
configPath, pathErr := modelsConfigPath()
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
}
}
backendCfg := resolveBackendConfig(cfg, backend)
baseURL = strings.TrimSpace(agent.BaseURL)
if baseURL == "" {
baseURL = strings.TrimSpace(backendCfg.BaseURL)
}
apiKey = strings.TrimSpace(agent.APIKey)
if apiKey == "" {
apiKey = strings.TrimSpace(backendCfg.APIKey)
}
model = strings.TrimSpace(agent.Model)
if model == "" {
configPath, pathErr := modelsConfigPath()
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath))
}
return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, nil
}
if dynamic, ok := LoadDynamicAgent(agentName); ok {
backend = strings.TrimSpace(cfg.DefaultBackend)
model = strings.TrimSpace(cfg.DefaultModel)
configPath, pathErr := modelsConfigPath()
if backend == "" || model == "" {
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
}
backendCfg := resolveBackendConfig(cfg, backend)
baseURL = strings.TrimSpace(backendCfg.BaseURL)
apiKey = strings.TrimSpace(backendCfg.APIKey)
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil
}
configPath, pathErr := modelsConfigPath()
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
}
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
return resolveAgentConfig(agentName)
}
func ResetModelsConfigCacheForTest() {
modelsConfigCached = nil
modelsConfigErr = nil
modelsConfigOnce = sync.Once{}
}

View File

@@ -1,262 +0,0 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
_, _, _, _, _, _, _, err := ResolveAgentConfig("develop")
if err == nil {
t.Fatalf("expected error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, modelsConfigTildePath) {
t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg)
}
if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) {
t.Fatalf("error should mention resolved config path, got: %s", msg)
}
if !strings.Contains(msg, "\"agents\"") {
t.Fatalf("error should include example config, got: %s", msg)
}
}
func TestLoadModelsConfig_NoFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
_, err := loadModelsConfig()
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestLoadModelsConfig_WithFile(t *testing.T) {
// Create temp dir and config file
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".codeagent")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
configContent := `{
"default_backend": "claude",
"default_model": "claude-opus-4",
"backends": {
"Claude": {
"base_url": "https://backend.example",
"api_key": "backend-key"
},
"codex": {
"base_url": "https://openai.example",
"api_key": "openai-key"
}
},
"agents": {
"custom-agent": {
"backend": "codex",
"model": "gpt-4o",
"description": "Custom agent",
"base_url": "https://agent.example",
"api_key": "agent-key"
}
}
}`
configPath := filepath.Join(configDir, "models.json")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", tmpDir)
t.Setenv("USERPROFILE", tmpDir)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
cfg, err := loadModelsConfig()
if err != nil {
t.Fatalf("loadModelsConfig: %v", err)
}
if cfg.DefaultBackend != "claude" {
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude")
}
if cfg.DefaultModel != "claude-opus-4" {
t.Errorf("DefaultModel = %q, want %q", cfg.DefaultModel, "claude-opus-4")
}
// Check custom agent
if agent, ok := cfg.Agents["custom-agent"]; !ok {
t.Error("custom-agent not found")
} else {
if agent.Backend != "codex" {
t.Errorf("custom-agent.Backend = %q, want %q", agent.Backend, "codex")
}
if agent.Model != "gpt-4o" {
t.Errorf("custom-agent.Model = %q, want %q", agent.Model, "gpt-4o")
}
}
if _, ok := cfg.Agents["oracle"]; ok {
t.Error("oracle should not be present without explicit config")
}
baseURL, apiKey := ResolveBackendConfig("claude")
if baseURL != "https://backend.example" {
t.Errorf("ResolveBackendConfig(baseURL) = %q, want %q", baseURL, "https://backend.example")
}
if apiKey != "backend-key" {
t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key")
}
backend, model, _, _, agentBaseURL, agentAPIKey, _, err := ResolveAgentConfig("custom-agent")
if err != nil {
t.Fatalf("ResolveAgentConfig(custom-agent): %v", err)
}
if backend != "codex" {
t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex")
}
if model != "gpt-4o" {
t.Errorf("ResolveAgentConfig(model) = %q, want %q", model, "gpt-4o")
}
if agentBaseURL != "https://agent.example" {
t.Errorf("ResolveAgentConfig(baseURL) = %q, want %q", agentBaseURL, "https://agent.example")
}
if agentAPIKey != "agent-key" {
t.Errorf("ResolveAgentConfig(apiKey) = %q, want %q", agentAPIKey, "agent-key")
}
}
func TestResolveAgentConfig_DynamicAgent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
agentDir := filepath.Join(home, ".codeagent", "agents")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(agentDir, "sarsh.md"), []byte("prompt\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"default_backend": "codex",
"default_model": "gpt-test"
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
backend, model, promptFile, _, _, _, _, err := ResolveAgentConfig("sarsh")
if err != nil {
t.Fatalf("ResolveAgentConfig(sarsh): %v", err)
}
if backend != "codex" {
t.Errorf("backend = %q, want %q", backend, "codex")
}
if model != "gpt-test" {
t.Errorf("model = %q, want %q", model, "gpt-test")
}
if promptFile != "~/.codeagent/agents/sarsh.md" {
t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md")
}
}
func TestLoadModelsConfig_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".codeagent")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
// Write invalid JSON
configPath := filepath.Join(configDir, "models.json")
if err := os.WriteFile(configPath, []byte("invalid json {"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", tmpDir)
t.Setenv("USERPROFILE", tmpDir)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
_, err := loadModelsConfig()
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"default_backend": "codex",
"default_model": "gpt-test",
"agents": {
"develop": { "backend": "codex", "model": "gpt-test" }
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "unknown-agent") {
t.Fatalf("error should mention agent name, got: %s", err.Error())
}
}
func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"agents": {
"bad-agent": { "backend": "codex", "model": " " }
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "empty model") {
t.Fatalf("error should mention empty model, got: %s", err.Error())
}
}

View File

@@ -1,102 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
// Config holds CLI configuration.
type Config struct {
Mode string // "new" or "resume"
Task string
SessionID string
WorkDir string
Model string
ReasoningEffort string
ExplicitStdin bool
Timeout int
Backend string
Agent string
PromptFile string
PromptFileExplicit bool
SkipPermissions bool
Yolo bool
MaxParallelWorkers int
}
// EnvFlagEnabled returns true when the environment variable exists and is not
// explicitly set to a falsey value ("0/false/no/off").
func EnvFlagEnabled(key string) bool {
val, ok := os.LookupEnv(key)
if !ok {
return false
}
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}
func ParseBoolFlag(val string, defaultValue bool) bool {
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return defaultValue
}
}
// EnvFlagDefaultTrue returns true unless the env var is explicitly set to
// false/0/no/off.
func EnvFlagDefaultTrue(key string) bool {
val, ok := os.LookupEnv(key)
if !ok {
return true
}
return ParseBoolFlag(val, true)
}
func ValidateAgentName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("agent name is empty")
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '-', r == '_':
default:
return fmt.Errorf("agent name %q contains invalid character %q", name, r)
}
}
return nil
}
const maxParallelWorkersLimit = 100
// ResolveMaxParallelWorkers reads CODEAGENT_MAX_PARALLEL_WORKERS. It returns 0
// for "unlimited".
func ResolveMaxParallelWorkers() int {
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return 0
}
if value > maxParallelWorkersLimit {
return maxParallelWorkersLimit
}
return value
}

View File

@@ -1,47 +0,0 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
// NewViper returns a viper instance configured for CODEAGENT_* environment
// variables and an optional config file.
//
// Search order when configFile is empty:
// - $HOME/.codeagent/config.(yaml|yml|json|toml|...)
func NewViper(configFile string) (*viper.Viper, error) {
v := viper.New()
v.SetEnvPrefix("CODEAGENT")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.AutomaticEnv()
if strings.TrimSpace(configFile) != "" {
v.SetConfigFile(configFile)
if err := v.ReadInConfig(); err != nil {
return nil, err
}
return v, nil
}
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return v, nil
}
v.SetConfigName("config")
v.AddConfigPath(filepath.Join(home, ".codeagent"))
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) {
return v, nil
}
return nil, err
}
return v, nil
}

View File

@@ -1,196 +0,0 @@
package executor
import (
"os"
"path/filepath"
"strings"
"testing"
backend "codeagent-wrapper/internal/backend"
config "codeagent-wrapper/internal/config"
)
// TestEnvInjectionWithAgent tests the full flow of env injection with agent config
func TestEnvInjectionWithAgent(t *testing.T) {
// Setup temp config
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".codeagent")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
// Write test config with agent that has base_url and api_key
configContent := `{
"default_backend": "codex",
"agents": {
"test-agent": {
"backend": "claude",
"model": "test-model",
"base_url": "https://test.api.com",
"api_key": "test-api-key-12345678"
}
}
}`
configPath := filepath.Join(configDir, "models.json")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", tmpDir)
t.Setenv("USERPROFILE", tmpDir)
// Reset config cache
config.ResetModelsConfigCacheForTest()
defer config.ResetModelsConfigCacheForTest()
// Test ResolveAgentConfig
agentBackend, model, _, _, baseURL, apiKey, _, err := config.ResolveAgentConfig("test-agent")
if err != nil {
t.Fatalf("ResolveAgentConfig: %v", err)
}
t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q",
agentBackend, model, baseURL, apiKey)
if agentBackend != "claude" {
t.Errorf("expected backend 'claude', got %q", agentBackend)
}
if baseURL != "https://test.api.com" {
t.Errorf("expected baseURL 'https://test.api.com', got %q", baseURL)
}
if apiKey != "test-api-key-12345678" {
t.Errorf("expected apiKey 'test-api-key-12345678', got %q", apiKey)
}
// Test Backend.Env
b := backend.ClaudeBackend{}
env := b.Env(baseURL, apiKey)
t.Logf("Backend.Env: %v", env)
if env == nil {
t.Fatal("expected non-nil env from Backend.Env")
}
if env["ANTHROPIC_BASE_URL"] != baseURL {
t.Errorf("expected ANTHROPIC_BASE_URL=%q, got %q", baseURL, env["ANTHROPIC_BASE_URL"])
}
if env["ANTHROPIC_API_KEY"] != apiKey {
t.Errorf("expected ANTHROPIC_API_KEY=%q, got %q", apiKey, env["ANTHROPIC_API_KEY"])
}
}
// TestEnvInjectionLogic tests the exact logic used in executor
func TestEnvInjectionLogic(t *testing.T) {
// Setup temp config
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".codeagent")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
configContent := `{
"default_backend": "codex",
"agents": {
"explore": {
"backend": "claude",
"model": "MiniMax-M2.1",
"base_url": "https://api.minimaxi.com/anthropic",
"api_key": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
}
}
}`
configPath := filepath.Join(configDir, "models.json")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", tmpDir)
t.Setenv("USERPROFILE", tmpDir)
config.ResetModelsConfigCacheForTest()
defer config.ResetModelsConfigCacheForTest()
// Simulate the executor logic
cfgBackend := "claude" // This should come from taskSpec.Backend
agentName := "explore"
// Step 1: Get backend config (usually empty for claude without global config)
baseURL, apiKey := config.ResolveBackendConfig(cfgBackend)
t.Logf("Step 1 - ResolveBackendConfig(%q): baseURL=%q, apiKey=%q", cfgBackend, baseURL, apiKey)
// Step 2: If agent specified, get agent config
if agentName != "" {
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName)
if err != nil {
t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err)
}
t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q",
agentName, agentBackend, agentBaseURL, agentAPIKey)
// Step 3: Check if agent backend matches cfg backend
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfgBackend)) {
baseURL, apiKey = agentBaseURL, agentAPIKey
t.Logf("Step 3 - Backend match! Using agent config: baseURL=%q, apiKey=%q", baseURL, apiKey)
} else {
t.Logf("Step 3 - Backend mismatch: agent=%q, cfg=%q", agentBackend, cfgBackend)
}
}
// Step 4: Get env vars from backend
b := backend.ClaudeBackend{}
injected := b.Env(baseURL, apiKey)
t.Logf("Step 4 - Backend.Env: %v", injected)
// Verify
if len(injected) == 0 {
t.Fatal("Expected env vars to be injected, got none")
}
expectedURL := "https://api.minimaxi.com/anthropic"
if injected["ANTHROPIC_BASE_URL"] != expectedURL {
t.Errorf("ANTHROPIC_BASE_URL: expected %q, got %q", expectedURL, injected["ANTHROPIC_BASE_URL"])
}
if _, ok := injected["ANTHROPIC_API_KEY"]; !ok {
t.Error("ANTHROPIC_API_KEY not set")
}
// Step 5: Test masking
for k, v := range injected {
masked := maskSensitiveValue(k, v)
t.Logf("Step 5 - Env log: %s=%s", k, masked)
}
}
// TestTaskSpecBackendPropagation tests that taskSpec.Backend is properly used
func TestTaskSpecBackendPropagation(t *testing.T) {
// Simulate what happens in RunCodexTaskWithContext
taskSpec := TaskSpec{
ID: "test",
Task: "hello",
Backend: "claude",
Agent: "explore",
}
// This is the logic from executor.go lines 889-916
cfg := &config.Config{
Mode: "new",
Task: taskSpec.Task,
Backend: "codex", // default
}
var backend Backend = nil // nil in single mode
commandName := "codex" // default
if backend != nil {
cfg.Backend = backend.Name()
} else if taskSpec.Backend != "" {
cfg.Backend = taskSpec.Backend
} else if commandName != "" {
cfg.Backend = commandName
}
t.Logf("taskSpec.Backend=%q, cfg.Backend=%q", taskSpec.Backend, cfg.Backend)
if cfg.Backend != "claude" {
t.Errorf("expected cfg.Backend='claude', got %q", cfg.Backend)
}
}

View File

@@ -1,333 +0,0 @@
package executor
import (
"strings"
"testing"
backend "codeagent-wrapper/internal/backend"
)
func TestMaskSensitiveValue(t *testing.T) {
tests := []struct {
name string
key string
value string
expected string
}{
{
name: "API_KEY with long value",
key: "ANTHROPIC_API_KEY",
value: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expected: "sk-a****xxxx",
},
{
name: "api_key lowercase",
key: "api_key",
value: "abcdefghijklmnop",
expected: "abcd****mnop",
},
{
name: "AUTH_TOKEN",
key: "AUTH_TOKEN",
value: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
expected: "eyJh****VCJ9",
},
{
name: "SECRET",
key: "MY_SECRET",
value: "super-secret-value-12345",
expected: "supe****2345",
},
{
name: "short key value (8 chars)",
key: "API_KEY",
value: "12345678",
expected: "****",
},
{
name: "very short key value",
key: "API_KEY",
value: "abc",
expected: "****",
},
{
name: "empty key value",
key: "API_KEY",
value: "",
expected: "",
},
{
name: "non-sensitive BASE_URL",
key: "ANTHROPIC_BASE_URL",
value: "https://api.anthropic.com",
expected: "https://api.anthropic.com",
},
{
name: "non-sensitive MODEL",
key: "MODEL",
value: "claude-3-opus",
expected: "claude-3-opus",
},
{
name: "case insensitive - Key",
key: "My_Key",
value: "1234567890abcdef",
expected: "1234****cdef",
},
{
name: "case insensitive - TOKEN",
key: "ACCESS_TOKEN",
value: "access123456789",
expected: "acce****6789",
},
{
name: "partial match - apikey",
key: "MYAPIKEY",
value: "1234567890",
expected: "1234****7890",
},
{
name: "partial match - secretvalue",
key: "SECRETVALUE",
value: "abcdefghij",
expected: "abcd****ghij",
},
{
name: "9 char value (just above threshold)",
key: "API_KEY",
value: "123456789",
expected: "1234****6789",
},
{
name: "exactly 8 char value (at threshold)",
key: "API_KEY",
value: "12345678",
expected: "****",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maskSensitiveValue(tt.key, tt.value)
if result != tt.expected {
t.Errorf("maskSensitiveValue(%q, %q) = %q, want %q", tt.key, tt.value, result, tt.expected)
}
})
}
}
func TestMaskSensitiveValue_NoLeakage(t *testing.T) {
// Ensure sensitive values are never fully exposed
sensitiveKeys := []string{"API_KEY", "api_key", "AUTH_TOKEN", "SECRET", "access_token", "MYAPIKEY"}
longValue := "this-is-a-very-long-secret-value-that-should-be-masked"
for _, key := range sensitiveKeys {
t.Run(key, func(t *testing.T) {
masked := maskSensitiveValue(key, longValue)
// Should not contain the full value
if masked == longValue {
t.Errorf("key %q: value was not masked", key)
}
// Should contain mask marker
if !strings.Contains(masked, "****") {
t.Errorf("key %q: masked value %q does not contain ****", key, masked)
}
// First 4 chars should be visible
if !strings.HasPrefix(masked, longValue[:4]) {
t.Errorf("key %q: masked value should start with first 4 chars", key)
}
// Last 4 chars should be visible
if !strings.HasSuffix(masked, longValue[len(longValue)-4:]) {
t.Errorf("key %q: masked value should end with last 4 chars", key)
}
})
}
}
func TestMaskSensitiveValue_NonSensitivePassthrough(t *testing.T) {
// Non-sensitive keys should pass through unchanged
nonSensitiveKeys := []string{
"ANTHROPIC_BASE_URL",
"BASE_URL",
"MODEL",
"BACKEND",
"WORKDIR",
"HOME",
"PATH",
}
value := "any-value-here-12345"
for _, key := range nonSensitiveKeys {
t.Run(key, func(t *testing.T) {
result := maskSensitiveValue(key, value)
if result != value {
t.Errorf("key %q: expected passthrough but got %q", key, result)
}
})
}
}
// TestClaudeBackendEnv tests that ClaudeBackend.Env returns correct env vars
func TestClaudeBackendEnv(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
expectKeys []string
expectNil bool
}{
{
name: "both base_url and api_key",
baseURL: "https://api.custom.com",
apiKey: "sk-test-key-12345",
expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"},
},
{
name: "only base_url",
baseURL: "https://api.custom.com",
apiKey: "",
expectKeys: []string{"ANTHROPIC_BASE_URL"},
},
{
name: "only api_key",
baseURL: "",
apiKey: "sk-test-key-12345",
expectKeys: []string{"ANTHROPIC_API_KEY"},
},
{
name: "both empty",
baseURL: "",
apiKey: "",
expectNil: true,
},
{
name: "whitespace only",
baseURL: " ",
apiKey: " ",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := backend.ClaudeBackend{}
env := b.Env(tt.baseURL, tt.apiKey)
if tt.expectNil {
if env != nil {
t.Errorf("expected nil env, got %v", env)
}
return
}
if env == nil {
t.Fatal("expected non-nil env")
}
for _, key := range tt.expectKeys {
if _, ok := env[key]; !ok {
t.Errorf("expected key %q in env", key)
}
}
// Verify values are correct
if tt.baseURL != "" && strings.TrimSpace(tt.baseURL) != "" {
if env["ANTHROPIC_BASE_URL"] != strings.TrimSpace(tt.baseURL) {
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", env["ANTHROPIC_BASE_URL"], strings.TrimSpace(tt.baseURL))
}
}
if tt.apiKey != "" && strings.TrimSpace(tt.apiKey) != "" {
if env["ANTHROPIC_API_KEY"] != strings.TrimSpace(tt.apiKey) {
t.Errorf("ANTHROPIC_API_KEY = %q, want %q", env["ANTHROPIC_API_KEY"], strings.TrimSpace(tt.apiKey))
}
}
})
}
}
// TestEnvLoggingIntegration tests that env vars are properly masked in logs
func TestEnvLoggingIntegration(t *testing.T) {
b := backend.ClaudeBackend{}
baseURL := "https://api.minimaxi.com/anthropic"
apiKey := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longjwttoken"
env := b.Env(baseURL, apiKey)
if env == nil {
t.Fatal("expected non-nil env")
}
// Verify that when we log these values, sensitive ones are masked
for k, v := range env {
masked := maskSensitiveValue(k, v)
if k == "ANTHROPIC_BASE_URL" {
// URL should not be masked
if masked != v {
t.Errorf("BASE_URL should not be masked: got %q, want %q", masked, v)
}
}
if k == "ANTHROPIC_API_KEY" {
// API key should be masked
if masked == v {
t.Errorf("API_KEY should be masked, but got original value")
}
if !strings.Contains(masked, "****") {
t.Errorf("masked API_KEY should contain ****: got %q", masked)
}
// Should still show first 4 and last 4 chars
if !strings.HasPrefix(masked, v[:4]) {
t.Errorf("masked value should start with first 4 chars of original")
}
if !strings.HasSuffix(masked, v[len(v)-4:]) {
t.Errorf("masked value should end with last 4 chars of original")
}
}
}
}
// TestGeminiBackendEnv tests GeminiBackend.Env for comparison
func TestGeminiBackendEnv(t *testing.T) {
b := backend.GeminiBackend{}
env := b.Env("https://custom.api", "gemini-api-key-12345")
if env == nil {
t.Fatal("expected non-nil env")
}
// Check that GEMINI env vars are set
if _, ok := env["GOOGLE_GEMINI_BASE_URL"]; !ok {
t.Error("expected GOOGLE_GEMINI_BASE_URL in env")
}
if _, ok := env["GEMINI_API_KEY"]; !ok {
t.Error("expected GEMINI_API_KEY in env")
}
// Verify masking works for Gemini keys too
for k, v := range env {
masked := maskSensitiveValue(k, v)
if strings.Contains(strings.ToLower(k), "key") {
if masked == v && len(v) > 0 {
t.Errorf("key %q should be masked", k)
}
}
}
}
// TestCodexBackendEnv tests CodexBackend.Env
func TestCodexBackendEnv(t *testing.T) {
b := backend.CodexBackend{}
env := b.Env("https://custom.api", "codex-api-key-12345")
if env == nil {
t.Fatal("expected non-nil env for codex")
}
// Check for OPENAI env vars
if _, ok := env["OPENAI_BASE_URL"]; !ok {
t.Error("expected OPENAI_BASE_URL in env")
}
if _, ok := env["OPENAI_API_KEY"]; !ok {
t.Error("expected OPENAI_API_KEY in env")
}
}

View File

@@ -1,130 +0,0 @@
package executor
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
config "codeagent-wrapper/internal/config"
)
type fakeCmd struct {
env map[string]string
}
func (f *fakeCmd) Start() error { return nil }
func (f *fakeCmd) Wait() error { return nil }
func (f *fakeCmd) StdoutPipe() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}
func (f *fakeCmd) StderrPipe() (io.ReadCloser, error) {
return nil, errors.New("fake stderr pipe error")
}
func (f *fakeCmd) StdinPipe() (io.WriteCloser, error) {
return nil, errors.New("fake stdin pipe error")
}
func (f *fakeCmd) SetStderr(io.Writer) {}
func (f *fakeCmd) SetDir(string) {}
func (f *fakeCmd) SetEnv(env map[string]string) {
if len(env) == 0 {
return
}
if f.env == nil {
f.env = make(map[string]string, len(env))
}
for k, v := range env {
f.env[k] = v
}
}
func (f *fakeCmd) Process() processHandle { return nil }
func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
// Arrange ~/.codeagent/models.json via HOME override.
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
const baseURL = "https://api.minimaxi.com/anthropic"
const apiKey = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
models := `{
"agents": {
"explore": {
"backend": "claude",
"model": "MiniMax-M2.1",
"base_url": "` + baseURL + `",
"api_key": "` + apiKey + `"
}
}
}`
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(models), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", tmpDir)
t.Setenv("USERPROFILE", tmpDir)
config.ResetModelsConfigCacheForTest()
defer config.ResetModelsConfigCacheForTest()
// Capture stderr (RunCodexTaskWithContext prints env injection lines there).
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
oldStderr := os.Stderr
os.Stderr = w
defer func() { os.Stderr = oldStderr }()
readDone := make(chan string, 1)
go func() {
defer r.Close()
b, _ := io.ReadAll(r)
readDone <- string(b)
}()
var cmd *fakeCmd
restoreRunner := SetNewCommandRunner(func(ctx context.Context, name string, args ...string) CommandRunner {
cmd = &fakeCmd{}
return cmd
})
defer restoreRunner()
// Act: force an early return right after env injection by making StderrPipe fail.
_ = RunCodexTaskWithContext(
context.Background(),
TaskSpec{Task: "hi", WorkDir: ".", Backend: "claude", Agent: "explore"},
nil,
"claude",
nil,
nil,
false,
false,
1,
)
_ = w.Close()
got := <-readDone
// Assert: env was injected into the command and logging is present with masking.
if cmd == nil || cmd.env == nil {
t.Fatalf("expected cmd env to be set, got cmd=%v env=%v", cmd, nil)
}
if cmd.env["ANTHROPIC_BASE_URL"] != baseURL {
t.Fatalf("ANTHROPIC_BASE_URL=%q, want %q", cmd.env["ANTHROPIC_BASE_URL"], baseURL)
}
if cmd.env["ANTHROPIC_API_KEY"] != apiKey {
t.Fatalf("ANTHROPIC_API_KEY=%q, want %q", cmd.env["ANTHROPIC_API_KEY"], apiKey)
}
if !strings.Contains(got, "Env: ANTHROPIC_BASE_URL="+baseURL) {
t.Fatalf("stderr missing base URL env log; stderr=%q", got)
}
if !strings.Contains(got, "Env: ANTHROPIC_API_KEY=eyJh****test") {
t.Fatalf("stderr missing masked API key log; stderr=%q", got)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +0,0 @@
package executor
import (
"bytes"
"fmt"
"strings"
config "codeagent-wrapper/internal/config"
)
func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, fmt.Errorf("parallel config is empty")
}
tasks := strings.Split(string(trimmed), "---TASK---")
var cfg ParallelConfig
seen := make(map[string]struct{})
taskIndex := 0
for _, taskBlock := range tasks {
taskBlock = strings.TrimSpace(taskBlock)
if taskBlock == "" {
continue
}
taskIndex++
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
}
meta := strings.TrimSpace(parts[0])
content := strings.TrimSpace(parts[1])
task := TaskSpec{WorkDir: defaultWorkdir}
agentSpecified := false
for _, line := range strings.Split(meta, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "id":
task.ID = value
case "workdir":
// Validate workdir: "-" is not a valid directory
if value == "-" {
return nil, fmt.Errorf("task block #%d has invalid workdir: '-' is not a valid directory path", taskIndex)
}
task.WorkDir = value
case "session_id":
task.SessionID = value
task.Mode = "resume"
case "backend":
task.Backend = value
case "model":
task.Model = value
case "reasoning_effort":
task.ReasoningEffort = value
case "agent":
agentSpecified = true
task.Agent = value
case "skip_permissions", "skip-permissions":
if value == "" {
task.SkipPermissions = true
continue
}
task.SkipPermissions = config.ParseBoolFlag(value, false)
case "dependencies":
for _, dep := range strings.Split(value, ",") {
dep = strings.TrimSpace(dep)
if dep != "" {
task.Dependencies = append(task.Dependencies, dep)
}
}
}
}
if task.Mode == "" {
task.Mode = "new"
}
if agentSpecified {
if strings.TrimSpace(task.Agent) == "" {
return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex)
}
if err := config.ValidateAgentName(task.Agent); err != nil {
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
}
backend, model, promptFile, reasoning, _, _, _, err := config.ResolveAgentConfig(task.Agent)
if err != nil {
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
}
if task.Backend == "" {
task.Backend = backend
}
if task.Model == "" {
task.Model = model
}
if task.ReasoningEffort == "" {
task.ReasoningEffort = reasoning
}
task.PromptFile = promptFile
}
if task.ID == "" {
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
}
if content == "" {
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
}
if task.Mode == "resume" && strings.TrimSpace(task.SessionID) == "" {
return nil, fmt.Errorf("task block #%d (%q) has empty session_id", taskIndex, task.ID)
}
if _, exists := seen[task.ID]; exists {
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
}
task.Task = content
cfg.Tasks = append(cfg.Tasks, task)
seen[task.ID] = struct{}{}
}
if len(cfg.Tasks) == 0 {
return nil, fmt.Errorf("no tasks found")
}
return &cfg, nil
}

View File

@@ -1,130 +0,0 @@
package executor
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func ReadAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error) {
raw := strings.TrimSpace(path)
if raw == "" {
return "", nil
}
expanded := raw
if raw == "~" || strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, "~\\") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if raw == "~" {
expanded = home
} else {
expanded = home + raw[1:]
}
}
absPath, err := filepath.Abs(expanded)
if err != nil {
return "", err
}
absPath = filepath.Clean(absPath)
home, err := os.UserHomeDir()
if err != nil {
if !allowOutsideClaudeDir {
return "", err
}
logWarn(fmt.Sprintf("Failed to resolve home directory for prompt file validation: %v; proceeding without restriction", err))
} else {
allowedDirs := []string{
filepath.Clean(filepath.Join(home, ".claude")),
filepath.Clean(filepath.Join(home, ".codeagent", "agents")),
}
for i := range allowedDirs {
allowedAbs, err := filepath.Abs(allowedDirs[i])
if err == nil {
allowedDirs[i] = filepath.Clean(allowedAbs)
}
}
isWithinDir := func(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
if rel == ".." {
return false
}
prefix := ".." + string(os.PathSeparator)
return !strings.HasPrefix(rel, prefix)
}
if !allowOutsideClaudeDir {
withinAllowed := false
for _, dir := range allowedDirs {
if isWithinDir(absPath, dir) {
withinAllowed = true
break
}
}
if !withinAllowed {
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
}
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
if errPath == nil {
resolvedPath = filepath.Clean(resolvedPath)
resolvedAllowed := make([]string, 0, len(allowedDirs))
for _, dir := range allowedDirs {
resolvedBase, errBase := filepath.EvalSymlinks(dir)
if errBase != nil {
continue
}
resolvedAllowed = append(resolvedAllowed, filepath.Clean(resolvedBase))
}
if len(resolvedAllowed) > 0 {
withinResolved := false
for _, dir := range resolvedAllowed {
if isWithinDir(resolvedPath, dir) {
withinResolved = true
break
}
}
if !withinResolved {
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s) (resolved): %s", strings.Join(resolvedAllowed, ", "), resolvedPath))
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
}
}
}
} else {
withinAllowed := false
for _, dir := range allowedDirs {
if isWithinDir(absPath, dir) {
withinAllowed = true
break
}
}
if !withinAllowed {
logWarn(fmt.Sprintf("Reading prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
}
}
}
data, err := os.ReadFile(absPath)
if err != nil {
return "", err
}
return strings.TrimRight(string(data), "\r\n"), nil
}
func WrapTaskWithAgentPrompt(prompt string, task string) string {
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
}

View File

@@ -1,186 +0,0 @@
package executor
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWrapTaskWithAgentPrompt(t *testing.T) {
got := WrapTaskWithAgentPrompt("P", "do")
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
if got != want {
t.Fatalf("wrapTaskWithAgentPrompt mismatch:\n got=%q\nwant=%q", got, want)
}
}
func TestReadAgentPromptFile_EmptyPath(t *testing.T) {
for _, allowOutside := range []bool{false, true} {
got, err := ReadAgentPromptFile(" ", allowOutside)
if err != nil {
t.Fatalf("unexpected error (allowOutside=%v): %v", allowOutside, err)
}
if got != "" {
t.Fatalf("expected empty result (allowOutside=%v), got %q", allowOutside, got)
}
}
}
func TestReadAgentPromptFile_ExplicitAbsolutePath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "prompt.md")
if err := os.WriteFile(path, []byte("LINE1\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := ReadAgentPromptFile(path, true)
if err != nil {
t.Fatalf("readAgentPromptFile error: %v", err)
}
if got != "LINE1" {
t.Fatalf("got %q, want %q", got, "LINE1")
}
}
func TestReadAgentPromptFile_ExplicitTildeExpansion(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
path := filepath.Join(home, "prompt.md")
if err := os.WriteFile(path, []byte("P\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := ReadAgentPromptFile("~/prompt.md", true)
if err != nil {
t.Fatalf("readAgentPromptFile error: %v", err)
}
if got != "P" {
t.Fatalf("got %q, want %q", got, "P")
}
}
func TestReadAgentPromptFile_RestrictedAllowsClaudeDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
claudeDir := filepath.Join(home, ".claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(claudeDir, "prompt.md")
if err := os.WriteFile(path, []byte("OK\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := ReadAgentPromptFile("~/.claude/prompt.md", false)
if err != nil {
t.Fatalf("readAgentPromptFile error: %v", err)
}
if got != "OK" {
t.Fatalf("got %q, want %q", got, "OK")
}
}
func TestReadAgentPromptFile_RestrictedAllowsCodeagentAgentsDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agentDir := filepath.Join(home, ".codeagent", "agents")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(agentDir, "sarsh.md")
if err := os.WriteFile(path, []byte("OK\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := ReadAgentPromptFile("~/.codeagent/agents/sarsh.md", false)
if err != nil {
t.Fatalf("readAgentPromptFile error: %v", err)
}
if got != "OK" {
t.Fatalf("got %q, want %q", got, "OK")
}
}
func TestReadAgentPromptFile_RestrictedRejectsOutsideClaudeDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
path := filepath.Join(home, "prompt.md")
if err := os.WriteFile(path, []byte("NO\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := ReadAgentPromptFile("~/prompt.md", false); err == nil {
t.Fatalf("expected error for prompt file outside ~/.claude, got nil")
}
}
func TestReadAgentPromptFile_RestrictedRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
path := filepath.Join(home, "secret.md")
if err := os.WriteFile(path, []byte("SECRET\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := ReadAgentPromptFile("~/.claude/../secret.md", false); err == nil {
t.Fatalf("expected traversal to be rejected, got nil")
}
}
func TestReadAgentPromptFile_NotFound(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
claudeDir := filepath.Join(home, ".claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
_, err := ReadAgentPromptFile("~/.claude/missing.md", false)
if err == nil || !os.IsNotExist(err) {
t.Fatalf("expected not-exist error, got %v", err)
}
}
func TestReadAgentPromptFile_PermissionDenied(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod-based permission test is not reliable on Windows")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
claudeDir := filepath.Join(home, ".claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(claudeDir, "private.md")
if err := os.WriteFile(path, []byte("PRIVATE\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("Chmod: %v", err)
}
_, err := ReadAgentPromptFile("~/.claude/private.md", false)
if err == nil {
t.Fatalf("expected permission error, got nil")
}
if !os.IsPermission(err) && !strings.Contains(strings.ToLower(err.Error()), "permission") {
t.Fatalf("expected permission denied, got: %v", err)
}
}

View File

@@ -1,104 +0,0 @@
package executor
import "strings"
// extractCoverageGap extracts what's missing from coverage reports.
func extractCoverageGap(message string) string {
if message == "" {
return ""
}
lower := strings.ToLower(message)
lines := strings.Split(message, "\n")
for _, line := range lines {
lineLower := strings.ToLower(line)
line = strings.TrimSpace(line)
if strings.Contains(lineLower, "uncovered") ||
strings.Contains(lineLower, "not covered") ||
strings.Contains(lineLower, "missing coverage") ||
strings.Contains(lineLower, "lines not covered") {
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
if strings.Contains(lineLower, "branch") && strings.Contains(lineLower, "not taken") {
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
}
if strings.Contains(lower, "function") && strings.Contains(lower, "0%") {
for _, line := range lines {
if strings.Contains(strings.ToLower(line), "0%") && strings.Contains(line, "function") {
line = strings.TrimSpace(line)
if len(line) > 100 {
return line[:97] + "..."
}
return line
}
}
}
return ""
}
// extractErrorDetail extracts meaningful error context from task output.
func extractErrorDetail(message string, maxLen int) string {
if message == "" || maxLen <= 0 {
return ""
}
lines := strings.Split(message, "\n")
var errorLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.HasPrefix(line, "at ") && strings.Contains(line, "(") {
if len(errorLines) > 0 && strings.HasPrefix(strings.ToLower(errorLines[len(errorLines)-1]), "at ") {
continue
}
}
if strings.Contains(lower, "error") ||
strings.Contains(lower, "fail") ||
strings.Contains(lower, "exception") ||
strings.Contains(lower, "assert") ||
strings.Contains(lower, "expected") ||
strings.Contains(lower, "timeout") ||
strings.Contains(lower, "not found") ||
strings.Contains(lower, "cannot") ||
strings.Contains(lower, "undefined") ||
strings.HasPrefix(line, "FAIL") ||
strings.HasPrefix(line, "●") {
errorLines = append(errorLines, line)
}
}
if len(errorLines) == 0 {
start := len(lines) - 5
if start < 0 {
start = 0
}
for _, line := range lines[start:] {
line = strings.TrimSpace(line)
if line != "" {
errorLines = append(errorLines, line)
}
}
}
result := strings.Join(errorLines, " | ")
return safeTruncate(result, maxLen)
}

View File

@@ -1,16 +0,0 @@
//go:build unix || darwin || linux
// +build unix darwin linux
package executor
import (
"syscall"
)
// sendTermSignal sends SIGTERM for graceful shutdown on Unix.
func sendTermSignal(proc processHandle) error {
if proc == nil {
return nil
}
return proc.Signal(syscall.SIGTERM)
}

View File

@@ -1,87 +0,0 @@
//go:build windows
// +build windows
package executor
import (
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// sendTermSignal on Windows directly kills the process.
// SIGTERM is not supported on Windows.
func sendTermSignal(proc processHandle) error {
if proc == nil {
return nil
}
pid := proc.Pid()
if pid > 0 {
// Kill the whole process tree to avoid leaving inheriting child processes around.
// This also helps prevent exec.Cmd.Wait() from blocking on stderr/stdout pipes held open by children.
taskkill := "taskkill"
if root := os.Getenv("SystemRoot"); root != "" {
taskkill = filepath.Join(root, "System32", "taskkill.exe")
}
cmd := exec.Command(taskkill, "/PID", strconv.Itoa(pid), "/T", "/F")
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Run(); err == nil {
return nil
}
if err := killProcessTree(pid); err == nil {
return nil
}
}
return proc.Kill()
}
func killProcessTree(pid int) error {
if pid <= 0 {
return nil
}
wmic := "wmic"
if root := os.Getenv("SystemRoot"); root != "" {
wmic = filepath.Join(root, "System32", "wbem", "WMIC.exe")
}
queryChildren := "(ParentProcessId=" + strconv.Itoa(pid) + ")"
listCmd := exec.Command(wmic, "process", "where", queryChildren, "get", "ProcessId", "/VALUE")
listCmd.Stderr = io.Discard
out, err := listCmd.Output()
if err == nil {
for _, childPID := range parseWMICPIDs(out) {
_ = killProcessTree(childPID)
}
}
querySelf := "(ProcessId=" + strconv.Itoa(pid) + ")"
termCmd := exec.Command(wmic, "process", "where", querySelf, "call", "terminate")
termCmd.Stdout = io.Discard
termCmd.Stderr = io.Discard
if termErr := termCmd.Run(); termErr != nil && err == nil {
err = termErr
}
return err
}
func parseWMICPIDs(out []byte) []int {
const prefix = "ProcessId="
var pids []int
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, prefix) {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix)))
if err != nil || n <= 0 {
continue
}
pids = append(pids, n)
}
return pids
}

View File

@@ -1,15 +0,0 @@
package executor
import "strings"
const stdinSpecialChars = "\n\\\"'`$"
func ShouldUseStdin(taskText string, piped bool) bool {
if piped {
return true
}
if len(taskText) > 800 {
return true
}
return strings.ContainsAny(taskText, stdinSpecialChars)
}

View File

@@ -1,46 +0,0 @@
package executor
import "context"
// ParallelConfig defines the JSON schema for parallel execution.
type ParallelConfig struct {
Tasks []TaskSpec `json:"tasks"`
GlobalBackend string `json:"backend,omitempty"`
}
// TaskSpec describes an individual task entry in the parallel config.
type TaskSpec struct {
ID string `json:"id"`
Task string `json:"task"`
WorkDir string `json:"workdir,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SessionID string `json:"session_id,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Agent string `json:"agent,omitempty"`
PromptFile string `json:"prompt_file,omitempty"`
SkipPermissions bool `json:"skip_permissions,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`
}
// TaskResult captures the execution outcome of a task.
type TaskResult struct {
TaskID string `json:"task_id"`
ExitCode int `json:"exit_code"`
Message string `json:"message"`
SessionID string `json:"session_id"`
Error string `json:"error"`
LogPath string `json:"log_path"`
// Structured report fields
Coverage string `json:"coverage,omitempty"` // extracted coverage percentage (e.g., "92%")
CoverageNum float64 `json:"coverage_num,omitempty"` // numeric coverage for comparison
CoverageTarget float64 `json:"coverage_target,omitempty"` // target coverage (default 90)
FilesChanged []string `json:"files_changed,omitempty"` // list of changed files
KeyOutput string `json:"key_output,omitempty"` // brief summary of what was done
TestsPassed int `json:"tests_passed,omitempty"` // number of tests passed
TestsFailed int `json:"tests_failed,omitempty"` // number of tests failed
sharedLog bool
}

View File

@@ -1,57 +0,0 @@
package executor
import (
"context"
"os/exec"
backend "codeagent-wrapper/internal/backend"
)
type CommandRunner = commandRunner
type ProcessHandle = processHandle
func SetForceKillDelay(seconds int32) (restore func()) {
prev := forceKillDelay.Load()
forceKillDelay.Store(seconds)
return func() { forceKillDelay.Store(prev) }
}
func SetSelectBackendFn(fn func(string) (Backend, error)) (restore func()) {
prev := selectBackendFn
if fn != nil {
selectBackendFn = fn
} else {
selectBackendFn = backend.Select
}
return func() { selectBackendFn = prev }
}
func SetCommandContextFn(fn func(context.Context, string, ...string) *exec.Cmd) (restore func()) {
prev := commandContext
if fn != nil {
commandContext = fn
} else {
commandContext = exec.CommandContext
}
return func() { commandContext = prev }
}
func SetNewCommandRunner(fn func(context.Context, string, ...string) CommandRunner) (restore func()) {
prev := newCommandRunner
if fn != nil {
newCommandRunner = fn
} else {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &realCmd{cmd: commandContext(ctx, name, args...)}
}
}
return func() { newCommandRunner = prev }
}
func WithTaskLogger(ctx context.Context, logger *Logger) context.Context {
return withTaskLogger(ctx, logger)
}
func TaskLoggerFromContext(ctx context.Context) *Logger {
return taskLoggerFromContext(ctx)
}

View File

@@ -1,59 +0,0 @@
package logger
import "sync/atomic"
var loggerPtr atomic.Pointer[Logger]
func setLogger(l *Logger) {
loggerPtr.Store(l)
}
func closeLogger() error {
logger := loggerPtr.Swap(nil)
if logger == nil {
return nil
}
return logger.Close()
}
func activeLogger() *Logger {
return loggerPtr.Load()
}
func logDebug(msg string) {
if logger := activeLogger(); logger != nil {
logger.Debug(msg)
}
}
func logInfo(msg string) {
if logger := activeLogger(); logger != nil {
logger.Info(msg)
}
}
func logWarn(msg string) {
if logger := activeLogger(); logger != nil {
logger.Warn(msg)
}
}
func logError(msg string) {
if logger := activeLogger(); logger != nil {
logger.Error(msg)
}
}
func SetLogger(l *Logger) { setLogger(l) }
func CloseLogger() error { return closeLogger() }
func ActiveLogger() *Logger { return activeLogger() }
func LogInfo(msg string) { logInfo(msg) }
func LogDebug(msg string) { logDebug(msg) }
func LogWarn(msg string) { logWarn(msg) }
func LogError(msg string) { logError(msg) }

View File

@@ -1,63 +0,0 @@
package logger
import (
"errors"
"math"
"time"
"github.com/shirou/gopsutil/v3/process"
)
func pidToInt32(pid int) (int32, bool) {
if pid <= 0 || pid > math.MaxInt32 {
return 0, false
}
return int32(pid), true
}
// isProcessRunning reports whether a process with the given pid appears to be running.
// It is intentionally conservative on errors to avoid deleting logs for live processes.
func isProcessRunning(pid int) bool {
pid32, ok := pidToInt32(pid)
if !ok {
return false
}
exists, err := process.PidExists(pid32)
if err == nil {
return exists
}
// If we can positively identify that the process doesn't exist, report false.
if errors.Is(err, process.ErrorProcessNotRunning) {
return false
}
// Permission/inspection failures: assume it's running to be safe.
return true
}
// getProcessStartTime returns the start time of a process.
// Returns zero time if the start time cannot be determined.
func getProcessStartTime(pid int) time.Time {
pid32, ok := pidToInt32(pid)
if !ok {
return time.Time{}
}
proc, err := process.NewProcess(pid32)
if err != nil {
return time.Time{}
}
ms, err := proc.CreateTime()
if err != nil || ms <= 0 {
return time.Time{}
}
return time.UnixMilli(ms)
}
func IsProcessRunning(pid int) bool { return isProcessRunning(pid) }
func GetProcessStartTime(pid int) time.Time { return getProcessStartTime(pid) }

View File

@@ -1,112 +0,0 @@
package logger
import (
"math"
"os"
"os/exec"
"runtime"
"strconv"
"testing"
"time"
)
func TestIsProcessRunning(t *testing.T) {
t.Run("boundary values", func(t *testing.T) {
if isProcessRunning(0) {
t.Fatalf("pid 0 should never be treated as running")
}
if isProcessRunning(-1) {
t.Fatalf("negative pid should never be treated as running")
}
})
t.Run("pid out of int32 range", func(t *testing.T) {
if strconv.IntSize <= 32 {
t.Skip("int cannot represent values above int32 range")
}
pid := int(int64(math.MaxInt32) + 1)
if isProcessRunning(pid) {
t.Fatalf("expected pid %d (out of int32 range) to be treated as not running", pid)
}
})
t.Run("current process", func(t *testing.T) {
if !isProcessRunning(os.Getpid()) {
t.Fatalf("expected current process (pid=%d) to be running", os.Getpid())
}
})
t.Run("fake pid", func(t *testing.T) {
const nonexistentPID = 1 << 30
if isProcessRunning(nonexistentPID) {
t.Fatalf("expected pid %d to be reported as not running", nonexistentPID)
}
})
t.Run("terminated process", func(t *testing.T) {
pid := exitedProcessPID(t)
if isProcessRunning(pid) {
t.Fatalf("expected exited child process (pid=%d) to be reported as not running", pid)
}
})
}
func exitedProcessPID(t *testing.T) int {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", "exit 0")
} else {
cmd = exec.Command("sh", "-c", "exit 0")
}
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start helper process: %v", err)
}
pid := cmd.Process.Pid
if err := cmd.Wait(); err != nil {
t.Fatalf("helper process did not exit cleanly: %v", err)
}
time.Sleep(50 * time.Millisecond)
return pid
}
func TestGetProcessStartTimeReadsProcStat(t *testing.T) {
start := getProcessStartTime(os.Getpid())
if start.IsZero() {
t.Fatalf("expected non-zero start time for current process")
}
if start.After(time.Now().Add(5 * time.Second)) {
t.Fatalf("start time is unexpectedly in the future: %v", start)
}
}
func TestGetProcessStartTimeInvalidData(t *testing.T) {
if !getProcessStartTime(0).IsZero() {
t.Fatalf("expected zero time for pid 0")
}
if !getProcessStartTime(-1).IsZero() {
t.Fatalf("expected zero time for negative pid")
}
if !getProcessStartTime(1 << 30).IsZero() {
t.Fatalf("expected zero time for non-existent pid")
}
if strconv.IntSize > 32 {
pid := int(int64(math.MaxInt32) + 1)
if !getProcessStartTime(pid).IsZero() {
t.Fatalf("expected zero time for pid %d (out of int32 range)", pid)
}
}
}
func TestGetBootTimeParsesBtime(t *testing.T) {
t.Skip("legacy boot-time probing removed; start time now uses gopsutil")
}
func TestGetBootTimeInvalidData(t *testing.T) {
t.Skip("legacy boot-time probing removed; start time now uses gopsutil")
}

View File

@@ -1,67 +0,0 @@
package logger
import (
"os"
"path/filepath"
"time"
)
func SetProcessRunningCheck(fn func(int) bool) (restore func()) {
prev := processRunningCheck
if fn != nil {
processRunningCheck = fn
} else {
processRunningCheck = isProcessRunning
}
return func() { processRunningCheck = prev }
}
func SetProcessStartTimeFn(fn func(int) time.Time) (restore func()) {
prev := processStartTimeFn
if fn != nil {
processStartTimeFn = fn
} else {
processStartTimeFn = getProcessStartTime
}
return func() { processStartTimeFn = prev }
}
func SetRemoveLogFileFn(fn func(string) error) (restore func()) {
prev := removeLogFileFn
if fn != nil {
removeLogFileFn = fn
} else {
removeLogFileFn = os.Remove
}
return func() { removeLogFileFn = prev }
}
func SetGlobLogFilesFn(fn func(string) ([]string, error)) (restore func()) {
prev := globLogFiles
if fn != nil {
globLogFiles = fn
} else {
globLogFiles = filepath.Glob
}
return func() { globLogFiles = prev }
}
func SetFileStatFn(fn func(string) (os.FileInfo, error)) (restore func()) {
prev := fileStatFn
if fn != nil {
fileStatFn = fn
} else {
fileStatFn = os.Lstat
}
return func() { fileStatFn = prev }
}
func SetEvalSymlinksFn(fn func(string) (string, error)) (restore func()) {
prev := evalSymlinksFn
if fn != nil {
evalSymlinksFn = fn
} else {
evalSymlinksFn = filepath.EvalSymlinks
}
return func() { evalSymlinksFn = prev }
}

View File

@@ -1,13 +0,0 @@
package logger
// WrapperName is the fixed name for this tool.
const WrapperName = "codeagent-wrapper"
// CurrentWrapperName returns the wrapper name (always "codeagent-wrapper").
func CurrentWrapperName() string { return WrapperName }
// LogPrefixes returns the log file name prefixes to look for.
func LogPrefixes() []string { return []string{WrapperName} }
// PrimaryLogPrefix returns the preferred filename prefix for log files.
func PrimaryLogPrefix() string { return WrapperName }

View File

@@ -1,74 +0,0 @@
package parser
import "github.com/goccy/go-json"
// JSONEvent represents a Codex JSON output event.
type JSONEvent struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Item *EventItem `json:"item,omitempty"`
}
// EventItem represents the item field in a JSON event.
type EventItem struct {
Type string `json:"type"`
Text interface{} `json:"text"`
}
// ClaudeEvent for Claude stream-json format.
type ClaudeEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Result string `json:"result,omitempty"`
}
// GeminiEvent for Gemini stream-json format.
type GeminiEvent struct {
Type string `json:"type"`
SessionID string `json:"session_id,omitempty"`
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta bool `json:"delta,omitempty"`
Status string `json:"status,omitempty"`
}
// UnifiedEvent combines all backend event formats into a single structure
// to avoid multiple JSON unmarshal operations per event.
type UnifiedEvent struct {
// Common fields
Type string `json:"type"`
// Codex-specific fields
ThreadID string `json:"thread_id,omitempty"`
Item json.RawMessage `json:"item,omitempty"` // Lazy parse
// Claude-specific fields
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Result string `json:"result,omitempty"`
// Gemini-specific fields
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta *bool `json:"delta,omitempty"`
Status string `json:"status,omitempty"`
// Opencode-specific fields (camelCase sessionID)
OpencodeSessionID string `json:"sessionID,omitempty"`
Part json.RawMessage `json:"part,omitempty"`
}
// OpencodePart represents the part field in opencode events.
type OpencodePart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Reason string `json:"reason,omitempty"`
SessionID string `json:"sessionID,omitempty"`
}
// ItemContent represents the parsed item.text field for Codex events.
type ItemContent struct {
Type string `json:"type"`
Text interface{} `json:"text"`
}

View File

@@ -1,50 +0,0 @@
package parser
import (
"strings"
"testing"
)
func TestParseJSONStream_Opencode(t *testing.T) {
input := `{"type":"step_start","timestamp":1768187730683,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339afa001NTqoJ2NS8x91zP","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-start","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47"}}
{"type":"text","timestamp":1768187744432,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339cb5001QDd0Lh0PzFZpa3","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"text","text":"Hello from opencode"}}
{"type":"step_finish","timestamp":1768187744471,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb033d0af0019VRZzpO2OVW1na","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-finish","reason":"stop","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47","cost":0}}`
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
if threadID != "ses_44fced3c7ffe83sZpzY1rlQka3" {
t.Errorf("threadID = %q, want %q", threadID, "ses_44fced3c7ffe83sZpzY1rlQka3")
}
if message != "Hello from opencode" {
t.Errorf("message = %q, want %q", message, "Hello from opencode")
}
}
func TestParseJSONStream_Opencode_MultipleTextEvents(t *testing.T) {
input := `{"type":"text","sessionID":"ses_123","part":{"type":"text","text":"Part 1"}}
{"type":"text","sessionID":"ses_123","part":{"type":"text","text":" Part 2"}}
{"type":"step_finish","sessionID":"ses_123","part":{"type":"step-finish","reason":"stop"}}`
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
if threadID != "ses_123" {
t.Errorf("threadID = %q, want %q", threadID, "ses_123")
}
if message != "Part 1 Part 2" {
t.Errorf("message = %q, want %q", message, "Part 1 Part 2")
}
}
func TestParseJSONStream_Opencode_NoStopReason(t *testing.T) {
input := `{"type":"text","sessionID":"ses_456","part":{"type":"text","text":"Content"}}
{"type":"step_finish","sessionID":"ses_456","part":{"type":"step-finish","reason":"tool-calls"}}`
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
if threadID != "ses_456" {
t.Errorf("threadID = %q, want %q", threadID, "ses_456")
}
if message != "Content" {
t.Errorf("message = %q, want %q", message, "Content")
}
}

View File

@@ -1,32 +0,0 @@
package parser
import (
"strings"
"testing"
)
func TestBackendParseJSONStream_UnknownEventsAreSilent(t *testing.T) {
input := strings.Join([]string{
`{"type":"turn.started"}`,
`{"type":"assistant","text":"hi"}`,
`{"type":"user","text":"yo"}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
}, "\n")
var infos []string
infoFn := func(msg string) { infos = append(infos, msg) }
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, infoFn, nil, nil)
if message != "ok" {
t.Fatalf("message=%q, want %q (infos=%v)", message, "ok", infos)
}
if threadID != "" {
t.Fatalf("threadID=%q, want empty (infos=%v)", threadID, infos)
}
for _, msg := range infos {
if strings.Contains(msg, "Agent event:") {
t.Fatalf("unexpected log for unknown event: %q", msg)
}
}
}

View File

@@ -1,15 +0,0 @@
package parser
import "testing"
func TestTruncateBytes(t *testing.T) {
if got := TruncateBytes([]byte("abc"), 3); got != "abc" {
t.Fatalf("TruncateBytes() = %q, want %q", got, "abc")
}
if got := TruncateBytes([]byte("abcd"), 3); got != "abc..." {
t.Fatalf("TruncateBytes() = %q, want %q", got, "abc...")
}
if got := TruncateBytes([]byte("abcd"), -1); got != "" {
t.Fatalf("TruncateBytes() = %q, want empty string", got)
}
}

View File

@@ -1,8 +0,0 @@
package utils
func Min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -1,36 +0,0 @@
package utils
import "testing"
func TestMin(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"a less than b", 1, 2, 1},
{"b less than a", 5, 3, 3},
{"equal values", 7, 7, 7},
{"negative a", -5, 3, -5},
{"negative b", 5, -3, -3},
{"both negative", -5, -3, -5},
{"zero and positive", 0, 5, 0},
{"zero and negative", 0, -5, -5},
{"large values", 1000000, 999999, 999999},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Min(tt.a, tt.b)
if got != tt.want {
t.Errorf("Min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func BenchmarkMin(b *testing.B) {
for i := 0; i < b.N; i++ {
Min(i, i+1)
}
}

View File

@@ -1,62 +0,0 @@
package utils
import "strings"
func Truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen < 0 {
return ""
}
return s[:maxLen] + "..."
}
// SafeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
func SafeTruncate(s string, maxLen int) string {
if maxLen <= 0 || s == "" {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen < 4 {
return string(runes[:1])
}
cutoff := maxLen - 3
if cutoff <= 0 {
return string(runes[:1])
}
if len(runes) <= cutoff {
return s
}
return string(runes[:cutoff]) + "..."
}
// SanitizeOutput removes ANSI escape sequences and control characters.
func SanitizeOutput(s string) string {
var result strings.Builder
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' {
inEscape = true
i++ // skip '['
continue
}
if inEscape {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
inEscape = false
}
continue
}
// Keep printable chars and common whitespace.
if s[i] >= 32 || s[i] == '\n' || s[i] == '\t' {
result.WriteByte(s[i])
}
}
return result.String()
}

View File

@@ -1,122 +0,0 @@
package utils
import (
"strings"
"testing"
)
func TestTruncate(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
want string
}{
{"empty string", "", 10, ""},
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"needs truncation", "hello world", 5, "hello..."},
{"zero maxLen", "hello", 0, "..."},
{"negative maxLen", "hello", -1, ""},
{"maxLen 1", "hello", 1, "h..."},
{"unicode bytes truncate", "你好世界", 10, "你好世\xe7..."}, // Truncate works on bytes, not runes
{"mixed truncate", "hello世界abc", 7, "hello\xe4\xb8..."}, // byte-based truncation
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Truncate(tt.s, tt.maxLen)
if got != tt.want {
t.Errorf("Truncate(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want)
}
})
}
}
func TestSafeTruncate(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
want string
}{
{"empty string", "", 10, ""},
{"zero maxLen", "hello", 0, ""},
{"negative maxLen", "hello", -1, ""},
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"needs truncation", "hello world", 8, "hello..."},
{"maxLen 1", "hello", 1, "h"},
{"maxLen 2", "hello", 2, "h"},
{"maxLen 3", "hello", 3, "h"},
{"maxLen 4", "hello", 4, "h..."},
{"unicode preserved", "你好世界", 10, "你好世界"},
{"unicode exact", "你好世界", 4, "你好世界"},
{"unicode truncate", "你好世界test", 6, "你好世..."},
{"mixed unicode", "ab你好cd", 5, "ab..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SafeTruncate(tt.s, tt.maxLen)
if got != tt.want {
t.Errorf("SafeTruncate(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want)
}
})
}
}
func TestSanitizeOutput(t *testing.T) {
tests := []struct {
name string
s string
want string
}{
{"empty string", "", ""},
{"plain text", "hello world", "hello world"},
{"with newline", "hello\nworld", "hello\nworld"},
{"with tab", "hello\tworld", "hello\tworld"},
{"ANSI color red", "\x1b[31mred\x1b[0m", "red"},
{"ANSI bold", "\x1b[1mbold\x1b[0m", "bold"},
{"ANSI complex", "\x1b[1;31;40mtext\x1b[0m", "text"},
{"control chars", "hello\x00\x01\x02world", "helloworld"},
{"mixed ANSI and control", "\x1b[32m\x00ok\x1b[0m", "ok"},
{"multiple ANSI sequences", "\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m", "red green"},
{"incomplete escape", "\x1b[", ""},
{"escape without bracket", "\x1bA", "A"},
{"cursor movement", "\x1b[2Aup\x1b[2Bdown", "updown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SanitizeOutput(tt.s)
if got != tt.want {
t.Errorf("SanitizeOutput(%q) = %q, want %q", tt.s, got, tt.want)
}
})
}
}
func BenchmarkTruncate(b *testing.B) {
s := strings.Repeat("hello world ", 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Truncate(s, 50)
}
}
func BenchmarkSafeTruncate(b *testing.B) {
s := strings.Repeat("你好世界", 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
SafeTruncate(s, 50)
}
}
func BenchmarkSanitizeOutput(b *testing.B) {
s := strings.Repeat("\x1b[31mred\x1b[0m text ", 50)
b.ResetTimer()
for i := 0; i < b.N; i++ {
SanitizeOutput(s)
}
}

View File

@@ -1,4 +1,4 @@
package executor package main
import ( import (
"os" "os"
@@ -7,12 +7,14 @@ import (
) )
func TestLogWriterWriteLimitsBuffer(t *testing.T) { func TestLogWriterWriteLimitsBuffer(t *testing.T) {
defer resetTestHooks()
logger, err := NewLogger() logger, err := NewLogger()
if err != nil { if err != nil {
t.Fatalf("NewLogger error: %v", err) t.Fatalf("NewLogger error: %v", err)
} }
setLogger(logger) setLogger(logger)
t.Cleanup(func() { _ = closeLogger() }) defer closeLogger()
lw := newLogWriter("P:", 10) lw := newLogWriter("P:", 10)
_, _ = lw.Write([]byte(strings.Repeat("a", 100))) _, _ = lw.Write([]byte(strings.Repeat("a", 100)))
@@ -34,3 +36,4 @@ func TestLogWriterWriteLimitsBuffer(t *testing.T) {
t.Fatalf("log output missing truncated entry, got %q", string(data)) t.Fatalf("log output missing truncated entry, got %q", string(data))
} }
} }

Some files were not shown because too many files have changed in this diff Show More