Compare commits

..

90 Commits

Author SHA1 Message Date
cexll
19a8d8e922 refactor: rename feature-dev to do workflow
- Rename skills/feature-dev/ → skills/do/
- Update config.json module name and paths
- Shorter command: /do instead of /feature-dev
- State file: .claude/do.local.md
- Completion promise: <promise>DO_COMPLETE</promise>

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 20:29:28 +08:00
NieiR
669b1d82ce fix(gemini): read GEMINI_MODEL from ~/.gemini/.env (#131)
When using gemini backend without --model flag, now automatically
reads GEMINI_MODEL from ~/.gemini/.env file, consistent with how
claude backend reads model from settings.
2026-01-23 12:03:50 +08:00
cexll
a21c31fd89 feat: add feature-dev skill with 7-phase workflow
Structured feature development with codeagent orchestration:
- Discovery, Exploration, Clarification, Architecture phases
- Implementation, Review, Summary phases
- Parallel agent execution via code-explorer, code-architect, etc.
- Hook-based workflow automation with validation scripts

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 12:01:31 +08:00
cexll
773f133111 chore: ignore references directory
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 12:01:04 +08:00
cexll
4f5d24531c feat(install): support \${CLAUDE_PLUGIN_ROOT} variable in hooks config
- find_module_hooks now returns (hooks_config, plugin_root_path) tuple
- Add _replace_hook_variables() for recursive placeholder substitution
- Add feature-dev module config to config.json

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 12:00:55 +08:00
cexll
cc24d43c8b fix(codeagent): validate non-empty output message before printing
Return exit code 1 when backend returns empty result.Message with exit_code=0.
Prevents silent failures where no output is produced.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 12:00:47 +08:00
cexll
27d4ac8afd chore: add go.work.sum for workspace dependencies
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-23 12:00:38 +08:00
cexll
2e5d12570d fix: add missing cmd/codeagent/main.go entry point
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-20 17:45:50 +08:00
cexll
7c89c40e8f fix(ci): update release workflow build path for new directory structure
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-20 17:42:10 +08:00
cexll
fa617d1599 refactor: restructure codebase to internal/ directory with modular architecture
- Move all source files to internal/{app,backend,config,executor,logger,parser,utils}
- Integrate third-party libraries: zerolog, goccy/go-json, gopsutil, cobra/viper
- Add comprehensive unit tests for utils package (94.3% coverage)
- Add performance benchmarks for string operations
- Fix error display: cleanup warnings no longer pollute Recent Errors
- Add GitHub Actions CI workflow
- Add Makefile for build automation
- Add README documentation

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-20 17:34:26 +08:00
cexll
90c630e30e fix: write PATH config to both profile and rc files (#128)
Previously install.sh only wrote PATH to ~/.bashrc (or ~/.zshrc),
which doesn't work for non-interactive login shells like `bash -lc`
used by Claude Code, because Ubuntu's ~/.bashrc exits early for
non-interactive shells.

Now writes to both:
- bash: ~/.bashrc + ~/.profile
- zsh: ~/.zshrc + ~/.zprofile

Each file is checked independently for idempotency.

Closes #128

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-17 22:22:41 +08:00
cexll
25bbbc32a7 feat: add course module with dev, product-requirements and test-cases skills
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-17 16:28:59 +08:00
cexll
d8304bf2b9 feat: add hooks management to install.py
- Add merge_hooks_to_settings: merge module hooks into settings.json
- Add unmerge_hooks_from_settings: remove module hooks from settings.json
- Add find_module_hooks: detect hooks.json in module directories
- Update execute_module: auto-merge hooks after install
- Update uninstall_module: auto-remove hooks on uninstall
- Add __module__ marker to track hook ownership

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-17 14:16:16 +08:00
cexll
7240e08900 feat: add sparv module and interactive plugin manager
- Add sparv module to config.json (SPARV workflow v1.1)
- Disable essentials module by default
- Add --status to show installation status of all modules
- Add --uninstall to remove installed modules
- Add interactive management mode (install/uninstall via menu)
- Add filesystem-based installation detection
- Support both module numbers and names in selection
- Merge install status instead of overwriting

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-17 13:38:52 +08:00
cexll
e122d8ff25 feat: add sparv enhanced rules v1.1
- Add Uncertainty Declaration (G3): declare assumptions when score < 2
- Add Requirement Routing: Quick/Full mode based on scope
- Add Context Acquisition: optional kb.md check before Specify
- Add Knowledge Base: .sparv/kb.md for cross-session patterns
- Add changelog-update.sh: maintain CHANGELOG by type

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-17 13:12:43 +08:00
马雨
6985a30a6a docs: update 'Agent Hierarchy' model for frontend-ui-ux-engineer and document-writer in README (#127)
* docs: update mappings for frontend-ui-ux-engineer and document-writer in README

* docs: update 'Agent Hierarchy' model for frontend-ui-ux-engineer and document-writer in README
2026-01-17 12:32:32 +08:00
马雨
dd4c12b8e2 docs: update mappings for frontend-ui-ux-engineer and document-writer in README (#126) 2026-01-17 12:04:12 +08:00
cexll
a88315d92d feat: add sparv skill to claude-plugin v1.1.0
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-16 22:26:31 +08:00
cexll
d1f13b3379 remove .sparv 2026-01-16 14:35:11 +08:00
cexll
5d362852ab feat sparv skill 2026-01-16 14:34:03 +08:00
cexll
238c7b9a13 fix(codeagent-wrapper): remove extraneous dash arg for opencode stdin mode (#124)
opencode does not support "-" as a stdin marker like codex/claude/gemini.
When using stdin mode, omit the "-" argument so opencode reads from stdin
without an unrecognized positional argument.

Closes #124

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-16 10:30:38 +08:00
cexll
0986fa82ee update readme 2026-01-16 09:39:55 +08:00
cexll
a989ce343c fix(codeagent-wrapper): correct default models for oracle and librarian agents (#120)
- oracle: claude-sonnet-4-20250514 → claude-opus-4-5-20251101
- librarian: claude-sonnet-4-5-20250514 → claude-sonnet-4-5-20250929

Fixes #120

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-16 09:37:39 +08:00
cexll
abe0839249 feat dev skill 2026-01-15 15:31:14 +08:00
cexll
d75c973f32 fix(codeagent-wrapper): filter codex 0.84.0 stderr noise logs (#122)
- Add skills loader error pattern to codex noise filter
- Update CHANGELOG for v5.6.4

Fixes #122

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-15 15:22:25 +08:00
cexll
e7f329940b fix(codeagent-wrapper): filter codex stderr noise logs
Add codexNoisePatterns to filter "ERROR codex_core::codex: needs_follow_up:"
messages from stderr output when using the codex backend.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-15 14:59:31 +08:00
cexll
0fc5eaaa2d fix: update version tests to match 5.6.3
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 17:26:21 +08:00
cexll
420eb857ff chore: bump codeagent-wrapper version to 5.6.3
Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 17:14:06 +08:00
cexll
661656c587 fix(codeagent-wrapper): use config override for codex reasoning effort
Replace invalid `--reasoning-effort` CLI flag with `-c model_reasoning_effort=<value>`
config override, as codex does not support the former.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 17:04:21 +08:00
cexll
ed4b088631 docs: add OmO workflow to README and fix plugin marketplace structure
- Add OmO multi-agent orchestrator documentation to README.md and README_CN.md
- Fix marketplace.json to follow official Claude Code plugin schema
- Add $schema field and move version/description to top level
- Create proper .claude-plugin/plugin.json for all plugins
- Remove non-standard marketplace.json from plugin subdirectories
- Simplify plugin names: omo, dev, requirements, bmad, essentials

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 14:29:15 +08:00
cexll
55a574280a fix(codeagent-wrapper): propagate SkipPermissions to parallel tasks (#113)
Parallel task execution was not inheriting the --skip-permissions flag,
causing permission prompts to appear for parallel tasks while single
tasks worked correctly.

Changes:
- Add SkipPermissions field to TaskSpec struct
- Parse skip_permissions/skip-permissions in parallel task config
- Inherit SkipPermissions from CLI args to parallel tasks
- Pass SkipPermissions when creating task Config in executor

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 11:50:36 +08:00
cexll
8f05626075 fix(codeagent-wrapper): add timeout for Windows process termination
- Add forceKillWaitTimeout (5s) to prevent cmd.Wait() blocking forever
- Enhance sendTermSignal with killProcessTree fallback using wmic
- Update omo README: remove sisyphus, fix model names, update config

Fixes #115

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 10:43:25 +08:00
NieiR
4395c5785d fix(codeagent-wrapper): reject dash as workdir parameter (#118)
Prevent '-' from being incorrectly parsed as a workdir path.
This fixes a potential ambiguity when using stdin mode.
2026-01-14 10:04:23 +08:00
cexll
b0d7a09ff2 refactor(codeagent-wrapper): remove sisyphus agent and unused code
- Remove sisyphus agent from default config (references deleted sisyphus.md)
- Clean up unused variables: useASCIIMode, jsonMarshal
- Remove unused type: codexHeader
- Remove unused functions: extractMessageSummary, extractKeyOutput, extractTaskBlock
- Update tests to reflect 6 default agents instead of 7

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-14 10:01:23 +08:00
cexll
f7aeaa5c7e fix(codeagent-wrapper): add sleep in fake script to prevent CI race condition
Add 50ms sleep in createFakeCodexScript to ensure parser goroutine has
time to read stdout before the process exits. Fixes TestRun_ExplicitStdinSuccess
flaky failure on Linux CI where fast shell execution closes pipe prematurely.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 22:56:05 +08:00
cexll
c8f75faf84 fix gemini env load 2026-01-13 22:40:49 +08:00
cexll
b8b06257ff feat(codeagent-wrapper): add reasoning effort config for codex backend
- Add --reasoning-effort CLI flag for codex model thinking intensity
- Support reasoning config in ~/.codeagent/models.json per agent
- CLI flag takes precedence over config file
- Only effective for codex backend

Closes #117

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 22:38:38 +08:00
cexll
369a3319f9 fix omo 2026-01-13 19:28:37 +08:00
cexll
75f08ab81f docs: update FAQ for default bypass/skip-permissions behavior
Reflect that codeagent-wrapper now enables bypass mode by default.
Document how to disable if permission prompts are needed.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 17:38:19 +08:00
cexll
23282ef460 refactor(omo): streamline agent documentation and remove sisyphus
- Simplify SKILL.md with cleaner agent definitions
- Update agent reference docs (develop, explore, librarian, oracle, etc.)
- Remove deprecated sisyphus agent
- Improve README with updated usage examples

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 17:38:02 +08:00
cexll
c7cb28a1da feat(codeagent-wrapper): default to skip-permissions and bypass-sandbox
- Claude: enable --dangerously-skip-permissions by default (set CODEAGENT_SKIP_PERMISSIONS=false to disable)
- Codex: enable --dangerously-bypass-approvals-and-sandbox by default (set CODEX_BYPASS_SANDBOX=false to disable)
- Gemini: use positional argument instead of deprecated -p flag (except for stdin mode)
- Add envFlagDefaultTrue helper for default-true env flags

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 17:37:44 +08:00
cexll
0a4982e96d feat(installer): add omo module for multi-agent orchestration
Add omo skill as installable module with Sisyphus coordinator and
specialized agents (oracle, librarian, explore, frontend-ui-ux-engineer,
document-writer, develop).

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-13 00:08:18 +08:00
cexll
17e52d78d2 feat(codeagent-wrapper): add multi-agent support with yolo mode
- Add --agent parameter for agent-based backend/model resolution
- Add --prompt-file parameter for agent prompt injection
- Add opencode backend support with JSON output parsing
- Add yolo field in agent config for auto-enabling dangerous flags
  - claude: --dangerously-skip-permissions
  - codex: --dangerously-bypass-approvals-and-sandbox
- Add develop agent for code development tasks
- Add omo skill for multi-agent orchestration with Sisyphus coordinator
- Bump version to 5.5.0

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-12 14:11:15 +08:00
cexll
55246ce9c4 Merge branch 'master' of github.com:cexll/myclaude 2026-01-09 11:56:40 +08:00
cexll
890fec81bf fix codeagent skill TaskOutput 2026-01-09 11:56:35 +08:00
makoMako
81f298c2ea fix(parser): 修复 Gemini init 事件 session_id 未提取的问题 (#111)
Gemini CLI 的 session_id 出现在 init 事件中,但 parser 的 isGemini
判定条件只检查 role/delta/status 字段,导致 init 事件被当作
"Unknown event" 忽略,session_id 无法提取。

修复方案:在 isGemini 条件中增加对 init 事件的识别。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-08 14:52:58 +08:00
cexll
8ea6d10be5 add test-cases skill 2026-01-08 11:34:25 +08:00
cexll
bdf62d0f1c add browser skill 2026-01-08 11:33:19 +08:00
makoMako
40e2d00d35 修复 Windows 后端退出:taskkill 结束进程树 + turn.completed 支持 (#108)
* fix(executor): handle turn.completed and terminate process tree on Windows

* fix: 修复代码审查发现的安全和资源泄漏问题

修复内容:
1. Windows 测试 taskkill 副作用:fake process 在 Windows 上返回 Pid()==0,避免真实执行 taskkill
2. taskkill PATH 劫持风险:使用 SystemRoot 环境变量构建绝对路径
3. stdinPipe 资源泄漏:在 StdoutPipe() 和 Start() 失败路径关闭 stdinPipe
4. stderr drain 并发语义:移除 500ms 超时,确保 drain 完成后再访问共享缓冲

测试验证:
- go test ./... -race 通过
- TestRunCodexTask_ForcesStopAfterTurnCompleted 通过
- TestExecutorSignalAndTermination 通过

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>

---------

Co-authored-by: cexll <evanxian9@gmail.com>
Co-authored-by: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-08 10:33:09 +08:00
cexll
13465b12e5 fix: support model parameter for all backends, auto-inject from settings (#105)
- Add Model field to Config and TaskSpec for per-task model selection
- Parse --model flag and model: metadata in parallel tasks
- Auto-inject model from ~/.claude/settings.json for claude backend in new mode
- Pass --model to claude CLI, -m to gemini CLI, --model to codex CLI
- Preserve --setting-sources "" isolation while reading minimal safe subset
- Add comprehensive tests for model parsing, propagation, and settings injection

Fixes #105

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-06 15:03:21 +08:00
cexll
cf93a0ada9 feat skill-install install script and security scan 2026-01-05 21:02:07 +08:00
cexll
b81953a1d7 feat: add uninstall scripts with selective module removal
- uninstall.py: Python uninstaller with --list, --dry-run, --module options
- uninstall.sh: Bash uninstaller with same functionality
- Reads installed_modules.json for precise removal
- Supports partial uninstall (--module dev)
- --purge option for complete removal
- Cleans PATH from shell configs (.bashrc/.zshrc)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-04 22:53:11 +08:00
cexll
1d2f28101a docs: add FAQ Q5 for permission/sandbox env vars
Add CODEX_BYPASS_SANDBOX and CODEAGENT_SKIP_PERMISSIONS
environment variables to FAQ section in both EN and CN READMEs.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-04 10:13:58 +08:00
cexll
81e95777a8 fix: replace setx with reg add to avoid 1024-char PATH truncation (#101)
- Use reg add instead of setx to bypass Windows 1024-character limit
- Add safety check for quotes/exclamation marks in PATH to prevent injection
- Preserve stderr output for better error diagnostics
- Update documentation with warnings about cmd PATH duplication
- Add test script for PATH update validation

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-31 14:40:34 +08:00
cexll
993249acb1 docs: 添加 FAQ 常见问题章节
添加 4 个高频问题的解决方案:
- Q1: codeagent-wrapper "Unknown event format" 日志问题 (#96)
- Q2: Gemini 无法读取 .gitignore 文件 (#75)
- Q3: /dev 命令并行执行性能优化建议 (#77)
- Q4: Go 版 Codex 权限配置指南 (#31)

提升用户自助排障能力,减少重复问题咨询。

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-26 15:03:43 +08:00
ben
0d28e70026 feat(dev-workflow): Add intelligent backend selection based on task complexity (#61)
合并智能 backend 选择功能。包含:multiSelect backend 选择、type 字段任务分类(default|ui|quick-fix)、智能路由策略
2025-12-26 15:01:58 +08:00
cexll
7560ce1976 fix: 移除未知事件格式的日志噪声 (#96)
问题:
- codeagent-wrapper 在处理 Claude Code 等其他后端的事件流时
- 对无法识别的事件格式(turn.started/assistant/user)打印警告日志
- 造成输出噪声,影响用户体验

修复:
- parser.go:274 - 移除对未知事件的 warnFn 日志打印
- 改为静默 continue,直接跳过这些事件
- 添加注释说明这些事件来自其他后端,无需处理

测试:
- 新增 parser_unknown_event_test.go 回归测试
- 验证未知事件不产生 "Agent event:" 日志
- 确保 Codex/Claude/Gemini 事件解析不受影响
- 所有测试通过

Closes #96

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-26 14:51:38 +08:00
cexll
683d18e6bb docs: update troubleshooting with idempotent PATH commands (#95)
- Use correct PATH pattern matching syntax
- Explain installer auto-adds PATH
- Provide idempotent command for manual use

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-25 11:40:53 +08:00
cexll
a7147f692c fix: prevent duplicate PATH entries on reinstall (#95)
- install.sh: Auto-detect shell and add PATH with idempotency check
- install.bat: Improve PATH detection with system PATH check
- Fix PATH variable quoting in pattern matching

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-25 11:38:42 +08:00
cexll
b71d74f01f fix: Minor issues #12 and #13 - ASCII mode and performance optimization
This commit addresses the remaining Minor issues from PR #94 code review:

Minor #12: Unicode Symbol Compatibility
- Added CODEAGENT_ASCII_MODE environment variable support
- When set to "true", uses ASCII symbols: PASS/WARN/FAIL
- Default behavior (unset or "false"): Unicode symbols ✓/⚠️/✗
- Updated help text to document the environment variable
- Added tests for both ASCII and Unicode modes

Implementation:
- executor.go:514: New getStatusSymbols() function
- executor.go:531: Dynamic symbol selection in generateFinalOutputWithMode
- main.go:34: useASCIIMode variable declaration
- main.go:495: Environment variable documentation in help
- executor_concurrent_test.go:292: Tests for ASCII mode
- main_integration_test.go:89: Parser updated for both symbol formats

Minor #13: Performance Optimization - Reduce Repeated String Operations
- Optimized Message parsing to split only once per task result
- Added *FromLines() variants of all extractor functions
- Original extract*() functions now wrap *FromLines() for compatibility
- Reduces memory allocations and CPU usage in parallel execution

Implementation:
- utils.go:300: extractCoverageFromLines()
- utils.go:390: extractFilesChangedFromLines()
- utils.go:455: extractTestResultsFromLines()
- utils.go:551: extractKeyOutputFromLines()
- main.go:255: Single split with reuse: lines := strings.Split(...)

Backward Compatibility:
- All original extract*() functions preserved
- Tests updated to handle both symbol formats
- No breaking changes to public API

Test Results:
- All tests pass: go test ./... (40.164s)
- ASCII mode verified: PASS/WARN/FAIL symbols display correctly
- Unicode mode verified: ✓/⚠️/✗ symbols remain default
- Performance: Single split per Message instead of 4+

Usage Examples:
  # Unicode mode (default)
  ./codeagent-wrapper --parallel < tasks.txt

  # ASCII mode (for terminals without Unicode support)
  CODEAGENT_ASCII_MODE=true ./codeagent-wrapper --parallel < tasks.txt

Benefits:
- Improved terminal compatibility across different environments
- Reduced memory allocations in parallel execution
- Better performance for large-scale parallel tasks
- User choice between Unicode aesthetics and ASCII compatibility

Related: #94

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-24 11:59:00 +08:00
cexll
af1c860f54 fix: code review fixes for PR #94 - all critical and major issues resolved
This commit addresses all Critical and Major issues identified in the code review:

Critical Issues Fixed:
- #1: Test statistics data loss (utils.go:480) - Changed exit condition from || to &&
- #2: Below-target header showing "below 0%" - Added defaultCoverageTarget constant

Major Issues Fixed:
- #3: Coverage extraction not robust - Relaxed trigger conditions for various formats
- #4: 0% coverage ignored - Changed from CoverageNum>0 to Coverage!="" check
- #5: File change extraction incomplete - Support root files and @ prefix
- #6: String truncation panic risk - Added safeTruncate() with rune-based truncation
- #7: Breaking change documentation missing - Updated help text and docs
- #8: .DS_Store garbage files - Removed files and updated .gitignore
- #9: Test coverage insufficient - Added 29+ test cases in utils_test.go
- #10: Terminal escape injection risk - Added sanitizeOutput() for ANSI cleaning
- #11: Redundant code - Removed unused patterns variable

Test Results:
- All tests pass: go test ./... (34.283s)
- Test coverage: 88.4% (up from ~85%)
- New test file: codeagent-wrapper/utils_test.go
- No breaking changes to existing functionality

Files Modified:
- codeagent-wrapper/utils.go (+166 lines) - Core fixes and new functions
- codeagent-wrapper/executor.go (+111 lines) - Output format fixes
- codeagent-wrapper/main.go (+45 lines) - Configuration updates
- codeagent-wrapper/main_test.go (+40 lines) - New integration tests
- codeagent-wrapper/utils_test.go (new file) - Complete extractor tests
- docs/CODEAGENT-WRAPPER.md (+38 lines) - Documentation updates
- .gitignore (+2 lines) - Added .DS_Store patterns
- Deleted 5 .DS_Store files

Verification:
- Binary compiles successfully (v5.4.0)
- All extractors validated with real-world test cases
- Security vulnerabilities patched
- Performance maintained (90% token reduction preserved)

Related: #94

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-24 09:55:39 +08:00
tytsxai
70b1896011 feat(codeagent-wrapper): v5.4.0 structured execution report (#94)
Merging PR #94 with code review fixes applied.

All Critical and Major issues from code review have been addressed:
- 11/13 issues fixed (2 minor optimizations deferred)
- Test coverage: 88.4%
- All tests passing
- Security vulnerabilities patched
- Documentation updated

The code review fixes have been committed to pr-94 branch and are ready for integration.
2025-12-24 09:53:58 +08:00
cexll
3fd3c67749 fix: correct settings.json filename and bump version to v5.2.8
- Fix incorrect filename reference from setting.json to settings.json in backend.go
- Update corresponding test fixtures to use correct filename
- Bump version from 5.2.7 to 5.2.8

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-22 10:32:44 +08:00
cexll
156a072a0b chore: simplify release workflow to use GitHub auto-generated notes
- Remove git-cliff dependency and node.js setup
- Use generate_release_notes: true for automatic PR/commit listing
- Maintains all binary builds and artifact uploads
- Release notes can still be manually edited after creation

Benefits:
- Simpler workflow with fewer dependencies
- Automatic PR titles and contributor attribution
- Easier to maintain and debug

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 20:37:11 +08:00
cexll
0ceb819419 chore: bump version to v5.2.7
Changes in v5.2.7:
- Security fix: pass env vars via process environment instead of command line
- Prevents ANTHROPIC_API_KEY leakage in ps/logs
- Add SetEnv() interface to commandRunner
- Type-safe env parsing with 1MB file size limit
- Comprehensive test coverage for loadMinimalEnvSettings()

Related: #89, PR #92

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 20:25:23 +08:00
ben
4d69c8aef1 fix: allow claude backend to read env from setting.json while preventing recursion (#92)
* fix: allow claude backend to read env from setting.json while preventing recursion

Fixes #89

Problem:
- --setting-sources "" prevents claude from reading ~/.claude/setting.json env
- Removing it causes infinite recursion via skills/commands/agents loading

Solution:
- Keep --setting-sources "" to block all config sources
- Add loadMinimalEnvSettings() to extract only env from setting.json
- Pass env explicitly via --settings parameter
- Update tests to validate dynamic --settings parameter

Benefits:
- Claude backend can access ANTHROPIC_API_KEY and other env vars
- Skills/commands/agents remain blocked, preventing recursion
- Graceful degradation if setting.json doesn't exist

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>

* security: pass env via process environment instead of command line

Critical security fix for issue #89:
- Prevents ANTHROPIC_API_KEY leakage in process command line (ps)
- Prevents sensitive values from being logged in wrapper logs

Changes:
1. executor.go:
   - Add SetEnv() method to commandRunner interface
   - realCmd merges env with os.Environ() and sets to cmd.Env
   - All test mocks implement SetEnv()

2. backend.go:
   - Change loadMinimalEnvSettings() to return map[string]string
   - Use os.UserHomeDir() instead of os.Getenv("HOME")
   - Add 1MB file size limit check
   - Only accept string values in env (reject non-strings)
   - Remove --settings parameter (no longer in command line)

3. Tests:
   - Add loadMinimalEnvSettings() unit tests
   - Remove --settings validation (no longer in args)
   - All test mocks implement SetEnv()

Security improvements:
- No sensitive values in argv (safe from ps/logs)
- Type-safe env parsing (string-only)
- File size limit prevents memory issues
- Graceful degradation if setting.json missing

Tests: All pass (30.912s)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>

---------

Co-authored-by: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 20:16:57 +08:00
ben
eec844d850 feat: add millisecond-precision timestamps to all log entries (#91)
- Add timestamp prefix format [YYYY-MM-DD HH:MM:SS.mmm] to every log entry
- Resolves issue where logs lacked time information, making it impossible to determine when events (like "Unknown event format" errors) occurred
- Update tests to handle new timestamp format by stripping prefixes during validation
- All 27+ tests pass with new format

Implementation:
- Modified logger.go:369-370 to inject timestamp before message
- Updated concurrent_stress_test.go to strip timestamps for format checks

Fixes #81

Generated with SWE-Agent.ai

Co-authored-by: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 18:57:27 +08:00
ben
1f42bcc1c6 fix: comprehensive security and quality improvements for PR #85 & #87 (#90)
Co-authored-by: tytsxai <tytsxai@users.noreply.github.com>
2025-12-21 18:01:20 +08:00
ben
0f359b048f Improve backend termination after message and extend timeout (#86)
* Improve backend termination after message and extend timeout

* fix: prevent premature backend termination and revert timeout

Critical fixes for executor.go termination logic:

1. Add onComplete callback to prevent premature termination
   - Parser now distinguishes between "any message" (onMessage) and
     "terminal event" (onComplete)
   - Codex: triggers onComplete on thread.completed
   - Claude: triggers onComplete on type:"result"
   - Gemini: triggers onComplete on type:"result" + terminal status

2. Fix executor to wait for completion events
   - Replace messageSeen termination trigger with completeSeen
   - Only start postMessageTerminateDelay after terminal event
   - Prevents killing backend before final answer in multi-message scenarios

3. Fix terminated flag synchronization
   - Only set terminated=true if terminateCommandFn actually succeeds
   - Prevents "marked as terminated but not actually terminated" state

4. Simplify timer cleanup logic
   - Unified non-blocking drain on messageTimer.C
   - Remove dependency on messageTimerCh nil state

5. Revert defaultTimeout from 24h to 2h
   - 24h (86400s) → 2h (7200s) to avoid operational risks
   - 12× timeout increase could cause resource exhaustion
   - Users needing longer tasks can use CODEX_TIMEOUT env var

All tests pass. Resolves early termination bug from code review.

Co-authored-by: Codeagent (Codex)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>

---------

Co-authored-by: SWE-Agent.ai <noreply@swe-agent.ai>
2025-12-21 15:55:01 +08:00
ben
4e2df6a80e fix: Parser重复解析优化 + 严重bug修复 + PR #86兼容性 (#88)
Merging parser optimization with critical bug fixes and PR #86 compatibility. Supersedes #84.
2025-12-21 14:10:40 +08:00
cexll
a30f434b5d update all readme 2025-12-19 20:53:27 +08:00
makoMako
41f4e21268 fix(gemini): filter noisy stderr output from gemini backend (#83)
* fix(gemini): filter noisy stderr output from gemini backend

- Add filteringWriter to filter [STARTUP], Warning, Session cleanup etc.
- Apply filter only for gemini backend stderr output
- Add unit tests for filtering logic

* fix: use defer for stderrFilter.Flush to cover all return paths

Address review feedback: ensure filter is flushed on failure paths
2025-12-19 20:50:21 +08:00
Jahan
a67aa00c9a BMADh和Requirements-Driven支持根据语义生成对应的文档 (#82)
Co-authored-by: root <root@5090523.zyx>
2025-12-18 22:37:40 +08:00
Wei
d61a0f9ffd fix: 修復 wsl install.sh 格式問題 (#78) 2025-12-17 22:24:02 +08:00
ben
fe5508228f fix: 修复多 backend 并行日志 PID 混乱并移除包装格式 (#74) (#76)
* fix(logger): 修复多 backend 并行日志 PID 混乱并移除包装格式

**问题:**
- logger.go:288 使用 os.Getpid() 导致并行任务日志 PID 混乱
- 日志文件添加时间戳/PID/级别前缀包装,应输出 backend 原始内容

**修复:**
1. Logger 结构体添加 pid 字段,创建时捕获 PID
2. 日志写入使用固定 l.pid 替代 os.Getpid()
3. 移除日志输出格式包装,直接写入原始消息
4. 添加内存缓存 ERROR/WARN 条目,ExtractRecentErrors 从缓存读取
5. 优化 executor.go context 初始化顺序,避免重复创建 logger

**测试:**
- 所有测试通过(23.7s)
- 更新相关测试用例匹配新格式

Closes #74

* fix(logger): 增强并发日志隔离和 task ID 清理

## 核心修复

### 1. Task ID Sanitization (logger.go)
- 新增 sanitizeLogSuffix(): 清理非法字符 (/, \, :, 等)
- 新增 fallbackLogSuffix(): 为空/非法 ID 生成唯一后备名
- 新增 isSafeLogRune(): 仅允许 [A-Za-z0-9._-]
- 路径穿越防护: ../../../etc/passwd → etc-passwd-{hash}.log
- 超长 ID 处理: 截断到 64 字符 + hash 确保唯一性
- 自动创建 TMPDIR (MkdirAll)

### 2. 共享日志标识 (executor.go)
- 新增 taskLoggerHandle 结构: 封装 logger、路径、共享标志
- 新增 newTaskLoggerHandle(): 统一处理 logger 创建和回退
- printTaskStart(): 显示 "Log (shared)" 标识
- generateFinalOutput(): 在 summary 中标记共享日志
- 并发失败时明确标识所有任务使用共享主日志

### 3. 内部标志 (config.go)
- TaskResult.sharedLog: 非导出字段,标识共享日志状态

### 4. Race Detector 修复 (logger.go:209-219)
- Close() 在关闭 channel 前先等待 pendingWG
- 消除 Logger.Close() 与 Logger.log() 之间的竞态条件

## 测试覆盖

### 新增测试 (logger_suffix_test.go)
- TestLoggerWithSuffixSanitizesUnsafeSuffix: 非法字符清理
- TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable: 只读目录处理

### 新增测试 (executor_concurrent_test.go)
- TestConcurrentTaskLoggerFailure: 多任务失败时共享日志标识
- TestSanitizeTaskID: 并发场景下 task ID 清理验证

## 验证结果

 所有单元测试通过
 Race detector 无竞态 (65.4s)
 路径穿越攻击防护
 并发日志完全隔离
 边界情况正确处理

Resolves: PR #76 review feedback
Co-Authored-By: Codex Review <codex@anthropic.ai>

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>

* fix(logger): 修复关键 bug 并优化日志系统 (v5.2.5)

修复 P0 级别问题:
- sanitizeLogSuffix 的 trim 碰撞(防止多 task 日志文件名冲突)
- ExtractRecentErrors 边界检查(防止 slice 越界)
- Logger.Close 阻塞风险(新增可配置超时机制)

代码质量改进:
- 删除无用字段 Logger.pid 和 logEntry.level
- 优化 sharedLog 标记绑定到最终 LogPath
- 移除日志前缀,直接输出 backend 原始内容

测试覆盖增强:
- 新增 4 个测试用例(碰撞防护、边界检查、缓存上限、shared 判定)
- 优化测试注释和逻辑

版本更新:5.2.4 → 5.2.5

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>

---------

Co-authored-by: swe-agent-bot <agent@swe-agent.ai>
2025-12-17 10:33:38 +08:00
ben
50093036c3 Merge pull request #71 from aliceric27/master
fix: 修復 win python install.py
2025-12-16 17:37:01 +08:00
Wei
0cae0ede08 Merge branch 'cexll:master' into master 2025-12-16 16:21:34 +08:00
ben
4613b57240 Merge pull request #72 from changxvv/master
fix: replace "Codex" to "codeagent" in dev-plan-generator subagent description
2025-12-16 14:13:57 +08:00
cexll
7535a7b101 update changelog 2025-12-16 13:05:28 +08:00
cexll
f6bb97eba9 update codeagent skill backend select 2025-12-16 13:02:40 +08:00
changxv
78a411462b fix: replace "Codex" to "codeagent" in dev-plan-generator subagent 2025-12-16 12:32:18 +08:00
alex
9471a981e3 fix: 修復 win python install.py 2025-12-16 12:29:50 +08:00
cexll
3d27d44676 chore(ci): integrate git-cliff for automated changelog generation
- Add cliff.toml configuration matching current CHANGELOG.md format
- Replace awk script with npx git-cliff in release workflow
- Add `make changelog` command for one-click CHANGELOG updates
- Use git-cliff --current flag to generate release notes per version

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-16 10:47:18 +08:00
ben
6a66c9741f Merge pull request #70 from cexll/fix/prevent-codeagent-infinite-recursion
fix(codeagent): 防止 Claude backend 无限递归调用
2025-12-16 10:37:45 +08:00
cexll
a09c103cfb fix(codeagent): 防止 Claude backend 无限递归调用
通过设置 --setting-sources="" 禁用所有配置源(user, project, local),
避免被调用的 Claude 实例加载 ~/.claude/CLAUDE.md 和 skills,
从而防止再次调用 codeagent 导致的循环超时问题。

修改内容:
- backend.go: ClaudeBackend.BuildArgs 添加 --setting-sources="" 参数
- backend_test.go: 更新 4 个测试用例以匹配新的参数列表
- main_test.go: 更新 2 个测试用例以匹配新的参数列表

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-16 10:27:21 +08:00
ben
1dec763e26 Merge pull request #69 from cexll/myclaude-master-20251215-073053-338465000
fix(executor): isolate log files per task in parallel mode
2025-12-16 10:20:30 +08:00
cexll
f57ea2df59 chore: bump version to 5.2.4
Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-16 10:17:47 +08:00
cexll
d215c33549 fix(executor): isolate log files per task in parallel mode
Previously, all parallel tasks shared the same log file path, making it
difficult to debug individual task execution. This change creates a
separate log file for each task using the naming convention:
codeagent-wrapper-{pid}-{taskName}.log

Changes:
- Add withTaskLogger/taskLoggerFromContext for per-task logger injection
- Modify executeConcurrentWithContext to create independent Logger per task
- Update printTaskStart to display task-specific log paths
- Extract defaultRunCodexTaskFn for proper test hook reset
- Add runCodexTaskFn reset to resetTestHooks()

Test coverage: 93.7%

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-16 10:05:54 +08:00
swe-agent[bot]
b3f8fcfea6 update CHANGELOG.md 2025-12-15 22:23:34 +08:00
ben
806bb04a35 Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
fix(parser): 修复 bufio.Scanner token too long 错误
2025-12-15 14:22:03 +08:00
172 changed files with 18970 additions and 4857 deletions

View File

@@ -1,209 +1,54 @@
{
"name": "claude-code-dev-workflows",
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "myclaude",
"version": "5.6.1",
"description": "Professional multi-agent development workflows with OmO orchestration, Requirements-Driven and BMAD methodologies",
"owner": {
"name": "Claude Code Dev Workflows",
"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"
"name": "cexll",
"email": "evanxian9@gmail.com"
},
"plugins": [
{
"name": "requirements-driven-development",
"source": "./requirements-driven-workflow/",
"description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
"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": [
"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": "omo",
"description": "Multi-agent orchestration for code analysis, bug investigation, fix planning, and implementation with intelligent routing to specialized agents",
"version": "5.6.1",
"source": "./skills/omo",
"category": "development"
},
{
"name": "bmad-agile-workflow",
"source": "./bmad-agile-workflow/",
"name": "dev",
"description": "Lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
"version": "5.6.1",
"source": "./dev-workflow",
"category": "development"
},
{
"name": "requirements",
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
"version": "5.6.1",
"source": "./requirements-driven-workflow",
"category": "development"
},
{
"name": "bmad",
"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"
]
"version": "5.6.1",
"source": "./bmad-agile-workflow",
"category": "development"
},
{
"name": "development-essentials",
"source": "./development-essentials/",
"name": "dev-kit",
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
"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": [
"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"
]
"version": "5.6.1",
"source": "./development-essentials",
"category": "productivity"
},
{
"name": "codex-cli",
"source": "./skills/codex/",
"description": "Execute Codex CLI for code analysis, refactoring, and automated code changes with file references (@syntax) and structured output",
"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": [
"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"
]
"name": "sparv",
"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",
"version": "1.1.0",
"source": "./skills/sparv",
"category": "development"
}
]
}

22
.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Ensure shell scripts always use LF line endings on all platforms
*.sh text eol=lf
# Ensure Python files use LF line endings
*.py text eol=lf
# Auto-detect text files and normalize line endings to LF
* text=auto eol=lf
# Explicitly declare files that should always be treated as binary
*.exe binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.zip binary
*.gz binary
*.tar binary

View File

@@ -74,7 +74,7 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
fi
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent
chmod +x ${OUTPUT_NAME}
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
@@ -104,38 +104,10 @@ jobs:
cp install.sh install.bat release/
ls -la release/
- name: Extract release notes from CHANGELOG
id: extract_notes
run: |
VERSION=${GITHUB_REF#refs/tags/v}
# Extract version section from CHANGELOG.md
awk -v ver="$VERSION" '
/^## [0-9]+\.[0-9]+\.[0-9]+ - / {
if (found) exit
if ($2 == ver) {
found = 1
next
}
}
found && /^## / { exit }
found { print }
' CHANGELOG.md > release_notes.md
# Fallback to auto-generated if extraction failed
if [ ! -s release_notes.md ]; then
echo "⚠️ No release notes found in CHANGELOG.md for version $VERSION" > 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
cat release_notes.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
body_path: release_notes.md
generate_release_notes: true
draft: false
prerelease: false

3
.gitignore vendored
View File

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

View File

@@ -1,129 +1,772 @@
# Changelog
## 5.2.0 - 2025-12-13
All notable changes to this project will be documented in this file.
### 🚀 Core Features
## [5.6.4] - 2026-01-15
#### Skills System Enhancements
- **New Skills**: Added `codeagent`, `product-requirements`, `prototype-prompt-generator` to `skill-rules.json`
- **Auto-Activation**: Skills automatically trigger based on keyword/pattern matching via hooks
- **Backward Compatibility**: Retained `skills/codex/SKILL.md` for existing workflows
### 🚀 Features
#### Multi-Backend Support (codeagent-wrapper)
- **Renamed**: `codex-wrapper``codeagent-wrapper` with pluggable backend architecture
- **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag
- **Smart Parser**: Auto-detects backend JSON stream formats
- **Session Resume**: All backends support `-r <session_id>` cross-session resume
- **Parallel Execution**: DAG task scheduling with global and per-task backend configuration
- **Concurrency Control**: `CODEAGENT_MAX_PARALLEL_WORKERS` env var limits concurrent tasks (max 100)
- **Test Coverage**: 93.4% (backend.go 100%, config.go 97.8%, executor.go 96.4%)
- add reasoning effort config for codex backend
- default to skip-permissions and bypass-sandbox
- add multi-agent support with yolo mode
- add omo module for multi-agent orchestration
- add intelligent backend selection based on task complexity (#61)
- v5.4.0 structured execution report (#94)
- add millisecond-precision timestamps to all log entries (#91)
- skill-install install script and security scan
- add uninstall scripts with selective module removal
#### Dev Workflow
- **`/dev`**: 6-step minimal dev workflow with mandatory 90% test coverage
### 🐛 Bug Fixes
#### Hooks System
- **UserPromptSubmit**: Auto-activate skills based on context
- **PostToolUse**: Auto-validation/formatting after tool execution
- **Stop**: Cleanup and reporting on session end
- **Examples**: Skill auto-activation, pre-commit checks
- filter codex stderr noise logs
- use config override for codex reasoning effort
- propagate SkipPermissions to parallel tasks (#113)
- add timeout for Windows process termination
- reject dash as workdir parameter (#118)
- add sleep in fake script to prevent CI race condition
- fix gemini env load
- fix omo
- fix codeagent skill TaskOutput
- 修复 Gemini init 事件 session_id 未提取的问题 (#111)
- Windows 后端退出taskkill 结束进程树 + turn.completed 支持 (#108)
- support model parameter for all backends, auto-inject from settings (#105)
- replace setx with reg add to avoid 1024-char PATH truncation (#101)
- 移除未知事件格式的日志噪声 (#96)
- prevent duplicate PATH entries on reinstall (#95)
- Minor issues #12 and #13 - ASCII mode and performance optimization
- correct settings.json filename and bump version to v5.2.8
- allow claude backend to read env from setting.json while preventing recursion (#92)
- comprehensive security and quality improvements for PR #85 & #87 (#90)
- Improve backend termination after message and extend timeout (#86)
- Parser重复解析优化 + 严重bug修复 + PR #86兼容性 (#88)
- filter noisy stderr output from gemini backend (#83)
- 修復 wsl install.sh 格式問題 (#78)
- 修复多 backend 并行日志 PID 混乱并移除包装格式 (#74) (#76)
#### Skills System
- **Auto-Activation**: `skill-rules.json` regex trigger rules
- **codeagent skill**: Multi-backend wrapper integration
- **Modular Design**: Easy to extend with custom skills
### 🚜 Refactor
#### Installation System Enhancements
- **`merge_json` operation**: Auto-merge `settings.json` configuration
- **Modular Installation**: `python3 install.py --module dev`
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output
- **Streaming Output**: `op_run_command` streams bash script execution
- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json`
- remove sisyphus agent and unused code
- streamline agent documentation and remove sisyphus
### 📚 Documentation
- `docs/architecture.md` (21KB): Architecture overview with ASCII diagrams
- `docs/CODEAGENT-WRAPPER.md` (9KB): Complete usage guide
- `docs/HOOKS.md` (4KB): Customization guide
- `README.md`: Added documentation index, corrected default backend description
- add OmO workflow to README and fix plugin marketplace structure
- update FAQ for default bypass/skip-permissions behavior
- 添加 FAQ 常见问题章节
- update troubleshooting with idempotent PATH commands (#95)
### 🔧 Important Fixes
### 💼 Other
#### codeagent-wrapper
- Fixed Claude/Gemini backend `-C` (workdir) and `-r` (resume) parameter support (codeagent-wrapper/backend.go:80-120)
- Corrected Claude backend permission flag logic `if cfg.SkipPermissions` (codeagent-wrapper/backend.go:95)
- Fixed parallel mode startup banner duplication (codeagent-wrapper/main.go:184-194 removed)
- Extract and display recent errors on abnormal exit `Logger.ExtractRecentErrors()` (codeagent-wrapper/logger.go:156)
- Added task block index to parallel config error messages (codeagent-wrapper/config.go:245)
- Refactored signal handling logic to avoid duplicate nil checks (codeagent-wrapper/main.go:290-305)
- Removed binary artifacts from tracking (codeagent-wrapper, *.test, coverage.out)
- add test-cases skill
- add browser skill
- BMADh和Requirements-Driven支持根据语义生成对应的文档 (#82)
- update all readme
#### Installation Scripts
- Fixed issue #55: `op_run_command` uses Popen + selectors for real-time streaming output
- Fixed issue #56: Display recent errors instead of entire log
- Changed module list header from "Enabled" to "Default" to avoid ambiguity
## [5.2.4] - 2025-12-16
#### CI/CD
- Removed `.claude/` config file validation step (.github/workflows/ci.yml:45)
- Updated version test case from 5.1.0 → 5.2.0 (codeagent-wrapper/main_test.go:23)
#### Commands & Documentation
- Reverted `skills/codex/SKILL.md` to `codex-wrapper` for backward compatibility
### ⚙️ Miscellaneous Tasks
#### dev-workflow
- Replaced Codex skill → codeagent skill throughout
- Added UI auto-detection: backend tasks use codex, UI tasks use gemini
- Corrected agent name: `develop-doc-generator``dev-plan-generator`
### ⚙️ Configuration & Environment Variables
- integrate git-cliff for automated changelog generation
#### New Environment Variables
- `CODEAGENT_SKIP_PERMISSIONS`: Control permission check behavior
- Claude backend defaults to `--dangerously-skip-permissions` enabled, set to `true` to disable
- Codex/Gemini backends default to permission checks enabled, set to `true` to skip
- `CODEAGENT_MAX_PARALLEL_WORKERS`: Parallel task concurrency limit (default: unlimited, recommended: 8, max: 100)
- bump version to 5.2.4
#### Configuration Files
- `config.schema.json`: Added `op_merge_json` schema validation
### 🐛 Bug Fixes
### ⚠️ Breaking Changes
**codex-wrapper → codeagent-wrapper rename**
- 防止 Claude backend 无限递归调用
**Migration**:
```bash
python3 install.py --module dev --force
```
- isolate log files per task in parallel mode
**Backward Compatibility**: `codex-wrapper/main.go` provides compatibility entry point
### 💼 Other
### 📦 Installation
```bash
# Install dev module
python3 install.py --module dev
- Merge pull request #70 from cexll/fix/prevent-codeagent-infinite-recursion
# List all modules
python3 install.py --list-modules
- Merge pull request #69 from cexll/myclaude-master-20251215-073053-338465000
# Verbose logging mode
python3 install.py --module dev --verbose
```
- update CHANGELOG.md
### 🧪 Test Results
- Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
**All tests passing**
- Overall coverage: 93.4%
- Security scan: 0 issues (gosec)
- Linting: Pass
## [5.2.3] - 2025-12-15
### 📄 Related PRs & Issues
- PR #53: Enterprise Workflow with Multi-Backend Support
- Issue #55: Installation script execution not visible
- Issue #56: Unfriendly error logging on abnormal exit
### 🐛 Bug Fixes
### 👥 Contributors
- Claude Sonnet 4.5
- Claude Opus 4.5
- SWE-Agent-Bot
- 修复 bufio.Scanner token too long 错误 ([#64](https://github.com/cexll/myclaude/issues/64))
### 💼 Other
- change version
### 🧪 Testing
- 同步测试中的版本号至 5.2.3
## [5.2.2] - 2025-12-13
### ⚙️ Miscellaneous Tasks
- Bump version and clean up documentation
### 🐛 Bug Fixes
- fix codeagent backend claude no auto
- fix install.py dev fail
### 🧪 Testing
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
## [5.2.1] - 2025-12-13
### 🐛 Bug Fixes
- fix codeagent claude and gemini root dir
### 💼 Other
- update readme
## [5.2.0] - 2025-12-13
### ⚙️ Miscellaneous Tasks
- Update CHANGELOG and remove deprecated test files
### 🐛 Bug Fixes
- fix race condition in stdout parsing
- add worker limit cap and remove legacy alias
- use -r flag for gemini backend resume
- clarify module list shows default state not enabled
- use -r flag for claude backend resume
- remove binary artifacts and improve error messages
- 异常退出时显示最近错误信息
- op_run_command 实时流式输出
- 修复权限标志逻辑和版本号测试
- 重构信号处理逻辑避免重复 nil 检查
- 移除 .claude 配置文件验证步骤
- 修复并行执行启动横幅重复打印问题
- 修复master合并后的编译和测试问题
### 💼 Other
- Merge rc/5.2 into master: v5.2.0 release improvements
- Merge pull request #53 from cexll/rc/5.2
- remove docs
- remove docs
- add prototype prompt skill
- add prd skill
- update memory claude
- remove command gh flow
- update license
- Merge branch 'master' into rc/5.2
- Merge pull request #52 from cexll/fix/parallel-log-path-on-startup
### 📚 Documentation
- remove GitHub workflow related content
### 🚀 Features
- Complete skills system integration and config cleanup
- Improve release notes and installation scripts
- 添加终端日志输出和 verbose 模式
- 完整多后端支持与安全优化
- 替换 Codex 为 codeagent 并添加 UI 自动检测
### 🚜 Refactor
- 调整文件命名和技能定义
### 🧪 Testing
- 添加 ExtractRecentErrors 单元测试
## [5.1.4] - 2025-12-09
### 🐛 Bug Fixes
- 任务启动时立即返回日志文件路径以支持实时调试
## [5.1.3] - 2025-12-08
### 🐛 Bug Fixes
- resolve CI timing race in TestFakeCmdInfra
## [5.1.2] - 2025-12-08
### 🐛 Bug Fixes
- 修复channel同步竞态条件和死锁问题
### 💼 Other
- Merge pull request #51 from cexll/fix/channel-sync-race-conditions
- change codex-wrapper version
## [5.1.1] - 2025-12-08
### 🐛 Bug Fixes
- 增强日志清理的安全性和可靠性
- resolve data race on forceKillDelay with atomic operations
### 💼 Other
- Merge pull request #49 from cexll/freespace8/master
- resolve signal handling conflict preserving testability and Windows support
### 🧪 Testing
- 补充测试覆盖提升至 89.3%
## [5.1.0] - 2025-12-07
### 💼 Other
- Merge pull request #45 from Michaelxwb/master
- 修改windows安装说明
- 修改打包脚本
- 支持windows系统的安装
- Merge pull request #1 from Michaelxwb/feature-win
- 支持window
### 🚀 Features
- 添加启动时清理日志的功能和--cleanup标志支持
- implement enterprise workflow with multi-backend support
## [5.0.0] - 2025-12-05
### ⚙️ Miscellaneous Tasks
- clarify unit-test coverage levels in requirement questions
### 🐛 Bug Fixes
- defer startup log until args parsed
### 💼 Other
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #43 from gurdasnijor/smithery/add-badge
- Add Smithery badge
- Merge pull request #42 from freespace8/master
### 📚 Documentation
- rewrite documentation for v5.0 modular architecture
### 🚀 Features
- feat install.py
- implement modular installation system
### 🚜 Refactor
- remove deprecated plugin modules
## [4.8.2] - 2025-12-02
### 🐛 Bug Fixes
- skip signal test in CI environment
- make forceKillDelay testable to prevent signal test timeout
- correct Go version in go.mod from 1.25.3 to 1.21
- fix codex wrapper async log
- capture and include stderr in error messages
### 💼 Other
- Merge pull request #41 from cexll/fix-async-log
- remove test case 90
- optimize codex-wrapper
- Merge branch 'master' into fix-async-log
## [4.8.1] - 2025-12-01
### 🎨 Styling
- replace emoji with text labels
### 🐛 Bug Fixes
- improve --parallel parameter validation and docs
### 💼 Other
- remove codex-wrapper bin
## [4.8.0] - 2025-11-30
### 💼 Other
- update codex skill dependencies
## [4.7.3] - 2025-11-29
### 🐛 Bug Fixes
- 保留日志文件以便程序退出后调试并完善日志输出功能
### 💼 Other
- Merge pull request #34 from cexll/cce-worktree-master-20251129-111802-997076000
- update CLAUDE.md and codex skill
### 📚 Documentation
- improve codex skill parameter best practices
### 🚀 Features
- add session resume support and improve output format
- add parallel execution support to codex-wrapper
- add async logging to temp file with lifecycle management
## [4.7.2] - 2025-11-28
### 🐛 Bug Fixes
- improve buffer size and streamline message extraction
### 💼 Other
- Merge pull request #32 from freespace8/master
### 🧪 Testing
- 增加对超大单行文本和非字符串文本的处理测试
## [4.7.1] - 2025-11-27
### 💼 Other
- optimize dev pipline
- Merge feat/codex-wrapper: fix repository URLs
## [4.7] - 2025-11-27
### 🐛 Bug Fixes
- update repository URLs to cexll/myclaude
## [4.7-alpha1] - 2025-11-27
### 🐛 Bug Fixes
- fix marketplace schema validation error in dev-workflow plugin
### 💼 Other
- Merge pull request #29 from cexll/feat/codex-wrapper
- Add codex-wrapper Go implementation
- update readme
- update readme
## [4.6] - 2025-11-25
### 💼 Other
- update dev workflow
- update dev workflow
## [4.5] - 2025-11-25
### 🐛 Bug Fixes
- fix codex skill eof
### 💼 Other
- update dev workflow plugin
- update readme
## [4.4] - 2025-11-22
### 🐛 Bug Fixes
- fix codex skill timeout and add more log
- fix codex skill
### 💼 Other
- update gemini skills
- update dev workflow
- update codex skills model config
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #24 from cexll/swe-agent/23-1763544297
### 🚀 Features
- 支持通过环境变量配置 skills 模型
## [4.3] - 2025-11-19
### 🐛 Bug Fixes
- fix codex skills running
### 💼 Other
- update skills plugin
- update gemini
- update doc
- Add Gemini CLI integration skill
### 🚀 Features
- feat simple dev workflow
## [4.2.2] - 2025-11-15
### 💼 Other
- update codex skills
## [4.2.1] - 2025-11-14
### 💼 Other
- Merge pull request #21 from Tshoiasc/master
- Merge branch 'master' into master
- Change default model to gpt-5.1-codex
- Enhance codex.py to auto-detect long inputs and switch to stdin mode, improving handling of shell argument issues. Updated build_codex_args to support stdin and added relevant logging for task length warnings.
## [4.2] - 2025-11-13
### 🐛 Bug Fixes
- fix codex.py wsl run err
### 💼 Other
- optimize codex skills
- Merge branch 'master' of github.com:cexll/myclaude
- Rename SKILLS.md to SKILL.md
- optimize codex skills
### 🚀 Features
- feat codex skills
## [4.1] - 2025-11-04
### 💼 Other
- update enhance-prompt.md response
- update readme
### 📚 Documentation
- 新增 /enhance-prompt 命令并更新所有 README 文档
## [4.0] - 2025-10-22
### 🐛 Bug Fixes
- fix skills format
### 💼 Other
- Merge branch 'master' of github.com:cexll/myclaude
- Merge pull request #18 from cexll/swe-agent/17-1760969135
- update requirements clarity
- update .gitignore
- Fix #17: Update root marketplace.json to use skills array
- Fix #17: Convert requirements-clarity to correct plugin directory format
- Fix #17: Convert requirements-clarity to correct plugin directory format
- Convert requirements-clarity to plugin format with English prompts
- Translate requirements-clarity skill to English for plugin compatibility
- Add requirements-clarity Claude Skill
- Add requirements clarification command
- update
## [3.5] - 2025-10-20
### 💼 Other
- Merge pull request #15 from cexll/swe-agent/13-1760944712
- Fix #13: Clean up redundant README files
- Optimize README structure - Solution A (modular)
- Merge pull request #14 from cexll/swe-agent/12-1760944588
- Fix #12: Update Makefile install paths for new directory structure
## [3.4] - 2025-10-20
### 💼 Other
- Merge pull request #11 from cexll/swe-agent/10-1760752533
- Fix marketplace metadata references
- Fix plugin configuration: rename to marketplace.json and update repository URLs
- Fix #10: Restructure plugin directories to ensure proper command isolation
## [3.3] - 2025-10-15
### 💼 Other
- Update README-zh.md
- Update README.md
- Update marketplace.json
- Update Chinese README with v3.2 plugin system documentation
- Update README with v3.2 plugin system documentation
## [3.2] - 2025-10-10
### 💼 Other
- Add Claude Code plugin system support
- update readme
- Add Makefile for quick deployment and update READMEs
## [3.1] - 2025-09-17
### ◀️ Revert
- revert
### 🐛 Bug Fixes
- fixed bmad-orchestrator not fund
- fix bmad
### 💼 Other
- update bmad review with codex support
- 优化 BMAD 工作流和代理配置
- update gpt5
- support bmad output-style
- update bmad user guide
- update bmad readme
- optimize requirements pilot
- add use gpt5 codex
- add bmad pilot
- sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
- Update Chinese README and requirements-pilot command to align with latest workflow
- update readme
- update agent
- update bugfix sub agents
- Update ask support KISS YAGNI SOLID
- Add comprehensive documentation and multi-agent workflow system
- update commands
<!-- generated by git-cliff -->

View File

@@ -1,7 +1,7 @@
# Claude Code Multi-Agent Workflow System Makefile
# Quick deployment for BMAD and Requirements workflows
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test changelog
# Default target
help:
@@ -22,6 +22,7 @@ help:
@echo " deploy-all - Deploy everything (commands + agents)"
@echo " test-bmad - Test BMAD workflow with sample"
@echo " test-requirements - Test Requirements workflow with sample"
@echo " changelog - Update CHANGELOG.md using git-cliff"
@echo " clean - Clean generated artifacts"
@echo " help - Show this help message"
@@ -145,3 +146,17 @@ all: deploy-all
version:
@echo "Claude Code Multi-Agent Workflow System v3.1"
@echo "BMAD + Requirements-Driven Development"
# Update CHANGELOG.md using git-cliff
changelog:
@echo "📝 Updating CHANGELOG.md with git-cliff..."
@if ! command -v git-cliff > /dev/null 2>&1; then \
echo "❌ git-cliff not found. Installing via Homebrew..."; \
brew install git-cliff; \
fi
@git-cliff -o CHANGELOG.md
@echo "✅ CHANGELOG.md updated successfully!"
@echo ""
@echo "Preview the changes:"
@echo " git diff CHANGELOG.md"

307
README.md
View File

@@ -7,7 +7,7 @@
[![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)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude)
[![Version](https://img.shields.io/badge/Version-5.6-green)](https://github.com/cexll/myclaude)
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
@@ -35,6 +35,41 @@ python3 install.py --install-dir ~/.claude
## Workflows Overview
### 0. OmO Multi-Agent Orchestrator (Recommended for Complex Tasks)
**Intelligent multi-agent orchestration that routes tasks to specialized agents based on risk signals.**
```bash
/omo "analyze and fix this authentication bug"
```
**Agent Hierarchy:**
| Agent | Role | Backend | Model |
|-------|------|---------|-------|
| `oracle` | Technical advisor | Claude | claude-opus-4-5 |
| `librarian` | External research | Claude | claude-sonnet-4-5 |
| `explore` | Codebase search | OpenCode | grok-code |
| `develop` | Code implementation | Codex | gpt-5.2 |
| `frontend-ui-ux-engineer` | UI/UX specialist | Gemini | gemini-3-pro |
| `document-writer` | Documentation | Gemini | gemini-3-flash |
**Routing Signals (Not Fixed Pipeline):**
- Code location unclear → `explore`
- External library/API → `librarian`
- Risky/multi-file change → `oracle`
- Implementation needed → `develop` / `frontend-ui-ux-engineer`
**Common Recipes:**
- Explain code: `explore`
- Small fix with known location: `develop` directly
- Bug fix, location unknown: `explore → develop`
- Cross-cutting refactor: `explore → oracle → develop`
- External API integration: `explore + librarian → oracle → develop`
**Best For:** Complex bug investigation, multi-file refactoring, architecture decisions
---
### 1. Dev Workflow (Recommended)
**The primary workflow for most development tasks.**
@@ -132,6 +167,59 @@ Requirements → Architecture → Sprint Plan → Development → Review → QA
---
## Version Requirements
### Codex CLI
**Minimum version:** Check compatibility with your installation
The codeagent-wrapper uses these Codex CLI features:
- `codex e` - Execute commands (shorthand for `codex exec`)
- `--skip-git-repo-check` - Skip git repository validation
- `--json` - JSON stream output format
- `-C <workdir>` - Set working directory
- `resume <session_id>` - Resume previous sessions
**Verify Codex CLI is installed:**
```bash
which codex
codex --version
```
### Claude CLI
**Minimum version:** Check compatibility with your installation
Required features:
- `--output-format stream-json` - Streaming JSON output format
- `--setting-sources` - Control setting sources (prevents infinite recursion)
- `--dangerously-skip-permissions` - Skip permission prompts (use with caution)
- `-p` - Prompt input flag
- `-r <session_id>` - Resume sessions
**Security Note:** The wrapper adds `--dangerously-skip-permissions` for Claude by default. Set `CODEAGENT_SKIP_PERMISSIONS=false` to disable if you need permission prompts.
**Verify Claude CLI is installed:**
```bash
which claude
claude --version
```
### Gemini CLI
**Minimum version:** Check compatibility with your installation
Required features:
- `-o stream-json` - JSON stream output format
- `-y` - Auto-approve prompts (non-interactive mode)
- `-r <session_id>` - Resume sessions
- `-p` - Prompt input flag
**Verify Gemini CLI is installed:**
```bash
which gemini
gemini --version
```
---
## Installation
### Modular Installation (Recommended)
@@ -163,15 +251,39 @@ python3 install.py --force
```
~/.claude/
├── CLAUDE.md # Core instructions and role definition
├── commands/ # Slash commands (/dev, /code, etc.)
├── agents/ # Agent definitions
├── bin/
│ └── codeagent-wrapper # Main executable
├── 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
│ └── SKILL.md # Codex integration skill
── config.json # Configuration
└── installed_modules.json # Installation status
```
### Customizing Installation Directory
By default, myclaude installs to `~/.claude`. You can customize this using the `INSTALL_DIR` environment variable:
```bash
# Install to custom directory
INSTALL_DIR=/opt/myclaude bash install.sh
# Update your PATH accordingly
export PATH="/opt/myclaude/bin:$PATH"
```
**Directory Structure:**
- `$INSTALL_DIR/bin/` - codeagent-wrapper binary
- `$INSTALL_DIR/skills/` - Skill definitions
- `$INSTALL_DIR/config.json` - Configuration file
- `$INSTALL_DIR/commands/` - Slash command definitions
- `$INSTALL_DIR/agents/` - Agent definitions
**Note:** When using a custom installation directory, ensure that `$INSTALL_DIR/bin` is added to your `PATH` environment variable.
### Configuration
Edit `config.json` to customize:
@@ -269,8 +381,10 @@ $Env:PATH = "$HOME\bin;$Env:PATH"
```
```batch
REM cmd.exe - persistent for current user
setx PATH "%USERPROFILE%\bin;%PATH%"
REM cmd.exe - persistent for current user (use PowerShell method above instead)
REM WARNING: This expands %PATH% which includes system PATH, causing duplication
REM Note: Using reg add instead of setx to avoid 1024-character truncation limit
reg add "HKCU\Environment" /v Path /t REG_EXPAND_SZ /d "%USERPROFILE%\bin;%PATH%" /f
```
---
@@ -294,11 +408,14 @@ setx PATH "%USERPROFILE%\bin;%PATH%"
**Codex wrapper not found:**
```bash
# Check PATH
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
# Installer auto-adds PATH, check if configured
if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
echo "PATH not configured. Reinstalling..."
bash install.sh
fi
# Reinstall
bash install.sh
# Or manually add (idempotent command)
[[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
```
**Permission denied:**
@@ -315,11 +432,173 @@ cat ~/.claude/installed_modules.json
python3 install.py --module dev --force
```
### Version Compatibility Issues
**Backend CLI not found:**
```bash
# Check if backend CLIs are installed
which codex
which claude
which gemini
# Install missing backends
# Codex: Follow installation instructions at https://codex.docs
# Claude: Follow installation instructions at https://claude.ai/docs
# Gemini: Follow installation instructions at https://ai.google.dev/docs
```
**Unsupported CLI flags:**
```bash
# If you see errors like "unknown flag" or "invalid option"
# Check backend CLI version
codex --version
claude --version
gemini --version
# For Codex: Ensure it supports `e`, `--skip-git-repo-check`, `--json`, `-C`, and `resume`
# For Claude: Ensure it supports `--output-format stream-json`, `--setting-sources`, `-r`
# For Gemini: Ensure it supports `-o stream-json`, `-y`, `-r`, `-p`
# Update your backend CLI to the latest version if needed
```
**JSON parsing errors:**
```bash
# If you see "failed to parse JSON output" errors
# Verify the backend outputs stream-json format
codex e --json "test task" # Should output newline-delimited JSON
claude --output-format stream-json -p "test" # Should output stream JSON
# If not, your backend CLI version may be too old or incompatible
```
**Infinite recursion with Claude backend:**
```bash
# The wrapper prevents this with `--setting-sources ""` flag
# If you still see recursion, ensure your Claude CLI supports this flag
claude --help | grep "setting-sources"
# If flag is not supported, upgrade Claude CLI
```
**Session resume failures:**
```bash
# Check if session ID is valid
codex history # List recent sessions
claude history
# Ensure backend CLI supports session resumption
codex resume <session_id> "test" # Should continue from previous session
claude -r <session_id> "test"
# If not supported, use new sessions instead of resume mode
```
---
## FAQ (Frequently Asked Questions)
### Q1: `codeagent-wrapper` execution fails with "Unknown event format"
**Problem:**
```
Unknown event format: {"type":"turn.started"}
Unknown event format: {"type":"assistant", ...}
```
**Solution:**
This is a logging event format display issue and does not affect actual functionality. It will be fixed in the next version. You can ignore these log outputs.
**Related Issue:** [#96](https://github.com/cexll/myclaude/issues/96)
---
### Q2: Gemini cannot read files ignored by `.gitignore`
**Problem:**
When using `codeagent-wrapper --backend gemini`, files in directories like `.claude/` that are ignored by `.gitignore` cannot be read.
**Solution:**
- **Option 1:** Remove `.claude/` from your `.gitignore` file
- **Option 2:** Ensure files that need to be read are not in `.gitignore` list
**Related Issue:** [#75](https://github.com/cexll/myclaude/issues/75)
---
### Q3: `/dev` command parallel execution is very slow
**Problem:**
Using `/dev` command for simple features takes too long (over 30 minutes) with no visibility into task progress.
**Solution:**
1. **Check logs:** Review `C:\Users\User\AppData\Local\Temp\codeagent-wrapper-*.log` to identify bottlenecks
2. **Adjust backend:**
- Try faster models like `gpt-5.1-codex-max`
- Running in WSL may be significantly faster
3. **Workspace:** Use a single repository instead of monorepo with multiple sub-projects
**Related Issue:** [#77](https://github.com/cexll/myclaude/issues/77)
---
### Q4: Codex permission denied with new Go version
**Problem:**
After upgrading to the new Go-based Codex implementation, execution fails with permission denied errors.
**Solution:**
Add the following configuration to `~/.codex/config.yaml` (Windows: `c:\user\.codex\config.toml`):
```yaml
model = "gpt-5.1-codex-max"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
approval_policy = "never"
sandbox_mode = "workspace-write"
disable_response_storage = true
network_access = true
```
**Key settings:**
- `approval_policy = "never"` - Remove approval restrictions
- `sandbox_mode = "workspace-write"` - Allow workspace write access
- `network_access = true` - Enable network access
**Related Issue:** [#31](https://github.com/cexll/myclaude/issues/31)
---
### Q5: How to disable default bypass/skip-permissions mode
**Background:**
By default, codeagent-wrapper enables bypass mode for both Codex and Claude backends:
- `CODEX_BYPASS_SANDBOX=true` - Bypasses Codex sandbox restrictions
- `CODEAGENT_SKIP_PERMISSIONS=true` - Skips Claude permission prompts
**To disable (if you need sandbox/permission protection):**
```bash
export CODEX_BYPASS_SANDBOX=false
export CODEAGENT_SKIP_PERMISSIONS=false
```
Or add to your shell profile (`~/.zshrc` or `~/.bashrc`):
```bash
echo 'export CODEX_BYPASS_SANDBOX=false' >> ~/.zshrc
echo 'export CODEAGENT_SKIP_PERMISSIONS=false' >> ~/.zshrc
```
**Note:** Disabling bypass mode will require manual approval for certain operations.
---
**Still having issues?** Visit [GitHub Issues](https://github.com/cexll/myclaude/issues) to search or report new issues.
---
## Documentation
### Core Guides
- **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
- **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation

View File

@@ -2,7 +2,7 @@
[![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)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude)
[![Version](https://img.shields.io/badge/Version-5.6-green)](https://github.com/cexll/myclaude)
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
@@ -30,6 +30,41 @@ python3 install.py --install-dir ~/.claude
## 工作流概览
### 0. OmO 多智能体编排器(复杂任务推荐)
**基于风险信号智能路由任务到专业智能体的多智能体编排系统。**
```bash
/omo "分析并修复这个认证 bug"
```
**智能体层级:**
| 智能体 | 角色 | 后端 | 模型 |
|-------|------|------|------|
| `oracle` | 技术顾问 | Claude | claude-opus-4-5 |
| `librarian` | 外部研究 | Claude | claude-sonnet-4-5 |
| `explore` | 代码库搜索 | OpenCode | grok-code |
| `develop` | 代码实现 | Codex | gpt-5.2 |
| `frontend-ui-ux-engineer` | UI/UX 专家 | Gemini | gemini-3-pro |
| `document-writer` | 文档撰写 | Gemini | gemini-3-flash |
**路由信号(非固定流水线):**
- 代码位置不明确 → `explore`
- 外部库/API → `librarian`
- 高风险/多文件变更 → `oracle`
- 需要实现 → `develop` / `frontend-ui-ux-engineer`
**常用配方:**
- 解释代码:`explore`
- 位置已知的小修复:直接 `develop`
- Bug 修复,位置未知:`explore → develop`
- 跨模块重构:`explore → oracle → develop`
- 外部 API 集成:`explore + librarian → oracle → develop`
**适用场景:** 复杂 bug 调查、多文件重构、架构决策
---
### 1. Dev 工作流(推荐)
**大多数开发任务的首选工作流。**
@@ -152,15 +187,39 @@ python3 install.py --force
```
~/.claude/
├── CLAUDE.md # 核心指令和角色定义
├── commands/ # 斜杠命令 (/dev, /code 等)
├── agents/ # 智能体定义
├── bin/
│ └── codeagent-wrapper # 主可执行文件
├── CLAUDE.md # 核心指令和角色定义
├── commands/ # 斜杠命令 (/dev, /code 等)
├── agents/ # 智能体定义
├── skills/
│ └── codex/
│ └── SKILL.md # Codex 集成技能
── installed_modules.json # 安装状态
│ └── SKILL.md # Codex 集成技能
── config.json # 配置文件
└── installed_modules.json # 安装状态
```
### 自定义安装目录
默认情况下myclaude 安装到 `~/.claude`。您可以使用 `INSTALL_DIR` 环境变量自定义安装目录:
```bash
# 安装到自定义目录
INSTALL_DIR=/opt/myclaude bash install.sh
# 相应更新您的 PATH
export PATH="/opt/myclaude/bin:$PATH"
```
**目录结构:**
- `$INSTALL_DIR/bin/` - codeagent-wrapper 可执行文件
- `$INSTALL_DIR/skills/` - 技能定义
- `$INSTALL_DIR/config.json` - 配置文件
- `$INSTALL_DIR/commands/` - 斜杠命令定义
- `$INSTALL_DIR/agents/` - 智能体定义
**注意:** 使用自定义安装目录时,请确保将 `$INSTALL_DIR/bin` 添加到您的 `PATH` 环境变量中。
### 配置
编辑 `config.json` 自定义:
@@ -258,8 +317,10 @@ $Env:PATH = "$HOME\bin;$Env:PATH"
```
```batch
REM cmd.exe - 永久添加(当前用户)
setx PATH "%USERPROFILE%\bin;%PATH%"
REM cmd.exe - 永久添加(当前用户)(建议使用上面的 PowerShell 方法)
REM 警告:此命令会展开 %PATH% 包含系统 PATH导致重复
REM 注意:使用 reg add 而非 setx 以避免 1024 字符截断限制
reg add "HKCU\Environment" /v Path /t REG_EXPAND_SZ /d "%USERPROFILE%\bin;%PATH%" /f
```
---
@@ -283,11 +344,14 @@ setx PATH "%USERPROFILE%\bin;%PATH%"
**Codex wrapper 未找到:**
```bash
# 检查 PATH
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
# 安装程序会自动添加 PATH检查是否已添加
if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
echo "PATH not configured. Reinstalling..."
bash install.sh
fi
# 重新安装
bash install.sh
# 或手动添加(幂等性命令)
[[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
```
**权限被拒绝:**
@@ -306,6 +370,105 @@ python3 install.py --module dev --force
---
## 常见问题 (FAQ)
### Q1: `codeagent-wrapper` 执行时报错 "Unknown event format"
**问题描述:**
执行 `codeagent-wrapper` 时出现错误:
```
Unknown event format: {"type":"turn.started"}
Unknown event format: {"type":"assistant", ...}
```
**解决方案:**
这是日志事件流的显示问题,不影响实际功能执行。预计在下个版本中修复。如需排查其他问题,可忽略此日志输出。
**相关 Issue** [#96](https://github.com/cexll/myclaude/issues/96)
---
### Q2: Gemini 无法读取 `.gitignore` 忽略的文件
**问题描述:**
使用 `codeagent-wrapper --backend gemini` 时,无法读取 `.claude/` 等被 `.gitignore` 忽略的目录中的文件。
**解决方案:**
- **方案一:** 在项目根目录的 `.gitignore` 中取消对 `.claude/` 的忽略
- **方案二:** 确保需要读取的文件不在 `.gitignore` 忽略列表中
**相关 Issue** [#75](https://github.com/cexll/myclaude/issues/75)
---
### Q3: `/dev` 命令并行执行特别慢
**问题描述:**
使用 `/dev` 命令开发简单功能耗时过长超过30分钟无法了解任务执行状态。
**解决方案:**
1. **检查日志:** 查看 `C:\Users\User\AppData\Local\Temp\codeagent-wrapper-*.log` 分析瓶颈
2. **调整后端:**
- 尝试使用 `gpt-5.1-codex-max` 等更快的模型
- 在 WSL 环境下运行速度可能更快
3. **工作区选择:** 使用独立的代码仓库而非包含多个子项目的 monorepo
**相关 Issue** [#77](https://github.com/cexll/myclaude/issues/77)
---
### Q4: 新版 Go 实现的 Codex 权限不足
**问题描述:**
升级到新版 Go 实现的 Codex 后,出现权限不足的错误。
**解决方案:**
`~/.codex/config.yaml` 中添加以下配置Windows: `c:\user\.codex\config.toml`
```yaml
model = "gpt-5.1-codex-max"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
approval_policy = "never"
sandbox_mode = "workspace-write"
disable_response_storage = true
network_access = true
```
**关键配置说明:**
- `approval_policy = "never"` - 移除审批限制
- `sandbox_mode = "workspace-write"` - 允许工作区写入权限
- `network_access = true` - 启用网络访问
**相关 Issue** [#31](https://github.com/cexll/myclaude/issues/31)
---
### Q5: 执行时遇到权限拒绝或沙箱限制
**问题描述:**
运行 codeagent-wrapper 时出现权限错误或沙箱限制。
**解决方案:**
设置以下环境变量:
```bash
export CODEX_BYPASS_SANDBOX=true
export CODEAGENT_SKIP_PERMISSIONS=true
```
或添加到 shell 配置文件(`~/.zshrc``~/.bashrc`
```bash
echo 'export CODEX_BYPASS_SANDBOX=true' >> ~/.zshrc
echo 'export CODEAGENT_SKIP_PERMISSIONS=true' >> ~/.zshrc
```
**注意:** 这些设置会绕过安全限制,请仅在可信环境中使用。
---
**仍有疑问?** 请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues) 搜索或提交新问题。
---
## 许可证
AGPL-3.0 License - 查看 [LICENSE](LICENSE)

View File

@@ -1,37 +0,0 @@
{
"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

@@ -0,0 +1,9 @@
{
"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

@@ -427,6 +427,10 @@ Generate architecture document at `./.claude/specs/{feature_name}/02-system-arch
## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, REST, GraphQL, JWT, RBAC, etc.) in English; translate explanatory text only.
### DO:
- Start by reviewing and referencing the PRD
- Present initial architecture based on requirements

View File

@@ -419,6 +419,10 @@ logger.info('User created', {
## Important Implementation Rules
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, CRUD, JWT, SQL, etc.) in English; translate explanatory text only.
### DO:
- Follow architecture specifications exactly
- Implement all acceptance criteria from PRD

View File

@@ -22,6 +22,10 @@ You are the BMAD Orchestrator. Your core focus is repository analysis, workflow
- Consistency: ensure conventions and patterns discovered in scan are preserved downstream
- Explicit handoffs: clearly document assumptions, risks, and integration points for other agents
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
## UltraThink Repository Scan
When asked to analyze the repository, follow this structure and return a clear, actionable summary.

View File

@@ -313,6 +313,10 @@ Generate PRD at `./.claude/specs/{feature_name}/01-product-requirements.md`:
## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, Sprint, PRD, KPI, MVP, etc.) in English; translate explanatory text only.
### DO:
- Start immediately with greeting and initial understanding
- Show quality scores transparently

View File

@@ -478,6 +478,10 @@ module.exports = {
## Important Testing Rules
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
### DO:
- Test all acceptance criteria from PRD
- Cover happy path, edge cases, and error scenarios

View File

@@ -45,3 +45,7 @@ You are an independent code review agent responsible for conducting reviews betw
- Focus on actionable findings
- Provide specific QA guidance
- Use clear, parseable output format
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.

View File

@@ -351,6 +351,10 @@ So that [benefit]
## Important Behaviors
### Language Rules:
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
- **Technical Terms**: Keep technical terms (Sprint, Epic, Story, Backlog, Velocity, etc.) in English; translate explanatory text only.
### DO:
- Read both PRD and Architecture documents thoroughly
- Create comprehensive task breakdown

72
cliff.toml Normal file
View File

@@ -0,0 +1,72 @@
# git-cliff configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# changelog header
header = """
# Changelog
All notable changes to this project will be documented in this file.
"""
# template for the changelog body
body = """
{% if version %}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}
## Unreleased
{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first }}
{% endfor -%}
{% endfor -%}
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = false
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/cexll/myclaude/issues/${2}))" },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "🚀 Features" },
{ message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^doc", group = "📚 Documentation" },
{ message = "^perf", group = "⚡ Performance" },
{ message = "^refactor", group = "🚜 Refactor" },
{ message = "^style", group = "🎨 Styling" },
{ message = "^test", group = "🧪 Testing" },
{ message = "^chore\\(release\\):", skip = true },
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "🛡️ Security" },
{ message = "^revert", group = "◀️ Revert" },
{ message = ".*", group = "💼 Other" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

View File

@@ -0,0 +1,39 @@
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
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Test
run: make test
- name: Build
run: make build
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Lint
run: make lint

View File

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

View File

@@ -0,0 +1,38 @@
GO ?= go
BINARY ?= codeagent
CMD_PKG := ./cmd/codeagent
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 -o $(BINARY) $(CMD_PKG)
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 $(CMD_PKG)

151
codeagent-wrapper/README.md Normal file
View File

@@ -0,0 +1,151 @@
# codeagent-wrapper
`codeagent-wrapper` 是一个用 Go 编写的“多后端 AI 代码代理”命令行包装器:用统一的 CLI 入口封装不同的 AI 工具后端Codex / Claude / Gemini / Opencode并提供一致的参数、配置与会话恢复体验。
入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)。
## 功能特性
- 多后端支持:`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
```
安装后确认:
```bash
codeagent 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
```

View File

@@ -1,74 +0,0 @@
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")
// }
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

@@ -1,122 +0,0 @@
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", "--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", "--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", "-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", "--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

@@ -1,39 +0,0 @@
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

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

View File

@@ -1,272 +0,0 @@
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"`
}
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

@@ -1,884 +0,0 @@
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
}
var runCodexTaskFn = func(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)
}
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 string) {
logger := activeLogger()
if logger == nil {
return
}
path := logger.Path()
if path == "" {
return
}
startPrintMu.Lock()
if !bannerPrinted {
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
bannerPrinted = true
}
fmt.Fprintf(os.Stderr, "Task %s: Log: %s\n", taskID, path)
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()
defer func() {
if r := recover(); r != nil {
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r)}
}
}()
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)
}()
ts.Context = ctx
printTaskStart(ts.ID)
resultsCh <- runCodexTaskFn(ts, timeout)
}(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 != "" {
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 {
result := TaskResult{TaskID: taskSpec.ID}
setLogPath := func() {
if result.LogPath != "" {
return
}
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
}
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 := activeLogger(); logger != nil {
logger.Info(prefixMsg(msg))
}
}
logWarnFn = func(msg string) {
if logger := activeLogger(); logger != nil {
logger.Warn(prefixMsg(msg))
}
}
logErrorFn = func(msg string) {
if logger := activeLogger(); 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 silent && activeLogger() == nil {
if l, err := NewLogger(); err == nil {
setLogger(l)
tempLogger = l
}
}
defer func() {
if tempLogger != nil {
_ = closeLogger()
}
}()
defer setLogPath()
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
if !silent {
stdoutLogger = newLogWriter("CODEX_STDOUT: ", codexLogLineLimit)
stderrLogger = newLogWriter("CODEX_STDERR: ", codexLogLineLimit)
}
ctx := parentCtx
if ctx == nil {
ctx = context.Background()
}
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)
}
if !silent {
stderrWriters = append([]io.Writer{os.Stderr}, 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 := activeLogger(); 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 logger := activeLogger(); logger != nil {
result.LogPath = logger.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,577 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
type execFakeProcess struct {
pid int
signals []os.Signal
killed atomic.Int32
mu sync.Mutex
}
func (p *execFakeProcess) Pid() int { return p.pid }
func (p *execFakeProcess) Kill() error {
p.killed.Add(1)
return nil
}
func (p *execFakeProcess) Signal(sig os.Signal) error {
p.mu.Lock()
p.signals = append(p.signals, sig)
p.mu.Unlock()
return nil
}
type writeCloserStub struct {
bytes.Buffer
closed atomic.Bool
}
func (w *writeCloserStub) Close() error {
w.closed.Store(true)
return nil
}
type reasonReadCloser struct {
r io.Reader
closed []string
mu sync.Mutex
closedC chan struct{}
}
func newReasonReadCloser(data string) *reasonReadCloser {
return &reasonReadCloser{r: strings.NewReader(data), closedC: make(chan struct{}, 1)}
}
func (rc *reasonReadCloser) Read(p []byte) (int, error) { return rc.r.Read(p) }
func (rc *reasonReadCloser) Close() error { rc.record("close"); return nil }
func (rc *reasonReadCloser) CloseWithReason(reason string) error {
rc.record(reason)
return nil
}
func (rc *reasonReadCloser) record(reason string) {
rc.mu.Lock()
rc.closed = append(rc.closed, reason)
rc.mu.Unlock()
select {
case rc.closedC <- struct{}{}:
default:
}
}
type execFakeRunner struct {
stdout io.ReadCloser
process processHandle
stdin io.WriteCloser
waitErr error
waitDelay time.Duration
startErr error
stdoutErr error
stdinErr error
allowNilProcess bool
started atomic.Bool
}
func (f *execFakeRunner) Start() error {
if f.startErr != nil {
return f.startErr
}
f.started.Store(true)
return nil
}
func (f *execFakeRunner) Wait() error {
if f.waitDelay > 0 {
time.Sleep(f.waitDelay)
}
return f.waitErr
}
func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
if f.stdoutErr != nil {
return nil, f.stdoutErr
}
if f.stdout == nil {
f.stdout = io.NopCloser(strings.NewReader(""))
}
return f.stdout, nil
}
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
if f.stdinErr != nil {
return nil, f.stdinErr
}
if f.stdin != nil {
return f.stdin, nil
}
return &writeCloserStub{}, nil
}
func (f *execFakeRunner) SetStderr(io.Writer) {}
func (f *execFakeRunner) SetDir(string) {}
func (f *execFakeRunner) Process() processHandle {
if f.process != nil {
return f.process
}
if f.allowNilProcess {
return nil
}
return &execFakeProcess{pid: 1}
}
func TestExecutorHelperCoverage(t *testing.T) {
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)
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)
rp := &realProcess{}
if rp.Pid() != 0 {
t.Fatalf("nil process should have pid 0")
}
if rp.Kill() != 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)
})
t.Run("topologicalSortAndSkip", func(t *testing.T) {
layers, err := topologicalSort([]TaskSpec{{ID: "root"}, {ID: "child", Dependencies: []string{"root"}}})
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) {
var firstStdout *reasonReadCloser
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`)
if firstStdout == nil {
firstStdout = rc
}
return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}}
}
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 {
t.Fatalf("unexpected result: %+v", res)
}
select {
case <-firstStdout.closedC:
case <-time.After(1 * time.Second):
t.Fatalf("stdout not closed with reason")
}
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
}
t.Cleanup(func() { runCodexTaskFn = orig })
if res := runCodexTask(TaskSpec{Task: "task-text", WorkDir: "."}, true, 1); res.ExitCode != 0 {
t.Fatalf("runCodexTask failed: %+v", res)
}
msg, threadID, code := runCodexProcess(context.Background(), []string{"arg"}, "content", false, 1)
if code != 0 || msg == "" {
t.Fatalf("runCodexProcess unexpected result: msg=%q code=%d threadID=%s", msg, code, threadID)
}
})
t.Run("startErrors", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode != 127 {
t.Fatalf("expected missing executable exit code, got %d", res.ExitCode)
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}}
}
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on start failure")
}
})
t.Run("timeoutAndPipes", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`),
process: &execFakeProcess{pid: 5},
waitDelay: 20 * time.Millisecond,
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0)
if res.ExitCode == 0 {
t.Fatalf("expected timeout result, got %+v", res)
}
})
t.Run("pipeErrors", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdout pipe error")
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
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)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdin pipe error")
}
})
t.Run("waitExitError", func(t *testing.T) {
err := exec.Command("false").Run()
exitErr, _ := err.(*exec.ExitError)
if exitErr == nil {
t.Fatalf("expected exec.ExitError")
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`),
process: &execFakeProcess{pid: 8},
waitErr: exitErr,
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on wait error")
}
})
t.Run("contextCancelled", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`),
process: &execFakeProcess{pid: 9},
waitDelay: 10 * time.Millisecond,
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected cancellation result")
}
})
t.Run("silentLogger", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`),
process: &execFakeProcess{pid: 10},
}
}
_ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath == "" {
t.Fatalf("expected success with temp logger, got %+v", res)
}
_ = closeLogger()
})
t.Run("missingMessage", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`),
process: &execFakeProcess{pid: 11},
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure when no agent_message returned")
}
})
}
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)
}

View File

@@ -1,3 +1,43 @@
module codeagent-wrapper
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
)

117
codeagent-wrapper/go.sum Normal file
View File

@@ -0,0 +1,117 @@
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

@@ -0,0 +1,150 @@
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

@@ -0,0 +1,278 @@
package wrapper
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
const (
version = "6.0.0-alpha1"
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 = 100 * 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

@@ -0,0 +1,9 @@
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

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

View File

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

View File

@@ -0,0 +1,103 @@
package wrapper
import (
"bytes"
"os"
"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)
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

@@ -0,0 +1,663 @@
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) {
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
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo = config.ResolveAgentConfig(agentName)
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,
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,4 +1,4 @@
package main
package wrapper
import (
"bufio"
@@ -11,8 +11,29 @@ import (
"sync/atomic"
"testing"
"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 高并发压力测试
func TestConcurrentStressLogger(t *testing.T) {
if testing.Short() {
@@ -76,10 +97,11 @@ func TestConcurrentStressLogger(t *testing.T) {
t.Logf("Successfully wrote %d/%d logs (%.1f%%)",
actualCount, totalExpected, float64(actualCount)/float64(totalExpected)*100)
// 验证日志格式
formatRE := regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[PID:\d+\] INFO: goroutine-`)
// 验证日志格式(纯文本,无前缀)
formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
for i, line := range lines[:min(10, len(lines))] {
if !formatRE.MatchString(line) {
msg := stripTimestampPrefix(line)
if !formatRE.MatchString(msg) {
t.Errorf("line %d has invalid format: %s", i, line)
}
}
@@ -291,18 +313,15 @@ func TestLoggerOrderPreservation(t *testing.T) {
sequences := make(map[int][]int) // goroutine ID -> sequence numbers
for scanner.Scan() {
line := scanner.Text()
line := stripTimestampPrefix(scanner.Text())
var gid, seq int
parts := strings.SplitN(line, " INFO: ", 2)
if len(parts) != 2 {
t.Errorf("invalid log format: %s", line)
// Parse format: G0-SEQ0001 (without INFO: prefix)
_, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)
if err != nil {
t.Errorf("invalid log format: %s (error: %v)", line, err)
continue
}
if _, err := fmt.Sscanf(parts[1], "G%d-SEQ%d", &gid, &seq); err == nil {
sequences[gid] = append(sequences[gid], seq)
} else {
t.Errorf("failed to parse sequence from line: %s", line)
}
sequences[gid] = append(sequences[gid], seq)
}
// 验证每个 goroutine 内部顺序

View File

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,54 @@
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
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,7 +1,8 @@
package main
package wrapper
import (
"bytes"
"codeagent-wrapper/internal/logger"
"fmt"
"io"
"os"
@@ -36,7 +37,9 @@ func captureStdout(t *testing.T, fn func()) string {
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy() error = %v", err)
}
return buf.String()
}
@@ -46,33 +49,127 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
lines := strings.Split(out, "\n")
var currentTask *TaskResult
inTaskResults := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Total:") {
// Parse new format header: "X tasks | Y passed | Z failed"
if strings.Contains(line, "tasks |") && strings.Contains(line, "passed |") {
parts := strings.Split(line, "|")
for _, p := range parts {
p = strings.TrimSpace(p)
if strings.HasSuffix(p, "tasks") {
if _, err := fmt.Sscanf(p, "%d tasks", &payload.Summary.Total); err != nil {
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
}
} else if strings.HasSuffix(p, "passed") {
if _, err := fmt.Sscanf(p, "%d passed", &payload.Summary.Success); err != nil {
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
}
} else if strings.HasSuffix(p, "failed") {
if _, err := fmt.Sscanf(p, "%d failed", &payload.Summary.Failed); err != nil {
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
}
}
}
} else if strings.HasPrefix(line, "Total:") {
// Legacy format: "Total: X | Success: Y | Failed: Z"
parts := strings.Split(line, "|")
for _, p := range parts {
p = strings.TrimSpace(p)
if strings.HasPrefix(p, "Total:") {
fmt.Sscanf(p, "Total: %d", &payload.Summary.Total)
if _, err := fmt.Sscanf(p, "Total: %d", &payload.Summary.Total); err != nil {
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
}
} else if strings.HasPrefix(p, "Success:") {
fmt.Sscanf(p, "Success: %d", &payload.Summary.Success)
if _, err := fmt.Sscanf(p, "Success: %d", &payload.Summary.Success); err != nil {
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
}
} else if strings.HasPrefix(p, "Failed:") {
fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed)
if _, err := fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed); err != nil {
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
}
}
}
} else if line == "## Task Results" {
inTaskResults = true
} else if line == "## Summary" {
// End of task results section
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
currentTask = nil
}
inTaskResults = false
} else if inTaskResults && strings.HasPrefix(line, "### ") {
// New task: ### task-id ✓ 92% or ### task-id PASS 92% (ASCII mode)
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
}
currentTask = &TaskResult{}
taskLine := strings.TrimPrefix(line, "### ")
parseMarker := func(marker string, exitCode int) bool {
needle := " " + marker
if !strings.Contains(taskLine, needle) {
return false
}
parts := strings.Split(taskLine, needle)
currentTask.TaskID = strings.TrimSpace(parts[0])
currentTask.ExitCode = exitCode
if exitCode == 0 && len(parts) > 1 {
coveragePart := strings.TrimSpace(parts[1])
if strings.HasSuffix(coveragePart, "%") {
currentTask.Coverage = coveragePart
}
}
return true
}
switch {
case parseMarker("✓", 0), parseMarker("PASS", 0):
// ok
case parseMarker("⚠️", 0), parseMarker("WARN", 0):
// warning
case parseMarker("✗", 1), parseMarker("FAIL", 1):
// fail
default:
currentTask.TaskID = taskLine
}
} else if currentTask != nil && inTaskResults {
// Parse task details
if strings.HasPrefix(line, "Exit code:") {
if _, err := fmt.Sscanf(line, "Exit code: %d", &currentTask.ExitCode); err != nil {
t.Fatalf("failed to parse exit code from %q: %v", line, err)
}
} else if strings.HasPrefix(line, "Error:") {
currentTask.Error = strings.TrimPrefix(line, "Error: ")
} else if strings.HasPrefix(line, "Log:") {
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
} else if strings.HasPrefix(line, "Did:") {
currentTask.KeyOutput = strings.TrimSpace(strings.TrimPrefix(line, "Did:"))
} else if strings.HasPrefix(line, "Detail:") {
// Error detail for failed tasks
if currentTask.Message == "" {
currentTask.Message = strings.TrimSpace(strings.TrimPrefix(line, "Detail:"))
}
}
} else if strings.HasPrefix(line, "--- Task:") {
// Legacy full output format
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
}
currentTask = &TaskResult{}
currentTask.TaskID = strings.TrimSuffix(strings.TrimPrefix(line, "--- Task: "), " ---")
} else if currentTask != nil {
} else if currentTask != nil && !inTaskResults {
// Legacy format parsing
if strings.HasPrefix(line, "Status: SUCCESS") {
currentTask.ExitCode = 0
} else if strings.HasPrefix(line, "Status: FAILED") {
if strings.Contains(line, "exit code") {
fmt.Sscanf(line, "Status: FAILED (exit code %d)", &currentTask.ExitCode)
if _, err := fmt.Sscanf(line, "Status: FAILED (exit code %d)", &currentTask.ExitCode); err != nil {
t.Fatalf("failed to parse exit code from %q: %v", line, err)
}
} else {
currentTask.ExitCode = 1
}
@@ -82,15 +179,11 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
currentTask.SessionID = strings.TrimPrefix(line, "Session: ")
} else if strings.HasPrefix(line, "Log:") {
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
} else if line != "" && !strings.HasPrefix(line, "===") && !strings.HasPrefix(line, "---") {
if currentTask.Message != "" {
currentTask.Message += "\n"
}
currentTask.Message += line
}
}
}
// Handle last task
if currentTask != nil {
payload.Results = append(payload.Results, *currentTask)
}
@@ -98,32 +191,6 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
return payload
}
func extractTaskBlock(t *testing.T, output, taskID string) string {
t.Helper()
header := fmt.Sprintf("--- Task: %s ---", taskID)
lines := strings.Split(output, "\n")
var block []string
collecting := false
for _, raw := range lines {
trimmed := strings.TrimSpace(raw)
if !collecting {
if trimmed == header {
collecting = true
block = append(block, trimmed)
}
continue
}
if strings.HasPrefix(trimmed, "--- Task: ") && trimmed != header {
break
}
block = append(block, trimmed)
}
if len(block) == 0 {
t.Fatalf("task block %s not found in output:\n%s", taskID, output)
}
return strings.Join(block, "\n")
}
func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResult {
t.Helper()
for _, res := range payload.Results {
@@ -135,6 +202,37 @@ func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResu
return TaskResult{}
}
func setTempDirEnv(t *testing.T, dir string) string {
t.Helper()
resolved := dir
if eval, err := filepath.EvalSymlinks(dir); err == nil {
resolved = eval
}
t.Setenv("TMPDIR", resolved)
t.Setenv("TEMP", resolved)
t.Setenv("TMP", resolved)
return resolved
}
func createTempLog(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create temp log %s: %v", path, err)
}
return path
}
func stubProcessRunning(t *testing.T, fn func(int) bool) {
t.Helper()
t.Cleanup(logger.SetProcessRunningCheck(fn))
}
func stubProcessStartTime(t *testing.T, fn func(int) time.Time) {
t.Helper()
t.Cleanup(logger.SetProcessStartTimeFn(fn))
}
func TestRunParallelEndToEnd_OrderAndConcurrency(t *testing.T) {
defer resetTestHooks()
origRun := runCodexTaskFn
@@ -320,7 +418,7 @@ id: beta
---CONTENT---
task-beta`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
var exitCode int
output := captureStdout(t, func() {
@@ -343,9 +441,10 @@ task-beta`
}
for _, id := range []string{"alpha", "beta"} {
want := fmt.Sprintf("Log: %s", logPathFor(id))
if !strings.Contains(output, want) {
t.Fatalf("parallel output missing %q for %s:\n%s", want, id, output)
// Summary mode shows log paths in table format, not "Log: xxx"
logPath := logPathFor(id)
if !strings.Contains(output, logPath) {
t.Fatalf("parallel output missing log path %q for %s:\n%s", logPath, id, output)
}
}
}
@@ -372,9 +471,9 @@ id: d
---CONTENT---
ok-d`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
@@ -426,10 +525,11 @@ ok-d`
t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut)
}
// After parallel log isolation fix, each task has its own log file
expectedLines := map[string]struct{}{
fmt.Sprintf("Task a: Log: %s", expectedLog): {},
fmt.Sprintf("Task b: Log: %s", expectedLog): {},
fmt.Sprintf("Task d: Log: %s", expectedLog): {},
fmt.Sprintf("Task a: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-a.log", os.Getpid()))): {},
fmt.Sprintf("Task b: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-b.log", os.Getpid()))): {},
fmt.Sprintf("Task d: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-d.log", os.Getpid()))): {},
}
if len(taskLines) != len(expectedLines) {
@@ -447,7 +547,7 @@ func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
defer resetTestHooks()
tempDir := setTempDirEnv(t, t.TempDir())
os.Args = []string{"codex-wrapper", "integration-log-check"}
os.Args = []string{"codeagent-wrapper", "integration-log-check"}
stdinReader = strings.NewReader("")
isTerminalFn = func() bool { return true }
codexCommand = "echo"
@@ -465,7 +565,7 @@ func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
if exitCode != 0 {
t.Fatalf("run() exit=%d, want 0", exitCode)
}
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
wantLine := fmt.Sprintf("Log: %s", expectedLog)
if !strings.Contains(stderr, wantLine) {
t.Fatalf("stderr missing %q, got: %q", wantLine, stderr)
@@ -549,16 +649,16 @@ ok-e`
if resD.LogPath != logPathFor("D") || resE.LogPath != logPathFor("E") {
t.Fatalf("expected log paths for D/E, got D=%q E=%q", resD.LogPath, resE.LogPath)
}
// Summary mode shows log paths in table, verify they appear in output
for _, id := range []string{"A", "D", "E"} {
block := extractTaskBlock(t, output, id)
want := fmt.Sprintf("Log: %s", logPathFor(id))
if !strings.Contains(block, want) {
t.Fatalf("task %s block missing %q:\n%s", id, want, block)
logPath := logPathFor(id)
if !strings.Contains(output, logPath) {
t.Fatalf("task %s log path %q not found in output:\n%s", id, logPath, output)
}
}
blockB := extractTaskBlock(t, output, "B")
if strings.Contains(blockB, "Log:") {
t.Fatalf("skipped task B should not emit a log line:\n%s", blockB)
// Task B was skipped, should have "-" or empty log path in table
if resB.LogPath != "" {
t.Fatalf("skipped task B should have empty log path, got %q", resB.LogPath)
}
}
@@ -568,7 +668,6 @@ func TestRunParallelTimeoutPropagation(t *testing.T) {
t.Cleanup(func() {
runCodexTaskFn = origRun
resetTestHooks()
os.Unsetenv("CODEX_TIMEOUT")
})
var receivedTimeout int
@@ -577,7 +676,7 @@ func TestRunParallelTimeoutPropagation(t *testing.T) {
return TaskResult{TaskID: task.ID, ExitCode: 124, Error: "timeout"}
}
os.Setenv("CODEX_TIMEOUT", "1")
t.Setenv("CODEX_TIMEOUT", "1")
input := `---TASK---
id: T
---CONTENT---
@@ -647,11 +746,11 @@ func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
orphanA := createTempLog(t, tempDir, "codex-wrapper-5001.log")
orphanB := createTempLog(t, tempDir, "codex-wrapper-5002-extra.log")
orphanC := createTempLog(t, tempDir, "codex-wrapper-5003-suffix.log")
orphanA := createTempLog(t, tempDir, "codeagent-wrapper-5001.log")
orphanB := createTempLog(t, tempDir, "codeagent-wrapper-5002-extra.log")
orphanC := createTempLog(t, tempDir, "codeagent-wrapper-5003-suffix.log")
runningPID := 81234
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", runningPID))
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", runningPID))
unrelated := createTempLog(t, tempDir, "wrapper.log")
stubProcessRunning(t, func(pid int) bool {
@@ -667,7 +766,7 @@ func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
codexCommand = createFakeCodexScript(t, "tid-startup", "ok")
stdinReader = strings.NewReader("")
isTerminalFn = func() bool { return true }
os.Args = []string{"codex-wrapper", "task"}
os.Args = []string{"codeagent-wrapper", "task"}
if exit := run(); exit != 0 {
t.Fatalf("run() exit=%d, want 0", exit)
@@ -693,7 +792,7 @@ func TestRunStartupCleanupConcurrentWrappers(t *testing.T) {
const totalLogs = 40
for i := 0; i < totalLogs; i++ {
createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", 9000+i))
createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", 9000+i))
}
stubProcessRunning(t, func(pid int) bool {
@@ -717,7 +816,7 @@ func TestRunStartupCleanupConcurrentWrappers(t *testing.T) {
close(start)
wg.Wait()
matches, err := filepath.Glob(filepath.Join(tempDir, "codex-wrapper-*.log"))
matches, err := filepath.Glob(filepath.Join(tempDir, "codeagent-wrapper-*.log"))
if err != nil {
t.Fatalf("glob error: %v", err)
}
@@ -731,9 +830,9 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
staleA := createTempLog(t, tempDir, "codex-wrapper-2100.log")
staleB := createTempLog(t, tempDir, "codex-wrapper-2200-extra.log")
keeper := createTempLog(t, tempDir, "codex-wrapper-2300.log")
staleA := createTempLog(t, tempDir, "codeagent-wrapper-2100.log")
staleB := createTempLog(t, tempDir, "codeagent-wrapper-2200-extra.log")
keeper := createTempLog(t, tempDir, "codeagent-wrapper-2300.log")
stubProcessRunning(t, func(pid int) bool {
return pid == 2300 || pid == os.Getpid()
@@ -745,7 +844,7 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
return time.Time{}
})
os.Args = []string{"codex-wrapper", "--cleanup"}
os.Args = []string{"codeagent-wrapper", "--cleanup"}
var exitCode int
output := captureStdout(t, func() {
@@ -769,10 +868,10 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
if !strings.Contains(output, "Files kept: 1") {
t.Fatalf("missing 'Files kept: 1' in output: %q", output)
}
if !strings.Contains(output, "codex-wrapper-2100.log") || !strings.Contains(output, "codex-wrapper-2200-extra.log") {
if !strings.Contains(output, "codeagent-wrapper-2100.log") || !strings.Contains(output, "codeagent-wrapper-2200-extra.log") {
t.Fatalf("missing deleted file names in output: %q", output)
}
if !strings.Contains(output, "codex-wrapper-2300.log") {
if !strings.Contains(output, "codeagent-wrapper-2300.log") {
t.Fatalf("missing kept file names in output: %q", output)
}
@@ -785,7 +884,7 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
t.Fatalf("expected kept log to remain, err=%v", err)
}
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
currentLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
if _, err := os.Stat(currentLog); err == nil {
t.Fatalf("cleanup mode should not create new log file %s", currentLog)
} else if !os.IsNotExist(err) {
@@ -804,7 +903,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
return CleanupStats{Scanned: 1}, fmt.Errorf("permission denied")
}
os.Args = []string{"codex-wrapper", "--cleanup"}
os.Args = []string{"codeagent-wrapper", "--cleanup"}
var exitCode int
errOutput := captureStderr(t, func() {
@@ -821,7 +920,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
t.Fatalf("cleanup called %d times, want 1", calls)
}
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
currentLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
if _, err := os.Stat(currentLog); err == nil {
t.Fatalf("cleanup failure should not create new log file %s", currentLog)
} else if !os.IsNotExist(err) {
@@ -834,7 +933,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
codexCommand = createFakeCodexScript(t, "tid-cleanup-e2e", "ok")
stdinReader = strings.NewReader("")
isTerminalFn = func() bool { return true }
os.Args = []string{"codex-wrapper", "post-cleanup task"}
os.Args = []string{"codeagent-wrapper", "post-cleanup task"}
var normalExit int
normalOutput := captureStdout(t, func() {

View File

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

View File

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,523 @@
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

@@ -0,0 +1,143 @@
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

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,322 @@
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

@@ -0,0 +1,139 @@
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 != "" {
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,110 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,220 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
ilogger "codeagent-wrapper/internal/logger"
"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{
DefaultBackend: "opencode",
DefaultModel: "opencode/grok-code",
Agents: map[string]AgentModelConfig{
"oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
},
}
var (
modelsConfigOnce sync.Once
modelsConfigCached *ModelsConfig
)
func modelsConfig() *ModelsConfig {
modelsConfigOnce.Do(func() {
modelsConfigCached = loadModelsConfig()
})
if modelsConfigCached == nil {
return &defaultModelsConfig
}
return modelsConfigCached
}
func loadModelsConfig() *ModelsConfig {
home, err := os.UserHomeDir()
if err != nil {
ilogger.LogWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err))
return &defaultModelsConfig
}
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 &defaultModelsConfig
}
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) {
ilogger.LogWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err))
}
return &defaultModelsConfig
}
var cfg ModelsConfig
if err := json.Unmarshal(data, &cfg); err != nil {
ilogger.LogWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err))
return &defaultModelsConfig
}
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
if cfg.DefaultBackend == "" {
cfg.DefaultBackend = defaultModelsConfig.DefaultBackend
}
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
if cfg.DefaultModel == "" {
cfg.DefaultModel = defaultModelsConfig.DefaultModel
}
// Merge with defaults
for name, agent := range defaultModelsConfig.Agents {
if _, exists := cfg.Agents[name]; !exists {
if cfg.Agents == nil {
cfg.Agents = make(map[string]AgentModelConfig)
}
cfg.Agents[name] = agent
}
}
// 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
}
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 := modelsConfig()
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) {
cfg := modelsConfig()
if agent, ok := cfg.Agents[agentName]; ok {
backend = strings.TrimSpace(agent.Backend)
if backend == "" {
backend = cfg.DefaultBackend
}
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)
}
return backend, strings.TrimSpace(agent.Model), agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo
}
if dynamic, ok := LoadDynamicAgent(agentName); ok {
backend = cfg.DefaultBackend
model = cfg.DefaultModel
backendCfg := resolveBackendConfig(cfg, backend)
baseURL = strings.TrimSpace(backendCfg.BaseURL)
apiKey = strings.TrimSpace(backendCfg.APIKey)
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false
}
backend = cfg.DefaultBackend
model = cfg.DefaultModel
backendCfg := resolveBackendConfig(cfg, backend)
baseURL = strings.TrimSpace(backendCfg.BaseURL)
apiKey = strings.TrimSpace(backendCfg.APIKey)
return backend, model, "", "", baseURL, apiKey, false
}
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) {
return resolveAgentConfig(agentName)
}
func ResetModelsConfigCacheForTest() {
modelsConfigCached = nil
modelsConfigOnce = sync.Once{}
}

View File

@@ -0,0 +1,221 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestResolveAgentConfig_Defaults(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
// Test that default agents resolve correctly without config file
tests := []struct {
agent string
wantBackend string
wantModel string
wantPromptFile string
}{
{"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"},
{"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"},
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
}
for _, tt := range tests {
t.Run(tt.agent, func(t *testing.T) {
backend, model, promptFile, _, _, _, _ := resolveAgentConfig(tt.agent)
if backend != tt.wantBackend {
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
}
if model != tt.wantModel {
t.Errorf("model = %q, want %q", model, tt.wantModel)
}
if promptFile != tt.wantPromptFile {
t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile)
}
})
}
}
func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("unknown-agent")
if backend != "opencode" {
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
}
if model != "opencode/grok-code" {
t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code")
}
if promptFile != "" {
t.Errorf("unknown agent promptFile = %q, want empty", promptFile)
}
}
func TestLoadModelsConfig_NoFile(t *testing.T) {
home := "/nonexistent/path/that/does/not/exist"
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
cfg := loadModelsConfig()
if cfg.DefaultBackend != "opencode" {
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode")
}
if len(cfg.Agents) != 6 {
t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents))
}
}
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 := loadModelsConfig()
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")
}
}
// Check that defaults are merged
if _, ok := cfg.Agents["oracle"]; !ok {
t.Error("default agent oracle should be merged")
}
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, _ := ResolveAgentConfig("custom-agent")
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)
}
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("sarsh")
if backend != "opencode" {
t.Errorf("backend = %q, want %q", backend, "opencode")
}
if model != "opencode/grok-code" {
t.Errorf("model = %q, want %q", model, "opencode/grok-code")
}
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()
cfg := loadModelsConfig()
// Should fall back to defaults
if cfg.DefaultBackend != "opencode" {
t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend)
}
}

View File

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,47 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
package executor
import (
"bytes"
"io"
"strings"
)
// geminiNoisePatterns contains stderr patterns to filter for gemini backend
var geminiNoisePatterns = []string{
"[STARTUP]",
"Session cleanup disabled",
"Warning:",
"(node:",
"(Use `node --trace-warnings",
"Loaded cached credentials",
"Loading extension:",
"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
type filteringWriter struct {
w io.Writer
patterns []string
buf bytes.Buffer
}
func newFilteringWriter(w io.Writer, patterns []string) *filteringWriter {
return &filteringWriter{w: w, patterns: patterns}
}
func (f *filteringWriter) Write(p []byte) (n int, err error) {
f.buf.Write(p)
for {
line, err := f.buf.ReadString('\n')
if err != nil {
// incomplete line, put it back
f.buf.WriteString(line)
break
}
if !f.shouldFilter(line) {
_, _ = f.w.Write([]byte(line))
}
}
return len(p), nil
}
func (f *filteringWriter) shouldFilter(line string) bool {
for _, pattern := range f.patterns {
if strings.Contains(line, pattern) {
return true
}
}
return false
}
// Flush writes any remaining buffered content
func (f *filteringWriter) Flush() {
if f.buf.Len() > 0 {
remaining := f.buf.String()
if !f.shouldFilter(remaining) {
_, _ = f.w.Write([]byte(remaining))
}
f.buf.Reset()
}
}

View File

@@ -0,0 +1,73 @@
package executor
import (
"bytes"
"testing"
)
func TestFilteringWriter(t *testing.T) {
tests := []struct {
name string
patterns []string
input string
want string
}{
{
name: "filter STARTUP lines",
patterns: geminiNoisePatterns,
input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n",
want: "Hello World\n",
},
{
name: "filter Warning lines",
patterns: geminiNoisePatterns,
input: "Warning: something bad\nActual output\n",
want: "Actual output\n",
},
{
name: "filter multiple patterns",
patterns: geminiNoisePatterns,
input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n",
want: "Real content\n",
},
{
name: "no filtering needed",
patterns: geminiNoisePatterns,
input: "Line 1\nLine 2\nLine 3\n",
want: "Line 1\nLine 2\nLine 3\n",
},
{
name: "empty input",
patterns: geminiNoisePatterns,
input: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
fw := newFilteringWriter(&buf, tt.patterns)
_, _ = fw.Write([]byte(tt.input))
fw.Flush()
if got := buf.String(); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestFilteringWriterPartialLines(t *testing.T) {
var buf bytes.Buffer
fw := newFilteringWriter(&buf, geminiNoisePatterns)
// Write partial line
_, _ = fw.Write([]byte("Hello "))
_, _ = fw.Write([]byte("World\n"))
fw.Flush()
if got := buf.String(); got != "Hello World\n" {
t.Errorf("got %q, want %q", got, "Hello World\n")
}
}

View File

@@ -1,83 +1,11 @@
package main
package executor
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
)
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.IndexAny(taskText, stdinSpecialChars) >= 0
}
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
}
import "bytes"
type logWriter struct {
prefix string
maxLen int
buf bytes.Buffer
prefix string
maxLen int
buf bytes.Buffer
dropped bool
}
@@ -194,32 +122,3 @@ func (b *tailBuffer) Write(p []byte) (int, error) {
func (b *tailBuffer) String() string {
return string(b.data)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen < 0 {
return ""
}
return s[:maxLen] + "..."
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func hello() string {
return "hello world"
}
func greet(name string) string {
return "hello " + name
}
func farewell(name string) string {
return "goodbye " + name
}

View File

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

View File

@@ -0,0 +1,135 @@
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, _, _, _ := config.ResolveAgentConfig(task.Agent)
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

@@ -0,0 +1,130 @@
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

@@ -0,0 +1,186 @@
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

@@ -0,0 +1,104 @@
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

@@ -0,0 +1,16 @@
//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

@@ -0,0 +1,87 @@
//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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,59 @@
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,10 +1,11 @@
package main
package logger
import (
"bufio"
"context"
"errors"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"strconv"
@@ -12,28 +13,35 @@ import (
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog"
)
// Logger writes log messages asynchronously to a temp file.
// It is intentionally minimal: a buffered channel + single worker goroutine
// to avoid contention while keeping ordering guarantees.
type Logger struct {
path string
file *os.File
writer *bufio.Writer
ch chan logEntry
flushReq chan chan struct{}
done chan struct{}
closed atomic.Bool
closeOnce sync.Once
workerWG sync.WaitGroup
pendingWG sync.WaitGroup
flushMu sync.Mutex
path string
file *os.File
writer *bufio.Writer
zlogger zerolog.Logger
ch chan logEntry
flushReq chan chan struct{}
done chan struct{}
closed atomic.Bool
closeOnce sync.Once
workerWG sync.WaitGroup
pendingWG sync.WaitGroup
flushMu sync.Mutex
workerErr error
errorEntries []string // Cache of recent ERROR/WARN entries
errorMu sync.Mutex
}
type logEntry struct {
level string
msg string
msg string
level zerolog.Level
isError bool // true for ERROR or WARN levels
}
// CleanupStats captures the outcome of a cleanupOldLogs run.
@@ -55,6 +63,10 @@ var (
evalSymlinksFn = filepath.EvalSymlinks
)
const maxLogSuffixLen = 64
var logSuffixCounter atomic.Uint64
// NewLogger creates the async logger and starts the worker goroutine.
// The log file is created under os.TempDir() using the required naming scheme.
func NewLogger() (*Logger, error) {
@@ -64,14 +76,23 @@ func NewLogger() (*Logger, error) {
// NewLoggerWithSuffix creates a logger with an optional suffix in the filename.
// Useful for tests that need isolated log files within the same process.
func NewLoggerWithSuffix(suffix string) (*Logger, error) {
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), os.Getpid())
pid := os.Getpid()
filename := fmt.Sprintf("%s-%d", PrimaryLogPrefix(), pid)
var safeSuffix string
if suffix != "" {
filename += "-" + suffix
safeSuffix = sanitizeLogSuffix(suffix)
}
if safeSuffix != "" {
filename += "-" + safeSuffix
}
filename += ".log"
path := filepath.Clean(filepath.Join(os.TempDir(), filename))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
@@ -86,12 +107,81 @@ func NewLoggerWithSuffix(suffix string) (*Logger, error) {
done: make(chan struct{}),
}
l.zlogger = zerolog.New(l.writer).With().Timestamp().Logger()
l.workerWG.Add(1)
go l.run()
return l, nil
}
func sanitizeLogSuffix(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return fallbackLogSuffix()
}
var b strings.Builder
changed := false
for _, r := range trimmed {
if isSafeLogRune(r) {
b.WriteRune(r)
} else {
changed = true
b.WriteByte('-')
}
if b.Len() >= maxLogSuffixLen {
changed = true
break
}
}
sanitized := strings.Trim(b.String(), "-.")
if sanitized != b.String() {
changed = true // Mark if trim removed any characters
}
if sanitized == "" {
return fallbackLogSuffix()
}
if changed || len(sanitized) > maxLogSuffixLen {
hash := crc32.ChecksumIEEE([]byte(trimmed))
hashStr := fmt.Sprintf("%x", hash)
maxPrefix := maxLogSuffixLen - len(hashStr) - 1
if maxPrefix < 1 {
maxPrefix = 1
}
if len(sanitized) > maxPrefix {
sanitized = sanitized[:maxPrefix]
}
sanitized = fmt.Sprintf("%s-%s", sanitized, hashStr)
}
return sanitized
}
func fallbackLogSuffix() string {
next := logSuffixCounter.Add(1)
return fmt.Sprintf("task-%d", next)
}
func isSafeLogRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z':
return true
case r >= 'A' && r <= 'Z':
return true
case r >= '0' && r <= '9':
return true
case r == '-', r == '_', r == '.':
return true
default:
return false
}
}
// Path returns the underlying log file path (useful for tests/inspection).
func (l *Logger) Path() string {
if l == nil {
@@ -100,22 +190,30 @@ func (l *Logger) Path() string {
return l.path
}
func (l *Logger) IsClosed() bool {
if l == nil {
return true
}
return l.closed.Load()
}
// Info logs at INFO level.
func (l *Logger) Info(msg string) { l.log("INFO", msg) }
func (l *Logger) Info(msg string) { l.logWithLevel(zerolog.InfoLevel, msg) }
// Warn logs at WARN level.
func (l *Logger) Warn(msg string) { l.log("WARN", msg) }
func (l *Logger) Warn(msg string) { l.logWithLevel(zerolog.WarnLevel, msg) }
// Debug logs at DEBUG level.
func (l *Logger) Debug(msg string) { l.log("DEBUG", msg) }
func (l *Logger) Debug(msg string) { l.logWithLevel(zerolog.DebugLevel, msg) }
// Error logs at ERROR level.
func (l *Logger) Error(msg string) { l.log("ERROR", msg) }
func (l *Logger) Error(msg string) { l.logWithLevel(zerolog.ErrorLevel, msg) }
// Close stops the worker and syncs the log file.
// Close signals the worker to flush and close the log file.
// The log file is NOT removed, allowing inspection after program exit.
// It is safe to call multiple times.
// Returns after a 5-second timeout if worker doesn't stop gracefully.
// Waits up to CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS (default: 5000) for shutdown; set to 0 to wait indefinitely.
// Returns an error if shutdown doesn't complete within the timeout.
func (l *Logger) Close() error {
if l == nil {
return nil
@@ -126,42 +224,51 @@ func (l *Logger) Close() error {
l.closeOnce.Do(func() {
l.closed.Store(true)
close(l.done)
close(l.ch)
// Wait for worker with timeout
timeout := loggerCloseTimeout()
workerDone := make(chan struct{})
go func() {
l.workerWG.Wait()
close(workerDone)
}()
select {
case <-workerDone:
// Worker stopped gracefully
case <-time.After(5 * time.Second):
// Worker timeout - proceed with cleanup anyway
closeErr = fmt.Errorf("logger worker timeout during close")
if timeout > 0 {
select {
case <-workerDone:
// Worker stopped gracefully
case <-time.After(timeout):
closeErr = fmt.Errorf("logger worker timeout during close")
return
}
} else {
<-workerDone
}
if err := l.writer.Flush(); err != nil && closeErr == nil {
closeErr = err
if l.workerErr != nil && closeErr == nil {
closeErr = l.workerErr
}
if err := l.file.Sync(); err != nil && closeErr == nil {
closeErr = err
}
if err := l.file.Close(); err != nil && closeErr == nil {
closeErr = err
}
// Log file is kept for debugging - NOT removed
// Users can manually clean up /tmp/<wrapper>-*.log files
})
return closeErr
}
func loggerCloseTimeout() time.Duration {
const defaultTimeout = 5 * time.Second
raw := strings.TrimSpace(os.Getenv("CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS"))
if raw == "" {
return defaultTimeout
}
ms, err := strconv.Atoi(raw)
if err != nil {
return defaultTimeout
}
if ms <= 0 {
return 0
}
return time.Duration(ms) * time.Millisecond
}
// RemoveLogFile removes the log file. Should only be called after Close().
func (l *Logger) RemoveLogFile() error {
if l == nil {
@@ -170,34 +277,29 @@ func (l *Logger) RemoveLogFile() error {
return os.Remove(l.path)
}
// ExtractRecentErrors reads the log file and returns the most recent ERROR and WARN entries.
// ExtractRecentErrors returns the most recent ERROR and WARN entries from memory cache.
// Returns up to maxEntries entries in chronological order.
func (l *Logger) ExtractRecentErrors(maxEntries int) []string {
if l == nil || l.path == "" {
if l == nil || maxEntries <= 0 {
return nil
}
f, err := os.Open(l.path)
if err != nil {
l.errorMu.Lock()
defer l.errorMu.Unlock()
if len(l.errorEntries) == 0 {
return nil
}
defer f.Close()
var entries []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "] ERROR:") || strings.Contains(line, "] WARN:") {
entries = append(entries, line)
}
// Return last N entries
start := 0
if len(l.errorEntries) > maxEntries {
start = len(l.errorEntries) - maxEntries
}
// Keep only the last maxEntries
if len(entries) > maxEntries {
entries = entries[len(entries)-maxEntries:]
}
return entries
result := make([]string, len(l.errorEntries)-start)
copy(result, l.errorEntries[start:])
return result
}
// Flush waits for all pending log entries to be written. Primarily for tests.
@@ -246,7 +348,7 @@ func (l *Logger) Flush() {
}
}
func (l *Logger) log(level, msg string) {
func (l *Logger) logWithLevel(entryLevel zerolog.Level, msg string) {
if l == nil {
return
}
@@ -254,7 +356,8 @@ func (l *Logger) log(level, msg string) {
return
}
entry := logEntry{level: level, msg: msg}
isError := entryLevel == zerolog.WarnLevel || entryLevel == zerolog.ErrorLevel
entry := logEntry{msg: msg, level: entryLevel, isError: isError}
l.flushMu.Lock()
l.pendingWG.Add(1)
l.flushMu.Unlock()
@@ -275,18 +378,42 @@ func (l *Logger) run() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
writeEntry := func(entry logEntry) {
l.zlogger.WithLevel(entry.level).Msg(entry.msg)
// Cache error/warn entries in memory for fast extraction
if entry.isError {
l.errorMu.Lock()
l.errorEntries = append(l.errorEntries, entry.msg)
if len(l.errorEntries) > 100 { // Keep last 100
l.errorEntries = l.errorEntries[1:]
}
l.errorMu.Unlock()
}
l.pendingWG.Done()
}
finalize := func() {
if err := l.writer.Flush(); err != nil && l.workerErr == nil {
l.workerErr = err
}
if err := l.file.Sync(); err != nil && l.workerErr == nil {
l.workerErr = err
}
if err := l.file.Close(); err != nil && l.workerErr == nil {
l.workerErr = err
}
}
for {
select {
case entry, ok := <-l.ch:
if !ok {
// Channel closed, final flush
_ = l.writer.Flush()
finalize()
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
pid := os.Getpid()
fmt.Fprintf(l.writer, "[%s] [PID:%d] %s: %s\n", timestamp, pid, entry.level, entry.msg)
l.pendingWG.Done()
writeEntry(entry)
case <-ticker.C:
_ = l.writer.Flush()
@@ -296,6 +423,21 @@ func (l *Logger) run() {
_ = l.writer.Flush()
_ = l.file.Sync()
close(flushDone)
case <-l.done:
for {
select {
case entry, ok := <-l.ch:
if !ok {
finalize()
return
}
writeEntry(entry)
default:
finalize()
return
}
}
}
}
}
@@ -309,10 +451,7 @@ func cleanupOldLogs() (CleanupStats, error) {
var stats CleanupStats
tempDir := os.TempDir()
prefixes := logPrefixes()
if len(prefixes) == 0 {
prefixes = []string{defaultWrapperName}
}
prefixes := LogPrefixes()
seen := make(map[string]struct{})
var matches []string
@@ -343,7 +482,8 @@ func cleanupOldLogs() (CleanupStats, error) {
stats.Kept++
stats.KeptFiles = append(stats.KeptFiles, filename)
if reason != "" {
logWarn(fmt.Sprintf("cleanupOldLogs: skipping %s: %s", filename, reason))
// Use Debug level to avoid polluting Recent Errors with cleanup noise
logDebug(fmt.Sprintf("cleanupOldLogs: skipping %s: %s", filename, reason))
}
continue
}
@@ -461,10 +601,7 @@ func isPIDReused(logPath string, pid int) bool {
if procStartTime.IsZero() {
// Can't determine process start time
// Check if file is very old (>7 days), likely from a dead process
if time.Since(fileModTime) > 7*24*time.Hour {
return true // File is old enough to be from a different process
}
return false // Be conservative for recent files
return time.Since(fileModTime) > 7*24*time.Hour
}
// If the log file was modified before the process started, PID was reused
@@ -474,10 +611,7 @@ func isPIDReused(logPath string, pid int) bool {
func parsePIDFromLog(path string) (int, bool) {
name := filepath.Base(path)
prefixes := logPrefixes()
if len(prefixes) == 0 {
prefixes = []string{defaultWrapperName}
}
prefixes := LogPrefixes()
for _, prefix := range prefixes {
prefixWithDash := fmt.Sprintf("%s-", prefix)
@@ -531,3 +665,19 @@ func renderWorkerLimit(limit int) string {
}
return strconv.Itoa(limit)
}
func CleanupOldLogs() (CleanupStats, error) { return cleanupOldLogs() }
func IsUnsafeFile(path string, tempDir string) (bool, string) { return isUnsafeFile(path, tempDir) }
func IsPIDReused(logPath string, pid int) bool { return isPIDReused(logPath, pid) }
func ParsePIDFromLog(path string) (int, bool) { return parsePIDFromLog(path) }
func LogConcurrencyPlanning(limit, total int) { logConcurrencyPlanning(limit, total) }
func LogConcurrencyState(event, taskID string, active, limit int) {
logConcurrencyState(event, taskID, active, limit)
}
func SanitizeLogSuffix(raw string) string { return sanitizeLogSuffix(raw) }

View File

@@ -0,0 +1,158 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoggerNilReceiverNoop(t *testing.T) {
var logger *Logger
logger.Info("info")
logger.Warn("warn")
logger.Debug("debug")
logger.Error("error")
logger.Flush()
if err := logger.Close(); err != nil {
t.Fatalf("Close() on nil logger should return nil, got %v", err)
}
}
func TestLoggerConcurrencyLogHelpers(t *testing.T) {
setTempDirEnv(t, t.TempDir())
logger, err := NewLoggerWithSuffix("concurrency")
if err != nil {
t.Fatalf("NewLoggerWithSuffix error: %v", err)
}
setLogger(logger)
defer func() { _ = closeLogger() }()
logConcurrencyPlanning(0, 2)
logConcurrencyPlanning(3, 2)
logConcurrencyState("start", "task-1", 1, 0)
logConcurrencyState("done", "task-1", 0, 3)
logger.Flush()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
output := string(data)
checks := []string{
"parallel: worker_limit=unbounded total_tasks=2",
"parallel: worker_limit=3 total_tasks=2",
"parallel: start task=task-1 active=1 limit=unbounded",
"parallel: done task=task-1 active=0 limit=3",
}
for _, c := range checks {
if !strings.Contains(output, c) {
t.Fatalf("log output missing %q, got: %s", c, output)
}
}
}
func TestLoggerConcurrencyLogHelpersNoopWithoutActiveLogger(t *testing.T) {
_ = closeLogger()
logConcurrencyPlanning(1, 1)
logConcurrencyState("start", "task-1", 0, 1)
}
func TestLoggerCleanupOldLogsSkipsUnsafeAndHandlesAlreadyDeleted(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
unsafePath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", PrimaryLogPrefix(), 222))
orphanPath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", PrimaryLogPrefix(), 111))
stubFileStat(t, func(path string) (os.FileInfo, error) {
if path == unsafePath {
return fakeFileInfo{mode: os.ModeSymlink}, nil
}
return os.Lstat(path)
})
stubProcessRunning(t, func(pid int) bool {
if pid == 111 {
_ = os.Remove(orphanPath)
}
return false
})
stats, err := cleanupOldLogs()
if err != nil {
t.Fatalf("cleanupOldLogs() unexpected error: %v", err)
}
if stats.Scanned != 2 {
t.Fatalf("scanned = %d, want %d", stats.Scanned, 2)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want %d", stats.Deleted, 0)
}
if stats.Kept != 2 {
t.Fatalf("kept = %d, want %d", stats.Kept, 2)
}
if stats.Errors != 0 {
t.Fatalf("errors = %d, want %d", stats.Errors, 0)
}
hasSkip := false
hasAlreadyDeleted := false
for _, name := range stats.KeptFiles {
if strings.Contains(name, "already deleted") {
hasAlreadyDeleted = true
}
if strings.Contains(name, filepath.Base(unsafePath)) {
hasSkip = true
}
}
if !hasSkip {
t.Fatalf("expected kept files to include unsafe log %q, got %+v", filepath.Base(unsafePath), stats.KeptFiles)
}
if !hasAlreadyDeleted {
t.Fatalf("expected kept files to include already deleted marker, got %+v", stats.KeptFiles)
}
}
func TestLoggerIsUnsafeFileErrorPaths(t *testing.T) {
tempDir := t.TempDir()
t.Run("stat ErrNotExist", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return nil, os.ErrNotExist
})
unsafe, reason := isUnsafeFile("missing.log", tempDir)
if !unsafe || reason != "" {
t.Fatalf("expected missing file to be skipped silently, got unsafe=%v reason=%q", unsafe, reason)
}
})
t.Run("stat error", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return nil, fmt.Errorf("boom")
})
unsafe, reason := isUnsafeFile("broken.log", tempDir)
if !unsafe || !strings.Contains(reason, "stat failed") {
t.Fatalf("expected stat failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
}
})
t.Run("EvalSymlinks error", func(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return fakeFileInfo{}, nil
})
stubEvalSymlinks(t, func(string) (string, error) {
return "", fmt.Errorf("resolve failed")
})
unsafe, reason := isUnsafeFile("cannot-resolve.log", tempDir)
if !unsafe || !strings.Contains(reason, "path resolution failed") {
t.Fatalf("expected resolution failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
}
})
}

View File

@@ -0,0 +1,115 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoggerWithSuffixNamingAndIsolation(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
taskA := "task-1"
taskB := "task-2"
loggerA, err := NewLoggerWithSuffix(taskA)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskA, err)
}
defer loggerA.Close()
loggerB, err := NewLoggerWithSuffix(taskB)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskB, err)
}
defer loggerB.Close()
wantA := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), taskA))
if loggerA.Path() != wantA {
t.Fatalf("loggerA path = %q, want %q", loggerA.Path(), wantA)
}
wantB := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), taskB))
if loggerB.Path() != wantB {
t.Fatalf("loggerB path = %q, want %q", loggerB.Path(), wantB)
}
if loggerA.Path() == loggerB.Path() {
t.Fatalf("expected different log files, got %q", loggerA.Path())
}
loggerA.Info("from taskA")
loggerB.Info("from taskB")
loggerA.Flush()
loggerB.Flush()
dataA, err := os.ReadFile(loggerA.Path())
if err != nil {
t.Fatalf("failed to read loggerA file: %v", err)
}
dataB, err := os.ReadFile(loggerB.Path())
if err != nil {
t.Fatalf("failed to read loggerB file: %v", err)
}
if !strings.Contains(string(dataA), "from taskA") {
t.Fatalf("loggerA missing its message, got: %q", string(dataA))
}
if strings.Contains(string(dataA), "from taskB") {
t.Fatalf("loggerA contains loggerB message, got: %q", string(dataA))
}
if !strings.Contains(string(dataB), "from taskB") {
t.Fatalf("loggerB missing its message, got: %q", string(dataB))
}
if strings.Contains(string(dataB), "from taskA") {
t.Fatalf("loggerB contains loggerA message, got: %q", string(dataB))
}
}
func TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable(t *testing.T) {
base := t.TempDir()
noWrite := filepath.Join(base, "ro")
if err := os.Mkdir(noWrite, 0o500); err != nil {
t.Fatalf("failed to create read-only temp dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(noWrite, 0o700) })
setTempDirEnv(t, noWrite)
logger, err := NewLoggerWithSuffix("task-err")
if err == nil {
_ = logger.Close()
t.Fatalf("expected error when temp dir is not writable")
}
}
func TestLoggerWithSuffixSanitizesUnsafeSuffix(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
raw := "../bad id/with?chars"
safe := sanitizeLogSuffix(raw)
if safe == "" {
t.Fatalf("sanitizeLogSuffix returned empty string")
}
if strings.ContainsAny(safe, "/\\") {
t.Fatalf("sanitized suffix should not contain path separators, got %q", safe)
}
logger, err := NewLoggerWithSuffix(raw)
if err != nil {
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", raw, err)
}
t.Cleanup(func() {
_ = logger.Close()
_ = os.Remove(logger.Path())
})
wantBase := fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), safe)
if gotBase := filepath.Base(logger.Path()); gotBase != wantBase {
t.Fatalf("log filename = %q, want %q", gotBase, wantBase)
}
if dir := filepath.Dir(logger.Path()); dir != tempDir {
t.Fatalf("logger path dir = %q, want %q", dir, tempDir)
}
}

View File

@@ -1,4 +1,4 @@
package main
package logger
import (
"bufio"
@@ -6,7 +6,6 @@ import (
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -26,7 +25,7 @@ func compareCleanupStats(got, want CleanupStats) bool {
return true
}
func TestRunLoggerCreatesFileWithPID(t *testing.T) {
func TestLoggerCreatesFileWithPID(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
@@ -46,7 +45,7 @@ func TestRunLoggerCreatesFileWithPID(t *testing.T) {
}
}
func TestRunLoggerWritesLevels(t *testing.T) {
func TestLoggerWritesLevels(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
@@ -69,7 +68,7 @@ func TestRunLoggerWritesLevels(t *testing.T) {
}
content := string(data)
checks := []string{"INFO: info message", "WARN: warn message", "DEBUG: debug message", "ERROR: error message"}
checks := []string{"info message", "warn message", "debug message", "error message"}
for _, c := range checks {
if !strings.Contains(content, c) {
t.Fatalf("log file missing entry %q, content: %s", c, content)
@@ -77,7 +76,7 @@ func TestRunLoggerWritesLevels(t *testing.T) {
}
}
func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
func TestLoggerCloseStopsWorkerAndKeepsFile(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
@@ -102,21 +101,9 @@ func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
// Clean up manually for test
defer os.Remove(logPath)
done := make(chan struct{})
go func() {
logger.workerWG.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(200 * time.Millisecond):
t.Fatalf("worker goroutine did not exit after Close")
}
}
func TestRunLoggerConcurrentWritesSafe(t *testing.T) {
func TestLoggerConcurrentWritesSafe(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
@@ -165,50 +152,13 @@ func TestRunLoggerConcurrentWritesSafe(t *testing.T) {
}
}
func TestRunLoggerTerminateProcessActive(t *testing.T) {
cmd := exec.Command("sleep", "5")
if err := cmd.Start(); err != nil {
t.Skipf("cannot start sleep command: %v", err)
}
timer := terminateProcess(&realCmd{cmd: cmd})
if timer == nil {
t.Fatalf("terminateProcess returned nil timer for active process")
}
defer timer.Stop()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(500 * time.Millisecond):
t.Fatalf("process not terminated promptly")
case <-done:
}
// Force the timer callback to run immediately to cover the kill branch.
timer.Reset(0)
time.Sleep(10 * time.Millisecond)
}
func TestRunTerminateProcessNil(t *testing.T) {
if timer := terminateProcess(nil); timer != nil {
t.Fatalf("terminateProcess(nil) should return nil timer")
}
if timer := terminateProcess(&realCmd{cmd: &exec.Cmd{}}); timer != nil {
t.Fatalf("terminateProcess with nil process should return nil timer")
}
}
func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) {
func TestLoggerCleanupOldLogsRemovesOrphans(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
orphan1 := createTempLog(t, tempDir, "codex-wrapper-111.log")
orphan2 := createTempLog(t, tempDir, "codex-wrapper-222-suffix.log")
running1 := createTempLog(t, tempDir, "codex-wrapper-333.log")
running2 := createTempLog(t, tempDir, "codex-wrapper-444-extra-info.log")
orphan1 := createTempLog(t, tempDir, "codeagent-wrapper-111.log")
orphan2 := createTempLog(t, tempDir, "codeagent-wrapper-222-suffix.log")
running1 := createTempLog(t, tempDir, "codeagent-wrapper-333.log")
running2 := createTempLog(t, tempDir, "codeagent-wrapper-444-extra-info.log")
untouched := createTempLog(t, tempDir, "unrelated.log")
runningPIDs := map[int]bool{333: true, 444: true}
@@ -252,19 +202,19 @@ func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) {
}
}
func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
func TestLoggerCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
invalid := []string{
"codex-wrapper-.log",
"codex-wrapper.log",
"codex-wrapper-foo-bar.txt",
"codeagent-wrapper-.log",
"codeagent-wrapper.log",
"codeagent-wrapper-foo-bar.txt",
"not-a-codex.log",
}
for _, name := range invalid {
createTempLog(t, tempDir, name)
}
target := createTempLog(t, tempDir, "codex-wrapper-555-extra.log")
target := createTempLog(t, tempDir, "codeagent-wrapper-555-extra.log")
var checked []int
stubProcessRunning(t, func(pid int) bool {
@@ -310,7 +260,7 @@ func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
}
}
func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) {
func TestLoggerCleanupOldLogsHandlesGlobFailures(t *testing.T) {
stubProcessRunning(t, func(pid int) bool {
t.Fatalf("process check should not run when glob fails")
return false
@@ -336,7 +286,7 @@ func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) {
}
}
func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
func TestLoggerCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
setTempDirEnv(t, t.TempDir())
stubProcessRunning(t, func(int) bool {
@@ -356,12 +306,12 @@ func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
}
}
func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
func TestLoggerCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
paths := []string{
createTempLog(t, tempDir, "codex-wrapper-6100.log"),
createTempLog(t, tempDir, "codex-wrapper-6101.log"),
createTempLog(t, tempDir, "codeagent-wrapper-6100.log"),
createTempLog(t, tempDir, "codeagent-wrapper-6101.log"),
}
stubProcessRunning(t, func(int) bool { return false })
@@ -396,11 +346,11 @@ func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
}
}
func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
func TestLoggerCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
protected := createTempLog(t, tempDir, "codex-wrapper-6200.log")
deletable := createTempLog(t, tempDir, "codex-wrapper-6201.log")
protected := createTempLog(t, tempDir, "codeagent-wrapper-6200.log")
deletable := createTempLog(t, tempDir, "codeagent-wrapper-6201.log")
stubProcessRunning(t, func(int) bool { return false })
stubProcessStartTime(t, func(int) time.Time { return time.Time{} })
@@ -433,13 +383,13 @@ func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
}
}
func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
func TestLoggerCleanupOldLogsPerformanceBound(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
const fileCount = 400
fakePaths := make([]string, fileCount)
for i := 0; i < fileCount; i++ {
name := fmt.Sprintf("codex-wrapper-%d.log", 10000+i)
name := fmt.Sprintf("codeagent-wrapper-%d.log", 10000+i)
fakePaths[i] = createTempLog(t, tempDir, name)
}
@@ -476,21 +426,11 @@ func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
}
}
func TestRunCleanupOldLogsCoverageSuite(t *testing.T) {
TestBackendParseJSONStream_CoverageSuite(t)
}
// Reuse the existing coverage suite so the focused TestLogger run still exercises
// the rest of the codebase and keeps coverage high.
func TestRunLoggerCoverageSuite(t *testing.T) {
TestBackendParseJSONStream_CoverageSuite(t)
}
func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
func TestLoggerCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
tempDir := setTempDirEnv(t, t.TempDir())
currentPID := os.Getpid()
currentLog := createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", currentPID))
currentLog := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", currentPID))
stubProcessRunning(t, func(pid int) bool {
if pid != currentPID {
@@ -518,7 +458,7 @@ func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
}
}
func TestIsPIDReusedScenarios(t *testing.T) {
func TestLoggerIsPIDReusedScenarios(t *testing.T) {
now := time.Now()
tests := []struct {
name string
@@ -552,7 +492,7 @@ func TestIsPIDReusedScenarios(t *testing.T) {
}
}
func TestIsUnsafeFileSecurityChecks(t *testing.T) {
func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
tempDir := t.TempDir()
absTempDir, err := filepath.Abs(tempDir)
if err != nil {
@@ -566,7 +506,7 @@ func TestIsUnsafeFileSecurityChecks(t *testing.T) {
stubEvalSymlinks(t, func(path string) (string, error) {
return filepath.Join(absTempDir, filepath.Base(path)), nil
})
unsafe, reason := isUnsafeFile(filepath.Join(absTempDir, "codex-wrapper-1.log"), tempDir)
unsafe, reason := isUnsafeFile(filepath.Join(absTempDir, "codeagent-wrapper-1.log"), tempDir)
if !unsafe || reason != "refusing to delete symlink" {
t.Fatalf("expected symlink to be rejected, got unsafe=%v reason=%q", unsafe, reason)
}
@@ -592,26 +532,32 @@ func TestIsUnsafeFileSecurityChecks(t *testing.T) {
})
otherDir := t.TempDir()
stubEvalSymlinks(t, func(string) (string, error) {
return filepath.Join(otherDir, "codex-wrapper-9.log"), nil
return filepath.Join(otherDir, "codeagent-wrapper-9.log"), nil
})
unsafe, reason := isUnsafeFile(filepath.Join(otherDir, "codex-wrapper-9.log"), tempDir)
unsafe, reason := isUnsafeFile(filepath.Join(otherDir, "codeagent-wrapper-9.log"), tempDir)
if !unsafe || reason != "file is outside tempDir" {
t.Fatalf("expected outside file to be rejected, got unsafe=%v reason=%q", unsafe, reason)
}
})
}
func TestRunLoggerPathAndRemove(t *testing.T) {
func TestLoggerPathAndRemove(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "sample.log")
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
t.Setenv("TMPDIR", tempDir)
logger, err := NewLoggerWithSuffix("sample")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
path := logger.Path()
if path == "" {
_ = logger.Close()
t.Fatalf("logger.Path() returned empty path")
}
if err := logger.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
logger := &Logger{path: path}
if got := logger.Path(); got != path {
t.Fatalf("Path() = %q, want %q", got, path)
}
if err := logger.RemoveLogFile(); err != nil {
t.Fatalf("RemoveLogFile() error = %v", err)
}
@@ -628,45 +574,20 @@ func TestRunLoggerPathAndRemove(t *testing.T) {
}
}
func TestRunLoggerInternalLog(t *testing.T) {
logger := &Logger{
ch: make(chan logEntry, 1),
done: make(chan struct{}),
pendingWG: sync.WaitGroup{},
}
done := make(chan logEntry, 1)
go func() {
entry := <-logger.ch
logger.pendingWG.Done()
done <- entry
}()
logger.log("INFO", "hello")
entry := <-done
if entry.level != "INFO" || entry.msg != "hello" {
t.Fatalf("unexpected entry %+v", entry)
}
logger.closed.Store(true)
logger.log("INFO", "ignored")
close(logger.done)
}
func TestRunParsePIDFromLog(t *testing.T) {
func TestLoggerParsePIDFromLog(t *testing.T) {
hugePID := strconv.FormatInt(math.MaxInt64, 10) + "0"
tests := []struct {
name string
pid int
ok bool
}{
{"codex-wrapper-123.log", 123, true},
{"codex-wrapper-999-extra.log", 999, true},
{"codex-wrapper-.log", 0, false},
{"codeagent-wrapper-123.log", 123, true},
{"codeagent-wrapper-999-extra.log", 999, true},
{"codeagent-wrapper-.log", 0, false},
{"invalid-name.log", 0, false},
{"codex-wrapper--5.log", 0, false},
{"codex-wrapper-0.log", 0, false},
{fmt.Sprintf("codex-wrapper-%s.log", hugePID), 0, false},
{"codeagent-wrapper--5.log", 0, false},
{"codeagent-wrapper-0.log", 0, false},
{fmt.Sprintf("codeagent-wrapper-%s.log", hugePID), 0, false},
}
for _, tt := range tests {
@@ -705,56 +626,32 @@ func setTempDirEnv(t *testing.T, dir string) string {
func stubProcessRunning(t *testing.T, fn func(int) bool) {
t.Helper()
original := processRunningCheck
processRunningCheck = fn
t.Cleanup(func() {
processRunningCheck = original
})
t.Cleanup(SetProcessRunningCheck(fn))
}
func stubProcessStartTime(t *testing.T, fn func(int) time.Time) {
t.Helper()
original := processStartTimeFn
processStartTimeFn = fn
t.Cleanup(func() {
processStartTimeFn = original
})
t.Cleanup(SetProcessStartTimeFn(fn))
}
func stubRemoveLogFile(t *testing.T, fn func(string) error) {
t.Helper()
original := removeLogFileFn
removeLogFileFn = fn
t.Cleanup(func() {
removeLogFileFn = original
})
t.Cleanup(SetRemoveLogFileFn(fn))
}
func stubGlobLogFiles(t *testing.T, fn func(string) ([]string, error)) {
t.Helper()
original := globLogFiles
globLogFiles = fn
t.Cleanup(func() {
globLogFiles = original
})
t.Cleanup(SetGlobLogFilesFn(fn))
}
func stubFileStat(t *testing.T, fn func(string) (os.FileInfo, error)) {
t.Helper()
original := fileStatFn
fileStatFn = fn
t.Cleanup(func() {
fileStatFn = original
})
t.Cleanup(SetFileStatFn(fn))
}
func stubEvalSymlinks(t *testing.T, fn func(string) (string, error)) {
t.Helper()
original := evalSymlinksFn
evalSymlinksFn = fn
t.Cleanup(func() {
evalSymlinksFn = original
})
t.Cleanup(SetEvalSymlinksFn(fn))
}
type fakeFileInfo struct {
@@ -769,69 +666,93 @@ func (f fakeFileInfo) ModTime() time.Time { return f.modTime }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() interface{} { return nil }
func TestExtractRecentErrors(t *testing.T) {
func TestLoggerExtractRecentErrors(t *testing.T) {
tests := []struct {
name string
content string
logs []struct{ level, msg string }
maxEntries int
want []string
}{
{
name: "empty log",
content: "",
logs: nil,
maxEntries: 10,
want: nil,
},
{
name: "no errors",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] DEBUG: processing`,
logs: []struct{ level, msg string }{
{"INFO", "started"},
{"DEBUG", "processing"},
},
maxEntries: 10,
want: nil,
},
{
name: "single error",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed`,
logs: []struct{ level, msg string }{
{"INFO", "started"},
{"ERROR", "something failed"},
},
maxEntries: 10,
want: []string{"[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed"},
want: []string{"something failed"},
},
{
name: "error and warn",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] WARN: warning message
[2025-01-01 12:00:02.000] [PID:123] ERROR: error message`,
logs: []struct{ level, msg string }{
{"INFO", "started"},
{"WARN", "warning message"},
{"ERROR", "error message"},
},
maxEntries: 10,
want: []string{
"[2025-01-01 12:00:01.000] [PID:123] WARN: warning message",
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error message",
"warning message",
"error message",
},
},
{
name: "truncate to max",
content: `[2025-01-01 12:00:00.000] [PID:123] ERROR: error 1
[2025-01-01 12:00:01.000] [PID:123] ERROR: error 2
[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3
[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4
[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5`,
logs: []struct{ level, msg string }{
{"ERROR", "error 1"},
{"ERROR", "error 2"},
{"ERROR", "error 3"},
{"ERROR", "error 4"},
{"ERROR", "error 5"},
},
maxEntries: 3,
want: []string{
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3",
"[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4",
"[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5",
"error 3",
"error 4",
"error 5",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "test.log")
if err := os.WriteFile(logPath, []byte(tt.content), 0o644); err != nil {
t.Fatalf("failed to write test log: %v", err)
logger, err := NewLoggerWithSuffix("extract-test")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer logger.Close()
defer func() { _ = logger.RemoveLogFile() }()
// Write logs using logger methods
for _, entry := range tt.logs {
switch entry.level {
case "INFO":
logger.Info(entry.msg)
case "WARN":
logger.Warn(entry.msg)
case "ERROR":
logger.Error(entry.msg)
case "DEBUG":
logger.Debug(entry.msg)
}
}
logger := &Logger{path: logPath}
logger.Flush()
got := logger.ExtractRecentErrors(tt.maxEntries)
if len(got) != len(tt.want) {
@@ -846,23 +767,137 @@ func TestExtractRecentErrors(t *testing.T) {
}
}
func TestExtractRecentErrorsNilLogger(t *testing.T) {
func TestLoggerExtractRecentErrorsNilLogger(t *testing.T) {
var logger *Logger
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nil logger ExtractRecentErrors() should return nil, got %v", got)
}
}
func TestExtractRecentErrorsEmptyPath(t *testing.T) {
logger := &Logger{path: ""}
func TestLoggerExtractRecentErrorsEmptyPath(t *testing.T) {
logger := &Logger{}
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("empty path ExtractRecentErrors() should return nil, got %v", got)
}
}
func TestExtractRecentErrorsFileNotExist(t *testing.T) {
logger := &Logger{path: "/nonexistent/path/to/log.log"}
func TestLoggerExtractRecentErrorsFileNotExist(t *testing.T) {
logger := &Logger{}
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nonexistent file ExtractRecentErrors() should return nil, got %v", got)
}
}
func TestSanitizeLogSuffixNoDuplicates(t *testing.T) {
testCases := []string{
"task",
"task.",
".task",
"-task",
"task-",
"--task--",
"..task..",
}
seen := make(map[string]string)
for _, input := range testCases {
result := sanitizeLogSuffix(input)
if result == "" {
t.Fatalf("sanitizeLogSuffix(%q) returned empty string", input)
}
if prev, exists := seen[result]; exists {
t.Fatalf("collision detected: %q and %q both produce %q", input, prev, result)
}
seen[result] = input
// Verify result is safe for file names
if strings.ContainsAny(result, "/\\:*?\"<>|") {
t.Fatalf("sanitizeLogSuffix(%q) = %q contains unsafe characters", input, result)
}
}
}
func TestExtractRecentErrorsBoundaryCheck(t *testing.T) {
logger, err := NewLoggerWithSuffix("boundary-test")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer logger.Close()
defer func() { _ = logger.RemoveLogFile() }()
// Write some errors
logger.Error("error 1")
logger.Warn("warn 1")
logger.Error("error 2")
logger.Flush()
// Test zero
result := logger.ExtractRecentErrors(0)
if result != nil {
t.Fatalf("ExtractRecentErrors(0) should return nil, got %v", result)
}
// Test negative
result = logger.ExtractRecentErrors(-5)
if result != nil {
t.Fatalf("ExtractRecentErrors(-5) should return nil, got %v", result)
}
// Test positive still works
result = logger.ExtractRecentErrors(10)
if len(result) != 3 {
t.Fatalf("ExtractRecentErrors(10) expected 3 entries, got %d", len(result))
}
}
func TestErrorEntriesMaxLimit(t *testing.T) {
logger, err := NewLoggerWithSuffix("max-limit-test")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer logger.Close()
defer func() { _ = logger.RemoveLogFile() }()
// Write 150 error/warn entries
for i := 1; i <= 150; i++ {
if i%2 == 0 {
logger.Error(fmt.Sprintf("error-%03d", i))
} else {
logger.Warn(fmt.Sprintf("warn-%03d", i))
}
}
logger.Flush()
// Extract all cached errors
result := logger.ExtractRecentErrors(200) // Request more than cache size
// Should only have last 100 entries (entries 51-150 in sequence)
if len(result) != 100 {
t.Fatalf("expected 100 cached entries, got %d", len(result))
}
// Verify entries are the last 100 (entries 51-150)
if !strings.Contains(result[0], "051") {
t.Fatalf("first cached entry should be entry 51, got: %s", result[0])
}
if !strings.Contains(result[99], "150") {
t.Fatalf("last cached entry should be entry 150, got: %s", result[99])
}
// Verify order is preserved - simplified logic
for i := 0; i < len(result)-1; i++ {
expectedNum := 51 + i
nextNum := 51 + i + 1
expectedEntry := fmt.Sprintf("%03d", expectedNum)
nextEntry := fmt.Sprintf("%03d", nextNum)
if !strings.Contains(result[i], expectedEntry) {
t.Fatalf("entry at index %d should contain %s, got: %s", i, expectedEntry, result[i])
}
if !strings.Contains(result[i+1], nextEntry) {
t.Fatalf("entry at index %d should contain %s, got: %s", i+1, nextEntry, result[i+1])
}
}
}

View File

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,67 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,436 @@
package parser
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/goccy/go-json"
)
const (
jsonLineReaderSize = 64 * 1024
jsonLineMaxBytes = 10 * 1024 * 1024
jsonLinePreviewBytes = 256
)
type lineScratch struct {
buf []byte
preview []byte
}
const maxPooledLineScratchCap = 1 << 20 // 1 MiB
var lineScratchPool = sync.Pool{
New: func() any {
return &lineScratch{
buf: make([]byte, 0, jsonLineReaderSize),
preview: make([]byte, 0, jsonLinePreviewBytes),
}
},
}
func ParseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
scratch := lineScratchPool.Get().(*lineScratch)
if scratch.buf == nil {
scratch.buf = make([]byte, 0, jsonLineReaderSize)
} else {
scratch.buf = scratch.buf[:0]
}
if scratch.preview == nil {
scratch.preview = make([]byte, 0, jsonLinePreviewBytes)
} else {
scratch.preview = scratch.preview[:0]
}
defer func() {
if cap(scratch.buf) > maxPooledLineScratchCap {
scratch.buf = nil
} else if scratch.buf != nil {
scratch.buf = scratch.buf[:0]
}
if cap(scratch.preview) > jsonLinePreviewBytes*4 {
scratch.preview = nil
} else if scratch.preview != nil {
scratch.preview = scratch.preview[:0]
}
lineScratchPool.Put(scratch)
}()
if warnFn == nil {
warnFn = func(string) {}
}
if infoFn == nil {
infoFn = func(string) {}
}
notifyMessage := func() {
if onMessage != nil {
onMessage()
}
}
notifyComplete := func() {
if onComplete != nil {
onComplete()
}
}
totalEvents := 0
var (
codexMessage string
claudeMessage string
geminiBuffer strings.Builder
opencodeMessage strings.Builder
)
for {
line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes, scratch)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
warnFn("Read stdout error: " + err.Error())
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
totalEvents++
if tooLong {
warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, TruncateBytes(line, 100)))
continue
}
// Single unmarshal for all backend types
var event UnifiedEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse event: %s", TruncateBytes(line, 100)))
continue
}
// Detect backend type by field presence
isCodex := event.ThreadID != ""
if !isCodex && len(event.Item) > 0 {
var itemHeader struct {
Type string `json:"type"`
}
if json.Unmarshal(event.Item, &itemHeader) == nil && itemHeader.Type != "" {
isCodex = true
}
}
// Codex-specific event types without thread_id or item
if !isCodex && (event.Type == "turn.started" || event.Type == "turn.completed") {
isCodex = true
}
isClaude := event.Subtype != "" || event.Result != ""
if !isClaude && event.Type == "result" && event.SessionID != "" && event.Status == "" {
isClaude = true
}
isGemini := (event.Type == "init" && event.SessionID != "") || event.Role != "" || event.Delta != nil || event.Status != ""
isOpencode := event.OpencodeSessionID != "" && len(event.Part) > 0
// Handle Opencode events first (most specific detection)
if isOpencode {
if threadID == "" {
threadID = event.OpencodeSessionID
}
var part OpencodePart
if err := json.Unmarshal(event.Part, &part); err != nil {
warnFn(fmt.Sprintf("Failed to parse opencode part: %s", err.Error()))
continue
}
// Extract sessionID from part if available
if part.SessionID != "" && threadID == "" {
threadID = part.SessionID
}
infoFn(fmt.Sprintf("Parsed Opencode event #%d type=%s part_type=%s", totalEvents, event.Type, part.Type))
if event.Type == "text" && part.Text != "" {
opencodeMessage.WriteString(part.Text)
notifyMessage()
}
if part.Type == "step-finish" && part.Reason == "stop" {
notifyComplete()
}
continue
}
// Handle Codex events
if isCodex {
var details []string
if event.ThreadID != "" {
details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID))
}
if len(details) > 0 {
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", ")))
} else {
infoFn(fmt.Sprintf("Parsed event #%d type=%s", totalEvents, event.Type))
}
switch event.Type {
case "thread.started":
threadID = event.ThreadID
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
case "thread.completed":
if event.ThreadID != "" && threadID == "" {
threadID = event.ThreadID
}
infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID))
notifyComplete()
case "turn.completed":
infoFn("turn.completed event")
notifyComplete()
case "item.completed":
var itemType string
if len(event.Item) > 0 {
var itemHeader struct {
Type string `json:"type"`
}
if err := json.Unmarshal(event.Item, &itemHeader); err == nil {
itemType = itemHeader.Type
}
}
if itemType == "agent_message" && len(event.Item) > 0 {
// Lazy parse: only parse item content when needed
var item ItemContent
if err := json.Unmarshal(event.Item, &item); err == nil {
normalized := NormalizeText(item.Text)
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
if normalized != "" {
codexMessage = normalized
notifyMessage()
}
} else {
warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error()))
}
} else {
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
}
}
continue
}
// Handle Claude events
if isClaude {
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
infoFn(fmt.Sprintf("Parsed Claude event #%d type=%s subtype=%s result_len=%d", totalEvents, event.Type, event.Subtype, len(event.Result)))
if event.Result != "" {
claudeMessage = event.Result
notifyMessage()
}
if event.Type == "result" {
notifyComplete()
}
continue
}
// Handle Gemini events
if isGemini {
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
if event.Content != "" {
geminiBuffer.WriteString(event.Content)
}
if event.Status != "" {
notifyMessage()
if event.Type == "result" && (event.Status == "success" || event.Status == "error" || event.Status == "complete" || event.Status == "failed") {
notifyComplete()
}
}
delta := false
if event.Delta != nil {
delta = *event.Delta
}
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, delta, event.Status, len(event.Content)))
continue
}
// Unknown event format from other backends (turn.started/assistant/user); ignore.
continue
}
switch {
case opencodeMessage.Len() > 0:
message = opencodeMessage.String()
case geminiBuffer.Len() > 0:
message = geminiBuffer.String()
case claudeMessage != "":
message = claudeMessage
default:
message = codexMessage
}
infoFn(fmt.Sprintf("parseJSONStream completed: events=%d, message_len=%d, thread_id_found=%t", totalEvents, len(message), threadID != ""))
return message, threadID
}
func HasKey(m map[string]json.RawMessage, key string) bool {
_, ok := m[key]
return ok
}
func DiscardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
var buffered bytes.Buffer
if decoder != nil {
if buf := decoder.Buffered(); buf != nil {
_, _ = buffered.ReadFrom(buf)
}
}
line, err := reader.ReadBytes('\n')
buffered.Write(line)
data := buffered.Bytes()
newline := bytes.IndexByte(data, '\n')
if newline == -1 {
return reader, err
}
remaining := data[newline+1:]
if len(remaining) == 0 {
return reader, err
}
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
}
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int, scratch *lineScratch) (line []byte, tooLong bool, err error) {
if r == nil {
return nil, false, errors.New("reader is nil")
}
if maxBytes <= 0 {
return nil, false, errors.New("maxBytes must be > 0")
}
if previewBytes < 0 {
previewBytes = 0
}
part, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
if !isPrefix {
if len(part) > maxBytes {
return part[:min(len(part), previewBytes)], true, nil
}
return part, false, nil
}
if scratch == nil {
scratch = &lineScratch{}
}
if scratch.preview == nil {
scratch.preview = make([]byte, 0, min(previewBytes, len(part)))
}
if scratch.buf == nil {
scratch.buf = make([]byte, 0, min(maxBytes, len(part)*2))
}
preview := scratch.preview[:0]
if previewBytes > 0 {
preview = append(preview, part[:min(previewBytes, len(part))]...)
}
buf := scratch.buf[:0]
total := 0
if len(part) > maxBytes {
tooLong = true
} else {
buf = append(buf, part...)
total = len(part)
}
for isPrefix {
part, isPrefix, err = r.ReadLine()
if err != nil {
return nil, tooLong, err
}
if previewBytes > 0 && len(preview) < previewBytes {
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
}
if !tooLong {
if total+len(part) > maxBytes {
tooLong = true
continue
}
buf = append(buf, part...)
total += len(part)
}
}
if tooLong {
scratch.preview = preview
scratch.buf = buf
return preview, true, nil
}
scratch.preview = preview
scratch.buf = buf
return buf, false, nil
}
func TruncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
if maxLen < 0 {
return ""
}
return string(b[:maxLen]) + "..."
}
func NormalizeText(text interface{}) string {
switch v := text.(type) {
case string:
return v
case []interface{}:
var sb strings.Builder
for _, item := range v {
if s, ok := item.(string); ok {
sb.WriteString(s)
}
}
return sb.String()
default:
return ""
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,50 @@
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,4 +1,4 @@
package main
package parser
import (
"strings"
@@ -18,7 +18,7 @@ func TestParseJSONStream_SkipsOverlongLineAndContinues(t *testing.T) {
var warns []string
warnFn := func(msg string) { warns = append(warns, msg) }
gotMessage, gotThreadID := parseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil)
gotMessage, gotThreadID := ParseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil, nil)
if gotMessage != "ok" {
t.Fatalf("message=%q, want %q (warns=%v)", gotMessage, "ok", warns)
}

View File

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,15 @@
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

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

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,122 @@
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,469 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"reflect"
"strings"
"sync/atomic"
"time"
)
const (
version = "5.2.3"
defaultWorkdir = "."
defaultTimeout = 7200 // seconds
codexLogLineLimit = 1000
stdinSpecialChars = "\n\\\"'`$"
stderrCaptureLimit = 4 * 1024
defaultBackendName = "codex"
defaultCodexCommand = "codex"
// stdout close reasons
stdoutCloseReasonWait = "wait-done"
stdoutCloseReasonDrain = "drain-timeout"
stdoutCloseReasonCtx = "context-cancel"
stdoutDrainTimeout = 100 * time.Millisecond
)
// Test hooks for dependency injection
var (
stdinReader io.Reader = os.Stdin
isTerminalFn = defaultIsTerminal
codexCommand = defaultCodexCommand
cleanupHook func()
loggerPtr atomic.Pointer[Logger]
buildCodexArgsFn = buildCodexArgs
selectBackendFn = selectBackend
commandContext = exec.CommandContext
jsonMarshal = json.Marshal
cleanupLogsFn = cleanupOldLogs
signalNotifyFn = signal.Notify
signalStopFn = signal.Stop
terminateCommandFn = terminateCommand
defaultBuildArgsFn = buildCodexArgs
runTaskFn = runCodexTask
exitFn = os.Exit
)
var forceKillDelay atomic.Int32
func init() {
forceKillDelay.Store(5) // seconds - default value
}
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 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 main() {
exitCode := run()
exitFn(exitCode)
}
// run is the main logic, returns exit code for testability
func run() (exitCode int) {
name := currentWrapperName()
// Handle --version and --help first (no logger needed)
if len(os.Args) > 1 {
switch os.Args[1] {
case "--version", "-v":
fmt.Printf("%s version %s\n", name, version)
return 0
case "--help", "-h":
printHelp()
return 0
case "--cleanup":
return runCleanupMode()
}
}
// Initialize logger for all other commands
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)
}
// On failure, extract and display recent errors before removing log
if logger != nil {
if exitCode != 0 {
if errors := logger.ExtractRecentErrors(10); len(errors) > 0 {
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
for _, entry := range errors {
fmt.Fprintln(os.Stderr, entry)
}
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
}
}
if err := logger.RemoveLogFile(); err != nil && !os.IsNotExist(err) {
// Silently ignore removal errors
}
}
}()
defer runCleanupHook()
// Clean up stale logs from previous runs.
runStartupCleanup()
// Handle remaining commands
if len(os.Args) > 1 {
args := os.Args[1:]
parallelIndex := -1
for i, arg := range args {
if arg == "--parallel" {
parallelIndex = i
break
}
}
if parallelIndex != -1 {
backendName := defaultBackendName
var extras []string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--parallel":
continue
case arg == "--backend":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
return 1
}
backendName = args[i+1]
i++
case strings.HasPrefix(arg, "--backend="):
value := strings.TrimPrefix(arg, "--backend=")
if value == "" {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
return 1
}
backendName = value
default:
extras = append(extras, arg)
}
}
if len(extras) > 0 {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend is 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)
return 1
}
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
for i := range cfg.Tasks {
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
cfg.Tasks[i].Backend = backendName
}
}
timeoutSec := resolveTimeout()
layers, err := topologicalSort(cfg.Tasks)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
results := executeConcurrent(layers, timeoutSec)
fmt.Println(generateFinalOutput(results))
exitCode = 0
for _, res := range results {
if res.ExitCode != 0 {
exitCode = res.ExitCode
}
}
return exitCode
}
}
logInfo("Script started")
cfg, err := parseArgs()
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))
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()
// Wire selected backend into runtime hooks for the rest of the execution,
// but preserve any injected test hooks for the default backend.
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
}
}
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
targetArg := taskText
if useStdin {
targetArg = "-"
}
codexArgs := buildCodexArgsFn(cfg, targetArg)
// Print startup information to stderr
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,
UseStdin: useStdin,
}
result := runTaskFn(taskSpec, false, cfg.Timeout)
if result.ExitCode != 0 {
return result.ExitCode
}
fmt.Println(result.Message)
if result.SessionID != "" {
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
}
return 0
}
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 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 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 - [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 --version
%[1]s --help
Parallel mode examples:
%[1]s --parallel < tasks.txt
echo '...' | %[1]s --parallel
%[1]s --parallel <<'EOF'
Environment Variables:
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
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,346 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
)
// 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"`
}
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)
}
const (
jsonLineReaderSize = 64 * 1024
jsonLineMaxBytes = 10 * 1024 * 1024
jsonLinePreviewBytes = 256
)
type codexHeader struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Item *struct {
Type string `json:"type"`
} `json:"item,omitempty"`
}
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func()) (message, threadID string) {
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
if warnFn == nil {
warnFn = func(string) {}
}
if infoFn == nil {
infoFn = func(string) {}
}
notifyMessage := func() {
if onMessage != nil {
onMessage()
}
}
totalEvents := 0
var (
codexMessage string
claudeMessage string
geminiBuffer strings.Builder
)
for {
line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
warnFn("Read stdout error: " + err.Error())
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
totalEvents++
if tooLong {
warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, truncateBytes(line, 100)))
continue
}
var codex codexHeader
if err := json.Unmarshal(line, &codex); err == nil {
isCodex := codex.ThreadID != "" || (codex.Item != nil && codex.Item.Type != "")
if isCodex {
var details []string
if codex.ThreadID != "" {
details = append(details, fmt.Sprintf("thread_id=%s", codex.ThreadID))
}
if codex.Item != nil && codex.Item.Type != "" {
details = append(details, fmt.Sprintf("item_type=%s", codex.Item.Type))
}
if len(details) > 0 {
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, codex.Type, strings.Join(details, ", ")))
} else {
infoFn(fmt.Sprintf("Parsed event #%d type=%s", totalEvents, codex.Type))
}
switch codex.Type {
case "thread.started":
threadID = codex.ThreadID
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
case "item.completed":
itemType := ""
if codex.Item != nil {
itemType = codex.Item.Type
}
if itemType == "agent_message" {
var event JSONEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Codex event: %s", truncateBytes(line, 100)))
continue
}
normalized := ""
if event.Item != nil {
normalized = normalizeText(event.Item.Text)
}
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
if normalized != "" {
codexMessage = normalized
notifyMessage()
}
} else {
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
}
}
continue
}
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(line, &raw); err != nil {
warnFn(fmt.Sprintf("Failed to parse line: %s", truncateBytes(line, 100)))
continue
}
switch {
case hasKey(raw, "subtype") || hasKey(raw, "result"):
var event ClaudeEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Claude event: %s", truncateBytes(line, 100)))
continue
}
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
infoFn(fmt.Sprintf("Parsed Claude event #%d type=%s subtype=%s result_len=%d", totalEvents, event.Type, event.Subtype, len(event.Result)))
if event.Result != "" {
claudeMessage = event.Result
notifyMessage()
}
case hasKey(raw, "role") || hasKey(raw, "delta"):
var event GeminiEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Gemini event: %s", truncateBytes(line, 100)))
continue
}
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
if event.Content != "" {
geminiBuffer.WriteString(event.Content)
notifyMessage()
}
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, event.Delta, event.Status, len(event.Content)))
default:
warnFn(fmt.Sprintf("Unknown event format: %s", truncateBytes(line, 100)))
}
}
switch {
case geminiBuffer.Len() > 0:
message = geminiBuffer.String()
case claudeMessage != "":
message = claudeMessage
default:
message = codexMessage
}
infoFn(fmt.Sprintf("parseJSONStream completed: events=%d, message_len=%d, thread_id_found=%t", totalEvents, len(message), threadID != ""))
return message, threadID
}
func hasKey(m map[string]json.RawMessage, key string) bool {
_, ok := m[key]
return ok
}
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
var buffered bytes.Buffer
if decoder != nil {
if buf := decoder.Buffered(); buf != nil {
_, _ = buffered.ReadFrom(buf)
}
}
line, err := reader.ReadBytes('\n')
buffered.Write(line)
data := buffered.Bytes()
newline := bytes.IndexByte(data, '\n')
if newline == -1 {
return reader, err
}
remaining := data[newline+1:]
if len(remaining) == 0 {
return reader, err
}
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
}
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int) (line []byte, tooLong bool, err error) {
if r == nil {
return nil, false, errors.New("reader is nil")
}
if maxBytes <= 0 {
return nil, false, errors.New("maxBytes must be > 0")
}
if previewBytes < 0 {
previewBytes = 0
}
part, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
if !isPrefix {
if len(part) > maxBytes {
return part[:min(len(part), previewBytes)], true, nil
}
return part, false, nil
}
preview := make([]byte, 0, min(previewBytes, len(part)))
if previewBytes > 0 {
preview = append(preview, part[:min(previewBytes, len(part))]...)
}
buf := make([]byte, 0, min(maxBytes, len(part)*2))
total := 0
if len(part) > maxBytes {
tooLong = true
} else {
buf = append(buf, part...)
total = len(part)
}
for isPrefix {
part, isPrefix, err = r.ReadLine()
if err != nil {
return nil, tooLong, err
}
if previewBytes > 0 && len(preview) < previewBytes {
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
}
if !tooLong {
if total+len(part) > maxBytes {
tooLong = true
continue
}
buf = append(buf, part...)
total += len(part)
}
}
if tooLong {
return preview, true, nil
}
return buf, false, nil
}
func truncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
if maxLen < 0 {
return ""
}
return string(b[:maxLen]) + "..."
}
func normalizeText(text interface{}) string {
switch v := text.(type) {
case string:
return v
case []interface{}:
var sb strings.Builder
for _, item := range v {
if s, ok := item.(string); ok {
sb.WriteString(s)
}
}
return sb.String()
default:
return ""
}
}

View File

@@ -1,217 +0,0 @@
//go:build unix || darwin || linux
// +build unix darwin linux
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
func TestIsProcessRunning(t *testing.T) {
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)
}
})
t.Run("boundary values", func(t *testing.T) {
if isProcessRunning(0) {
t.Fatalf("pid 0 should never be treated as running")
}
if isProcessRunning(-42) {
t.Fatalf("negative pid should never be treated as running")
}
})
t.Run("find process error", func(t *testing.T) {
original := findProcess
defer func() { findProcess = original }()
mockErr := errors.New("findProcess failure")
findProcess = func(pid int) (*os.Process, error) {
return nil, mockErr
}
if isProcessRunning(1234) {
t.Fatalf("expected false when os.FindProcess fails")
}
})
}
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 TestRunProcessCheckSmoke(t *testing.T) {
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("boundary values", func(t *testing.T) {
if isProcessRunning(0) {
t.Fatalf("pid 0 should never be treated as running")
}
if isProcessRunning(-42) {
t.Fatalf("negative pid should never be treated as running")
}
})
t.Run("find process error", func(t *testing.T) {
original := findProcess
defer func() { findProcess = original }()
mockErr := errors.New("findProcess failure")
findProcess = func(pid int) (*os.Process, error) {
return nil, mockErr
}
if isProcessRunning(1234) {
t.Fatalf("expected false when os.FindProcess fails")
}
})
}
func TestGetProcessStartTimeReadsProcStat(t *testing.T) {
pid := 4321
boot := time.Unix(1_710_000_000, 0)
startTicks := uint64(4500)
statFields := make([]string, 25)
for i := range statFields {
statFields[i] = strconv.Itoa(i + 1)
}
statFields[19] = strconv.FormatUint(startTicks, 10)
statContent := fmt.Sprintf("%d (%s) %s", pid, "cmd with space", strings.Join(statFields, " "))
stubReadFile(t, func(path string) ([]byte, error) {
switch path {
case fmt.Sprintf("/proc/%d/stat", pid):
return []byte(statContent), nil
case "/proc/stat":
return []byte(fmt.Sprintf("cpu 0 0 0 0\nbtime %d\n", boot.Unix())), nil
default:
return nil, os.ErrNotExist
}
})
got := getProcessStartTime(pid)
want := boot.Add(time.Duration(startTicks/100) * time.Second)
if !got.Equal(want) {
t.Fatalf("getProcessStartTime() = %v, want %v", got, want)
}
}
func TestGetProcessStartTimeInvalidData(t *testing.T) {
pid := 99
stubReadFile(t, func(path string) ([]byte, error) {
switch path {
case fmt.Sprintf("/proc/%d/stat", pid):
return []byte("garbage"), nil
case "/proc/stat":
return []byte("btime not-a-number\n"), nil
default:
return nil, os.ErrNotExist
}
})
if got := getProcessStartTime(pid); !got.IsZero() {
t.Fatalf("invalid /proc data should return zero time, got %v", got)
}
}
func TestGetBootTimeParsesBtime(t *testing.T) {
const bootSec = 1_711_111_111
stubReadFile(t, func(path string) ([]byte, error) {
if path != "/proc/stat" {
return nil, os.ErrNotExist
}
content := fmt.Sprintf("intr 0\nbtime %d\n", bootSec)
return []byte(content), nil
})
got := getBootTime()
want := time.Unix(bootSec, 0)
if !got.Equal(want) {
t.Fatalf("getBootTime() = %v, want %v", got, want)
}
}
func TestGetBootTimeInvalidData(t *testing.T) {
cases := []struct {
name string
content string
}{
{"missing", "cpu 0 0 0 0"},
{"malformed", "btime abc"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
stubReadFile(t, func(string) ([]byte, error) {
return []byte(tt.content), nil
})
if got := getBootTime(); !got.IsZero() {
t.Fatalf("getBootTime() unexpected value for %s: %v", tt.name, got)
}
})
}
}
func stubReadFile(t *testing.T, fn func(string) ([]byte, error)) {
t.Helper()
original := readFileFn
readFileFn = fn
t.Cleanup(func() {
readFileFn = original
})
}

View File

@@ -1,104 +0,0 @@
//go:build unix || darwin || linux
// +build unix darwin linux
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
)
var findProcess = os.FindProcess
var readFileFn = os.ReadFile
// isProcessRunning returns true if a process with the given pid is running on Unix-like systems.
func isProcessRunning(pid int) bool {
if pid <= 0 {
return false
}
proc, err := findProcess(pid)
if err != nil || proc == nil {
return false
}
err = proc.Signal(syscall.Signal(0))
if err != nil && (errors.Is(err, syscall.ESRCH) || errors.Is(err, os.ErrProcessDone)) {
return false
}
return true
}
// getProcessStartTime returns the start time of a process on Unix-like systems.
// Returns zero time if the start time cannot be determined.
func getProcessStartTime(pid int) time.Time {
if pid <= 0 {
return time.Time{}
}
// Read /proc/<pid>/stat to get process start time
statPath := fmt.Sprintf("/proc/%d/stat", pid)
data, err := readFileFn(statPath)
if err != nil {
return time.Time{}
}
// Parse stat file: fields are space-separated, but comm (field 2) can contain spaces
// Find the last ')' to skip comm field safely
content := string(data)
lastParen := strings.LastIndex(content, ")")
if lastParen == -1 {
return time.Time{}
}
fields := strings.Fields(content[lastParen+1:])
if len(fields) < 20 {
return time.Time{}
}
// Field 22 (index 19 after comm) is starttime in clock ticks since boot
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return time.Time{}
}
// Get system boot time
bootTime := getBootTime()
if bootTime.IsZero() {
return time.Time{}
}
// Convert ticks to duration (typically 100 ticks/sec on most systems)
ticksPerSec := uint64(100) // sysconf(_SC_CLK_TCK), typically 100
startTime := bootTime.Add(time.Duration(startTicks/ticksPerSec) * time.Second)
return startTime
}
// getBootTime returns the system boot time by reading /proc/stat.
func getBootTime() time.Time {
data, err := readFileFn("/proc/stat")
if err != nil {
return time.Time{}
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "btime ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
bootSec, err := strconv.ParseInt(fields[1], 10, 64)
if err == nil {
return time.Unix(bootSec, 0)
}
}
}
}
return time.Time{}
}

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