Merge pull request #4471 from code-yeongyu/omodex

Introduce LazyCodex
This commit is contained in:
YeonGyu-Kim
2026-05-31 02:37:21 +09:00
committed by GitHub
698 changed files with 90688 additions and 1879 deletions
+57 -7
View File
@@ -33,12 +33,20 @@ jobs:
fi
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build lsp-tools-mcp submodule
run: npm ci && npm run build
working-directory: packages/lsp-tools-mcp
@@ -61,12 +69,20 @@ jobs:
run: bun test
typecheck:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build lsp-tools-mcp submodule
run: npm ci && npm run build
working-directory: packages/lsp-tools-mcp
@@ -91,9 +107,41 @@ jobs:
- name: Type check script tooling
run: bun run typecheck:script
codex-compatibility:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.12"
- uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Run Codex compatibility tests
run: bun run test:codex
build:
runs-on: ubuntu-latest
needs: [test, typecheck]
needs: [test, typecheck, codex-compatibility]
permissions:
contents: write
steps:
@@ -159,15 +207,17 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: "1.3.12"
- name: Generate release notes
id: notes
run: |
NOTES=$(bun run script/generate-changelog.ts)
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+19 -19
View File
@@ -49,7 +49,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: "1.3.12"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -75,8 +75,8 @@ jobs:
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
- name: Check if already published
id: check
@@ -95,26 +95,26 @@ jobs:
echo "oh-my-openagent-${{ matrix.platform }}@${VERSION}: ${OA_STATUS}"
if [ "$OC_STATUS" = "200" ]; then
echo "skip_opencode=true" >> $GITHUB_OUTPUT
echo "skip_opencode=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_opencode=false" >> $GITHUB_OUTPUT
echo "skip_opencode=false" >> "$GITHUB_OUTPUT"
echo "→ oh-my-opencode-${{ matrix.platform }}@${VERSION} needs publishing"
fi
if [ "$OA_STATUS" = "200" ]; then
echo "skip_openagent=true" >> $GITHUB_OUTPUT
echo "skip_openagent=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_openagent=false" >> $GITHUB_OUTPUT
echo "skip_openagent=false" >> "$GITHUB_OUTPUT"
echo "→ oh-my-openagent-${{ matrix.platform }}@${VERSION} needs publishing"
fi
# Skip build only if BOTH are already published
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Update version in package.json
@@ -288,8 +288,8 @@ jobs:
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
- name: Check if already published
id: check
@@ -300,30 +300,29 @@ jobs:
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
if [ "$OC_STATUS" = "200" ]; then
echo "skip_opencode=true" >> $GITHUB_OUTPUT
echo "skip_opencode=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_opencode=false" >> $GITHUB_OUTPUT
echo "skip_opencode=false" >> "$GITHUB_OUTPUT"
fi
if [ "$OA_STATUS" = "200" ]; then
echo "skip_openagent=true" >> $GITHUB_OUTPUT
echo "skip_openagent=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_openagent=false" >> $GITHUB_OUTPUT
echo "skip_openagent=false" >> "$GITHUB_OUTPUT"
fi
# Need artifact if either package needs publishing
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
echo "skip_all=true" >> $GITHUB_OUTPUT
echo "skip_all=true" >> "$GITHUB_OUTPUT"
else
echo "skip_all=false" >> $GITHUB_OUTPUT
echo "skip_all=false" >> "$GITHUB_OUTPUT"
fi
- name: Download artifact
id: download
if: steps.check.outputs.skip_all != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: binary-${{ matrix.platform }}
@@ -369,6 +368,7 @@ jobs:
- name: Publish oh-my-opencode-${{ matrix.platform }}
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
continue-on-error: true
env:
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
NPM_CONFIG_PROVENANCE: true
@@ -383,7 +383,7 @@ jobs:
timeout-minutes: 15
- name: Publish oh-my-openagent-${{ matrix.platform }}
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
if: always() && steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
env:
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
NPM_CONFIG_PROVENANCE: true
+267 -79
View File
@@ -22,6 +22,11 @@ on:
required: false
type: boolean
default: false
sync_lazycodex_marketplace:
description: "Sync the LazyCodex Codex marketplace repository"
required: false
type: boolean
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -38,7 +43,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
bun-version: "1.3.12"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -55,7 +60,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
bun-version: "1.3.12"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -72,7 +77,17 @@ jobs:
id-token: write
contents: read
steps:
- name: Verify trusted publisher for all 24 packages
- name: Require LazyCodex sync token
if: inputs.sync_lazycodex_marketplace == true
env:
LAZYCODEX_SYNC_TOKEN: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}
run: |
if [ -z "$LAZYCODEX_SYNC_TOKEN" ]; then
echo "::error::LAZYCODEX_SYNC_TOKEN is required to push the Codex marketplace bundle to code-yeongyu/lazycodex."
exit 1
fi
- name: Verify trusted publisher for release packages
env:
REPO: code-yeongyu/oh-my-openagent
WORKFLOW_FILE: publish.yml
@@ -87,7 +102,7 @@ jobs:
fi
PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline)
ALL_PACKAGES=(oh-my-opencode oh-my-openagent)
ALL_PACKAGES=(oh-my-opencode oh-my-openagent lazycodex)
for plat in "${PLATFORMS[@]}"; do
ALL_PACKAGES+=("oh-my-opencode-${plat}")
ALL_PACKAGES+=("oh-my-openagent-${plat}")
@@ -115,7 +130,7 @@ jobs:
if [ ${#FAILED[@]} -gt 0 ]; then
{
echo
echo "::error::Trusted publisher not configured for ${#FAILED[@]} package(s)."
echo "::error::Trusted publisher not configured for ${#FAILED[@]} required package(s)."
echo "::error::Configure each below at the URL with these values:"
echo "::error:: Provider: GitHub Actions"
echo "::error:: Organization: code-yeongyu"
@@ -132,38 +147,12 @@ jobs:
echo
echo "All ${#ALL_PACKAGES[@]} packages have trusted publisher configured."
publish-main:
release-metadata:
runs-on: ubuntu-latest
needs: [test, typecheck, preflight-trust]
if: github.repository == 'code-yeongyu/oh-my-openagent'
outputs:
version: ${{ steps.version.outputs.version }}
dist_tag: ${{ steps.version.outputs.dist_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- run: git fetch --force --tags
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for trusted publishing (>=11.5.1)
run: npm install -g npm@latest
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Calculate version
id: version
env:
@@ -187,7 +176,7 @@ jobs:
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" == *"-"* ]]; then
DIST_TAG=$(printf '%s' "$VERSION" | cut -d'-' -f2 | cut -d'.' -f1)
@@ -195,30 +184,122 @@ jobs:
echo "::error::Invalid dist_tag: $DIST_TAG"
exit 1
fi
echo "dist_tag=${DIST_TAG:-next}" >> $GITHUB_OUTPUT
echo "dist_tag=${DIST_TAG:-next}" >> "$GITHUB_OUTPUT"
else
echo "dist_tag=" >> $GITHUB_OUTPUT
echo "dist_tag=" >> "$GITHUB_OUTPUT"
fi
echo "Version: $VERSION"
publish-main:
runs-on: ubuntu-latest
needs: [test, typecheck, preflight-trust, release-metadata, publish-platform]
if: >-
always() &&
github.repository == 'code-yeongyu/oh-my-openagent' &&
needs.test.result == 'success' &&
needs.typecheck.result == 'success' &&
needs.preflight-trust.result == 'success' &&
needs.release-metadata.result == 'success' &&
(inputs.skip_platform == true || needs.publish-platform.result == 'success')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- run: git fetch --force --tags
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.12"
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for trusted publishing (>=11.5.1)
run: npm install -g npm@latest
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Verify platform packages are published
env:
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline)
FAILED=()
for platform in "${PLATFORMS[@]}"; do
for family in oh-my-opencode oh-my-openagent; do
pkg="${family}-${platform}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${pkg}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "OK ${pkg}@${VERSION}"
else
echo "MISS ${pkg}@${VERSION} (HTTP ${STATUS})"
FAILED+=("${pkg}")
fi
done
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "::error::Missing platform package(s); refusing to publish wrappers."
for pkg in "${FAILED[@]}"; do
echo "::error:: ${pkg}@${VERSION}"
done
exit 1
fi
- name: Check if already published
id: check
env:
VERSION: ${{ steps.version.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-opencode@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check if oh-my-openagent already published
id: check-openagent
env:
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "✓ oh-my-openagent@${VERSION} already published"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check if lazycodex already published
id: check-lazycodex
env:
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/lazycodex/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "✓ lazycodex@${VERSION} already published"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Update version
if: steps.check.outputs.skip != 'true'
if: >-
steps.check.outputs.skip != 'true' ||
steps.check-openagent.outputs.skip != 'true' ||
steps.check-lazycodex.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
@@ -231,11 +312,17 @@ jobs:
jq --arg v "$VERSION" '.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)' package.json > tmp.json && mv tmp.json package.json
- name: Build main package
if: steps.check.outputs.skip != 'true'
if: >-
steps.check.outputs.skip != 'true' ||
steps.check-openagent.outputs.skip != 'true' ||
steps.check-lazycodex.outputs.skip != 'true'
run: bun run build
- name: Strip token auth from .npmrc to force OIDC
if: steps.check.outputs.skip != 'true'
if: >-
steps.check.outputs.skip != 'true' ||
steps.check-openagent.outputs.skip != 'true' ||
steps.check-lazycodex.outputs.skip != 'true'
run: |
for f in .npmrc "$HOME/.npmrc"; do
if [ -f "$f" ]; then
@@ -248,33 +335,20 @@ jobs:
- name: Publish oh-my-opencode
if: steps.check.outputs.skip != 'true'
env:
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
NPM_CONFIG_PROVENANCE: true
run: |
if [ -n "$DIST_TAG" ]; then
npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose
else
npm publish --access public --provenance --loglevel verbose
fi
- name: Check if oh-my-openagent already published
id: check-openagent
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-openagent@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
npm publish --access public --provenance --tag latest --loglevel verbose
fi
- name: Publish oh-my-openagent
if: steps.check-openagent.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
VERSION: ${{ needs.release-metadata.outputs.version }}
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
NPM_CONFIG_PROVENANCE: true
run: |
# Update package name, version, and optionalDependencies for oh-my-openagent
@@ -299,19 +373,58 @@ jobs:
run: |
git checkout -- package.json
- name: Publish lazycodex
if: steps.check-lazycodex.outputs.skip != 'true'
env:
OMO_VERSION: ${{ needs.release-metadata.outputs.version }}
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
NPM_CONFIG_PROVENANCE: true
run: |
jq --arg omo_version "$OMO_VERSION" '
.name = "lazycodex" |
.version = $omo_version |
.optionalDependencies = (
.optionalDependencies | to_entries |
map(.key = (.key | sub("^oh-my-opencode-"; "oh-my-openagent-")) | .value = $omo_version) |
from_entries
)
' package.json > tmp.json && mv tmp.json package.json
if [ -n "$DIST_TAG" ]; then
npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose
else
npm publish --access public --provenance --tag latest --loglevel verbose
fi
- name: Restore package.json after lazycodex publish attempt
if: always() && steps.check-lazycodex.outputs.skip != 'true'
run: |
git checkout -- package.json
publish-platform:
needs: publish-main
if: inputs.skip_platform != true
needs: [test, typecheck, preflight-trust, release-metadata]
if: >-
always() &&
github.repository == 'code-yeongyu/oh-my-openagent' &&
inputs.skip_platform != true &&
needs.test.result == 'success' &&
needs.typecheck.result == 'success' &&
needs.preflight-trust.result == 'success' &&
needs.release-metadata.result == 'success'
uses: ./.github/workflows/publish-platform.yml
with:
version: ${{ needs.publish-main.outputs.version }}
dist_tag: ${{ needs.publish-main.outputs.dist_tag }}
version: ${{ needs.release-metadata.outputs.version }}
dist_tag: ${{ needs.release-metadata.outputs.dist_tag }}
secrets: inherit
release:
runs-on: ubuntu-latest
needs: [publish-main, publish-platform]
if: always() && needs.publish-main.result == 'success' && (inputs.skip_platform == true || needs.publish-platform.result == 'success')
needs: [release-metadata, publish-main, publish-platform]
if: >-
always() &&
needs.release-metadata.result == 'success' &&
needs.publish-main.result == 'success' &&
(inputs.skip_platform == true || needs.publish-platform.result == 'success')
steps:
- uses: actions/checkout@v4
with:
@@ -321,7 +434,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: "1.3.12"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -335,9 +448,36 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Apply release version to source tree
- name: Resolve release state
id: release-state
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
RELEASE_REF: ${{ github.ref_name }}
run: |
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
echo "release_commit_exists=true" >> "$GITHUB_OUTPUT"
git checkout --detach "v${VERSION}"
exit 0
fi
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
RELEASE_SHA=""
if [ -n "$RELEASE_REF" ]; then
RELEASE_SHA=$(git rev-list --max-count=1 --grep="^release: v${VERSION}$" "origin/${RELEASE_REF}" 2>/dev/null || true)
fi
if [ -n "$RELEASE_SHA" ]; then
echo "release_commit_exists=true" >> "$GITHUB_OUTPUT"
git checkout --detach "$RELEASE_SHA"
else
echo "release_commit_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Apply release version to source tree
if: steps.release-state.outputs.release_commit_exists != 'true'
env:
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
@@ -350,8 +490,9 @@ jobs:
jq --arg v "$VERSION" '.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)' package.json > tmp.json && mv tmp.json package.json
- name: Commit version bump
if: steps.release-state.outputs.release_commit_exists != 'true'
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
@@ -359,26 +500,73 @@ jobs:
git diff --cached --quiet || git commit -m "release: v${VERSION}"
- name: Create release tag
if: steps.release-state.outputs.tag_exists != 'true'
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
echo "::error::Tag v${VERSION} already exists"
exit 1
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Release tag v${VERSION} already exists locally"
else
git tag "v${VERSION}"
fi
git tag "v${VERSION}"
- name: Push release state
if: steps.release-state.outputs.tag_exists != 'true'
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
RELEASE_REF: ${{ github.ref_name }}
RELEASE_COMMIT_EXISTS: ${{ steps.release-state.outputs.release_commit_exists }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git push origin HEAD
git push origin "v${VERSION}"
if [ "$RELEASE_COMMIT_EXISTS" != "true" ]; then
if [ -z "$RELEASE_REF" ]; then
echo "::error::Cannot push release commit because github.ref_name is empty."
exit 1
fi
git push origin "HEAD:${RELEASE_REF}"
else
echo "Release commit already exists on origin/${RELEASE_REF}"
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "Release tag v${VERSION} already exists on origin"
else
git push origin "v${VERSION}"
fi
- name: Checkout LazyCodex marketplace
if: inputs.sync_lazycodex_marketplace == true
uses: actions/checkout@v4
with:
repository: code-yeongyu/lazycodex
path: lazycodex-marketplace
token: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}
fetch-depth: 0
- name: Sync LazyCodex Codex marketplace
if: inputs.sync_lazycodex_marketplace == true
env:
VERSION: ${{ needs.release-metadata.outputs.version }}
run: |
npm --prefix packages/omo-codex/plugin ci
bun run --cwd packages/omo-codex/plugin build
bun run build:ast-grep-mcp
bun run build:lsp-tools-mcp
bun run script/sync-lazycodex-marketplace.ts "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/lazycodex-marketplace"
cd "$GITHUB_WORKSPACE/lazycodex-marketplace"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add .agents/plugins/marketplace.json plugins/omo
if git diff --cached --quiet; then
echo "LazyCodex marketplace already up to date"
else
git commit -m "chore: sync Codex marketplace v${VERSION}"
git push origin HEAD:main
fi
- name: Create GitHub release
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "v${VERSION}" >/dev/null 2>&1 || \
@@ -392,7 +580,7 @@ jobs:
- name: Merge to master
continue-on-error: true
env:
VERSION: ${{ needs.publish-main.outputs.version }}
VERSION: ${{ needs.release-metadata.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
@@ -18,7 +18,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: "1.3.12"
- name: Install dependencies
run: bun install --frozen-lockfile
+23 -21
View File
@@ -59,7 +59,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: "1.3.12"
- name: Cache Bun dependencies
uses: actions/cache@v4
@@ -345,29 +345,31 @@ jobs:
COMMENT_ID_VAL: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
{
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue and get title
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
# Check if PR or Issue and get title
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
echo "type=pr"
echo "number=${ISSUE_NUM}"
else
echo "type=issue"
echo "number=${ISSUE_NUM}"
fi
echo "title=${TITLE}"
fi
echo "title=${TITLE}" >> $GITHUB_OUTPUT
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
echo "comment<<EOF"
echo "$COMMENT_BODY"
echo "EOF"
echo "author=$AUTHOR"
echo "comment_id=$COMMENT_ID"
} >> "$GITHUB_OUTPUT"
# Add :eyes: reaction (as sisyphus-dev-ai)
- name: Add eyes reaction
+1 -1
View File
@@ -31,6 +31,7 @@ npm-debug.log*
# Lock files (use bun.lockb instead)
package-lock.json
!packages/omo-codex/plugin/package-lock.json
yarn.lock
# Environment
@@ -40,7 +41,6 @@ test-injection/
notepad.md
oauth-success.html
*.bun-build
.omx/
.dori-sync/
.dori/
.playwright-mcp/
+6
View File
@@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Per-agent `displayName` for i18n. Agents can present localized names in UI and logs. (PR #4081)
- Grok family models registered with `reasoningEffort` support. (PR #4186)
- CLI `setup` alias for `install`. Either command runs the interactive setup wizard. (PR #4174)
- Codex CLI Light edition (`omo-codex`): one-command install via `bunx omo install --platform=codex` or the new `lazycodex` bin entry. Vendored Codex plugin namespace `omo` with rules, comment-checker, LSP, ultrawork, and ulw-loop components. Plugin lands in `~/.codex/plugins/cache/sisyphuslabs/omo/` and is enabled in `~/.codex/config.toml`. Idempotent installer (re-running is safe).
- New `--platform <opencode|codex|both>` install flag (default `opencode`). Replaces the previous Codex-as-optional-addon model — `--platform=codex` installs only the Codex Light edition, `--platform=both` installs both editions in one run.
- Three new bin entries: `omo` (short alias) and `lazycodex` (auto-defaults `--platform=codex`). Existing `oh-my-opencode` and `oh-my-openagent` continue to work unchanged.
- New PostHog telemetry stream `omo_codex_daily_active` distinguishing omo-codex installations from omo-opencode. Independent opt-out via `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`; global `OMO_DISABLE_POSTHOG` and `OMO_SEND_ANONYMOUS_TELEMETRY` still suppress both products.
- omo-codex now reports true daily-active usage (DAU/WAU/MAU). A new Codex plugin component `telemetry` (`packages/omo-codex/plugin/components/telemetry/`) fires a single `omo_codex_daily_active` event with `reason: "session_start"` from every Codex `SessionStart` hook, with the same UTC-day deduplication, hashed installation identifier, and opt-out env vars as the install-time event. Identity constants stay byte-equivalent across the CLI installer and the plugin runtime via `packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts`.
- Triple-publish to npm: `oh-my-opencode`, `oh-my-openagent`, and the new `lazycodex` package with the same compiled CLI and four bin commands. See `docs/reference/lazycodex-npm-reservation.md` for the first-publish playbook.
### Changed
+33 -18
View File
@@ -123,6 +123,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
匿名のテレメトリは、アクティブなインストール数(DAU/WAU/MAU)の集計のためにデフォルトで有効になっています。マシン1台につきUTC日あたり最大1回イベントが送信され、ハッシュ化されたインストール識別子を使用し、生のホスト名は使用せず、PostHog person profile も作成されません。無効化するには `OMO_SEND_ANONYMOUS_TELEMETRY=0` または `OMO_DISABLE_POSTHOG=1` を設定してください。[プライバシーポリシー](docs/legal/privacy-policy.md)と[利用規約](docs/legal/terms-of-service.md)をご覧ください。
**Ultimate と Light:** oh-my-openagent は同じ製品の 2 つのエディションとして提供されます。**Ultimate エディション**`bunx omo install` または `--platform=opencode`、デフォルト)は OpenCode 上のフル機能で、11 エージェント、54+ フック、Team Mode、すべての MCP、スラッシュコマンド、IntentGate モードを提供します。**Light エディション**(`bunx omo install --platform=codex`)は OpenAI Codex CLI のプラグインシステムへ綺麗に移植できる 5 コンポーネント(`rules``comment-checker``lsp``ultrawork``ulw-loop`)のみを提供します。`bunx lazycodex install``--platform=codex` のショートカット別名です。両方を同時にインストールするには `--platform=both`。Codex 専用テレメトリは `OMO_CODEX_DISABLE_POSTHOG=1` または `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` で無効化できます。
---
## この README をスキップする
@@ -150,24 +152,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
- [GLM Coding プラン ($10)](https://z.ai/subscribe)
- 従量課金 (pay-per-token) の対象であれば、Kimi や Gemini モデルを使っても費用はそれほどかかりません。
| | 機能 | 何をするのか |
| :---: | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **規律あるエージェント (Discipline Agents)** | Sisyphus が Hephaestus、Oracle、Librarian、Explore をオーケストレーションします。完全な AI 開発チームが並列で動きます。 |
| 👥 | **Team Mode** (v4.0, オプトイン) | リードエージェント + 最大 8 メンバーの並列実行、リアルタイム tmux 可視化、専用 `team_*` ツール群。`hyperplan`(5 人の敵対的批評家)と `security-research`(3 人のハンター + 2 人の PoC エンジニア)を駆動します。[ドキュメント →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | 一言で OK。すべてのエージェントがアクティブになり、終わるまで止まりません。 |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | ユーザーの真の意図を分析してから分類・行動します。もう文字通りに誤解して的外れなことをすることはありません。 |
| 🔗 | **ハッシュベースの編集ツール** | `LINE#ID` のコンテンツハッシュですべての変更を検証します。stale-line エラー 0%。[oh-my-pi](https://github.com/can1357/oh-my-pi) にインスパイアされています。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | ワークスペース単位のリネーム、ビルド前の診断、AST を考慮した書き換え。エージェントに IDE レベルの精度を提供します。 |
| 🧠 | **バックグラウンドエージェント** | 5 人以上の専門家を並列で投入します。コンテキストは軽く保ち、結果は準備ができ次第受け取ります。 |
| 📚 | **組み込み MCP** | Exa (Web 検索)、Context7 (公式ドキュメント)、Grep.app (GitHub 検索)。常にオンです。 |
| 🔁 | **Ralph Loop / `/ulw-loop`** | 自己参照ループ。100% 完了するまで絶対に止まりません。 |
| | **Todo Enforcer** | エージェントがサボる?システムが首根っこを掴んで戻します。あなたのタスクは必ず終わります。 |
| 💬 | **コメントチェッカー** | コメントから AI 臭い無駄話を排除します。シニアエンジニアが書いたようなコードになります。 |
| 🖥️ | **Tmux 統合** | 完全なインタラクティブターミナル。REPL、デバッガー、TUI アプリがすべてリアルタイムで動きます。 |
| 🔌 | **Claude Code 互換性** | 既存のフック、コマンド、スキル、MCP、プラグイン?すべてここでそのまま動きます。 |
| 🎯 | **スキル内蔵 MCP** | スキルが独自の MCP サーバーを持ち歩きます。コンテキストが肥大化しません。 |
| 📋 | **Prometheus プランナー** | インタビューモードで、実行前に戦略的な計画から立てます。 |
| 🔍 | **`/init-deep`** | プロジェクト全体にわたって階層的な `AGENTS.md` ファイルを自動生成します。トークン効率とエージェントのパフォーマンスの両方を向上させます。 |
| | 機能 | Editions | 何をするのか |
| :---: | :------------------------------------------------------- | :------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **規律あるエージェント (Discipline Agents)** | Ultimate | Sisyphus が Hephaestus、Oracle、Librarian、Explore をオーケストレーションします。完全な AI 開発チームが並列で動きます。 |
| 🧩 | **Codex CLI Light Edition** | Light | OpenAI Codex CLI 上で動作する omo の 5 つの移植コンポーネント (rules, comment-checker, LSP, ultrawork, ulw-loop)。インストール: `bunx omo install --platform=codex` |
| 👥 | **Team Mode** (v4.0, オプトイン) | Ultimate | リードエージェント + 最大 8 メンバーの並列実行、リアルタイム tmux 可視化、専用 `team_*` ツール群。`hyperplan`(5 人の敵対的批評家)と `security-research`(3 人のハンター + 2 人の PoC エンジニア)を駆動します。[ドキュメント →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Both | 一言で OK。すべてのエージェント (Ultimate) または Codex `ultrawork` コンポーネント (Light) がアクティブになり、終わるまで止まりません。 |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | ユーザーの真の意図を分析してから分類・行動します。`search` / `analyze` / `team` / `hyperplan` をトリガー。(Light は `ulw` / `ultrawork` のみフック。) |
| 🔗 | **ハッシュベースの編集ツール** | Ultimate | `LINE#ID` のコンテンツハッシュですべての変更を検証します。stale-line エラー 0%。[oh-my-pi](https://github.com/can1357/oh-my-pi) にインスパイア。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex はネイティブの `apply_patch` を使用。) |
| 🛠️ | **LSP + AST-Grep** | Ultimate | ワークスペース単位のリネーム、ビルド前の診断、AST を考慮した書き換え。エージェントに IDE レベルの精度を提供。(LSP は Light でも `lsp` コンポーネントで動作; AST-Grep は Ultimate のみ。) |
| 🧠 | **バックグラウンドエージェント** | Ultimate | 5 人以上の専門家を並列で投入。コンテキストは軽く保ち、結果は準備ができ次第受け取ります。 |
| 📚 | **組み込み MCP** | Ultimate | Exa (Web 検索)、Context7 (公式ドキュメント)、Grep.app (GitHub 検索)。常にオン。(Light は LSP MCP のみ。) |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 自己参照ループ。100% 完了するまで絶対に止まりません。 |
| | **Todo Enforcer** (Boulder) | Ultimate | エージェントがサボる?システムが首根っこを掴んで戻します。あなたのタスクは必ず終わります。 |
| 💬 | **コメントチェッカー** | Both | コメントから AI 臭い無駄話を排除。両エディションで同じ `@code-yeongyu/comment-checker` バイナリが動作。 |
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` の階層的コンテキスト注入。Ultimate はフック、Light は `rules` コンポーネント。 |
| 🧬 | **Ulw Loop** | Light | `.omo/ulw-loop/` evidence audit ベースの永続的マルチゴール オーケストレーション。現在は Codex 専用; OpenCode 側への移植はロードマップ。 |
| 🖥️ | **Tmux 統合** | Ultimate | 完全なインタラクティブターミナル。REPL、デバッガー、TUI アプリがすべてリアルタイムで動きます。 |
| 🔌 | **Claude Code 互換性** | Ultimate | 既存のフック、コマンド、スキル、MCP、プラグイン?すべてここでそのまま動きます。(Codex は独自のネイティブプラグインシステムを保有。) |
| 🎯 | **スキル内蔵 MCP** | Ultimate | スキルが独自の MCP サーバーを持ち歩きます。コンテキストが肥大化しません。 |
| 📋 | **Prometheus プランナー** | Ultimate | インタビューモードで、実行前に戦略的な計画から立てます。 |
| 🔍 | **`/init-deep`** | Ultimate | プロジェクト全体にわたって階層的な `AGENTS.md` ファイルを自動生成。トークン効率とエージェントのパフォーマンスの両方を向上させます。 |
> **Editions legend.** **Ultimate** = OpenCode 専用 (`bunx omo install`)。**Light** = Codex CLI 専用 (`bunx omo install --platform=codex`)。**Both** = 両エディションに提供、しばしば内部実装は若干異なる。
### 規律あるエージェント (Discipline Agents)
@@ -339,6 +346,14 @@ oh-my-openagent を削除するには:
# プラグインがロードされなくなっているはずです
```
4. **omo-codex (Codex CLI Light エディション) を削除する**
```bash
rm -rf ~/.codex/plugins/cache/sisyphuslabs
```
その後 `~/.codex/config.toml` を開き、`[marketplaces.sisyphuslabs]`、`[plugins."omo@sisyphuslabs"]`、`[hooks.state."omo@sisyphuslabs:..."]` ブロックを削除してください。
## Features
最初から存在していて当然だと感じる機能たち。一度使うと戻れなくなります。
+33 -18
View File
@@ -124,6 +124,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
익명 텔레메트리는 활성 설치 수(DAU/WAU/MAU) 집계를 위해 기본적으로 활성화되어 있습니다. 머신당 UTC 하루에 최대 1회만 이벤트가 전송되며, 해시된 설치 식별자를 사용하고 원시 호스트명은 절대 사용하지 않으며 PostHog person profile은 생성되지 않습니다. `OMO_SEND_ANONYMOUS_TELEMETRY=0` 또는 `OMO_DISABLE_POSTHOG=1`로 비활성화할 수 있습니다. [개인정보처리방침](docs/legal/privacy-policy.md)과 [서비스 이용약관](docs/legal/terms-of-service.md)을 참조하세요.
**Ultimate vs Light:** oh-my-openagent는 같은 제품의 두 에디션으로 출시됩니다. **Ultimate 에디션**(`bunx omo install` 또는 `--platform=opencode`, 기본값)은 OpenCode 위에서 풀 기능 — 11 agent, 54+ hook, Team Mode, 모든 MCP, 슬래시 명령, IntentGate 모드 — 을 제공합니다. **Light 에디션**(`bunx omo install --platform=codex`)은 OpenAI Codex CLI의 플러그인 시스템에 깔끔히 포팅되는 5개 컴포넌트(`rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`)만 제공합니다. `bunx lazycodex install``--platform=codex`의 단축 별칭입니다. 둘 다 설치하려면 `--platform=both`. Codex 전용 텔레메트리는 `OMO_CODEX_DISABLE_POSTHOG=1` 또는 `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`으로 비활성화할 수 있습니다.
---
## 이 README 건너뛰기
@@ -151,24 +153,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
- [GLM Coding 요금제 ($10)](https://z.ai/subscribe)
- 종량제(pay-per-token) 대상자라면 kimi와 gemini 모델을 써도 비용이 별로 안 나옵니다.
| | 기능 | 하는 일 |
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Sisyphus가 Hephaestus, Oracle, Librarian, Explore를 지휘합니다. 병렬로 도는 풀스택 AI 개발팀. |
| 👥 | **Team Mode** (v4.0, opt-in) | 리드 에이전트 + 최대 8명의 병렬 멤버, 실시간 tmux 시각화, 전용 `team_*` 도구. `hyperplan`(5명의 적대적 비평가)과 `security-research`(3명의 헌터 + 2명의 PoC 엔지니어)를 구동합니다. [문서 →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | 한 단어. 모든 에이전트가 켜집니다. 끝날 때까지 멈추지 않습니다. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | 분류하거나 행동하기 전에 사용자의 진짜 의도부터 분석합니다. 문자 그대로 오해하는 일은 끝. |
| 🔗 | **Hash-Anchored Edit Tool** | `LINE#ID` 콘텐츠 해시가 모든 변경을 검증합니다. 낡은 라인 에러 0건. [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감. [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | 워크스페이스 리네임, 빌드 전 진단, AST 기반 리라이트. 에이전트에게도 IDE 수준의 정밀도. |
| 🧠 | **Background Agents** | 전문가 5명 이상을 동시에 발사. 컨텍스트는 가볍게. 결과는 준비되면 도착. |
| 📚 | **Built-in MCPs** | Exa(웹 검색), Context7(공식 문서), Grep.app(GitHub 검색). 항상 켜져 있음. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | 자기참조 루프. 100% 끝날 때까지 멈추지 않습니다. |
| | **Todo Enforcer** | 에이전트가 놀고 있나요? 시스템이 다시 끌어옵니다. 당신의 작업은 반드시 끝납니다. |
| 💬 | **Comment Checker** | 주석에 AI 슬롭 금지. 시니어가 쓴 것처럼 읽히는 코드. |
| 🖥️ | **Tmux Integration** | 풀 인터랙티브 터미널. REPL, 디버거, TUI 전부 라이브. |
| 🔌 | **Claude Code Compatible** | 쓰시던 hook, command, skill, MCP, plugin 전부 그대로 동작합니다. |
| 🎯 | **Skill-Embedded MCPs** | 스킬이 자기만의 MCP 서버를 들고 다닙니다. 컨텍스트 낭비 없음. |
| 📋 | **Prometheus Planner** | 실행 전 인터뷰 모드로 전략 플래닝. |
| 🔍 | **`/init-deep`** | 프로젝트 전반에 계층형 `AGENTS.md` 파일을 자동 생성합니다. 토큰 효율에도, 에이전트 성능에도 좋습니다. |
| | 기능 | Editions | 하는 일 |
| :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Ultimate | Sisyphus가 Hephaestus, Oracle, Librarian, Explore를 지휘합니다. 병렬로 도는 풀스택 AI 개발팀. |
| 🧩 | **Codex CLI Light Edition** | Light | OpenAI Codex CLI에서 동작하는 omo의 5개 포팅 컴포넌트(rules, comment-checker, LSP, ultrawork, ulw-loop). 설치: `bunx omo install --platform=codex`. |
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | 리드 에이전트 + 최대 8명의 병렬 멤버, 실시간 tmux 시각화, 전용 `team_*` 도구. `hyperplan`(5명의 적대적 비평가)과 `security-research`(3명의 헌터 + 2명의 PoC 엔지니어)를 구동합니다. [문서 →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Both | 한 단어. 모든 에이전트(Ultimate)나 Codex `ultrawork` 컴포넌트(Light)가 켜집니다. 끝날 때까지 멈추지 않습니다. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | 분류하거나 행동하기 전에 사용자의 진짜 의도부터 분석합니다. `search` / `analyze` / `team` / `hyperplan` 트리거. (Light는 `ulw` / `ultrawork`만 hook.) |
| 🔗 | **Hash-Anchored Edit Tool** | Ultimate | `LINE#ID` 콘텐츠 해시가 모든 변경을 검증합니다. 낡은 라인 에러 0건. [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감. [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex는 자체 `apply_patch` 사용.) |
| 🛠️ | **LSP + AST-Grep** | Ultimate | 워크스페이스 리네임, 빌드 전 진단, AST 기반 리라이트. 에이전트에게도 IDE 수준의 정밀도. (LSP는 Light에서도 `lsp` 컴포넌트로 동작; AST-Grep은 Ultimate 전용.) |
| 🧠 | **Background Agents** | Ultimate | 전문가 5명 이상을 동시에 발사. 컨텍스트는 가볍게. 결과는 준비되면 도착. |
| 📚 | **Built-in MCPs** | Ultimate | Exa(웹 검색), Context7(공식 문서), Grep.app(GitHub 검색). 항상 켜져 있음. (Light는 LSP MCP만.) |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 자기참조 루프. 100% 끝날 때까지 멈추지 않습니다. |
| | **Todo Enforcer** (Boulder) | Ultimate | 에이전트가 놀고 있나요? 시스템이 다시 끌어옵니다. 당신의 작업은 반드시 끝납니다. |
| 💬 | **Comment Checker** | Both | 주석에 AI 슬롭 금지. 동일한 `@code-yeongyu/comment-checker` 바이너리가 두 에디션 모두에서 동작. |
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` 계층형 컨텍스트 주입. Ultimate은 hook, Light는 `rules` 컴포넌트. |
| 🧬 | **Ulw Loop** | Light | `.omo/ulw-loop/` evidence audit 기반 영속 멀티 골 오케스트레이션. 현재 Codex 전용; OpenCode 사이드 포팅은 로드맵에 있음. |
| 🖥️ | **Tmux Integration** | Ultimate | 풀 인터랙티브 터미널. REPL, 디버거, TUI 전부 라이브. |
| 🔌 | **Claude Code Compatible** | Ultimate | 쓰시던 hook, command, skill, MCP, plugin 전부 그대로 동작합니다. (Codex는 자체 플러그인 시스템 보유.) |
| 🎯 | **Skill-Embedded MCPs** | Ultimate | 스킬이 자기만의 MCP 서버를 들고 다닙니다. 컨텍스트 낭비 없음. |
| 📋 | **Prometheus Planner** | Ultimate | 실행 전 인터뷰 모드로 전략 플래닝. |
| 🔍 | **`/init-deep`** | Ultimate | 프로젝트 전반에 계층형 `AGENTS.md` 파일을 자동 생성합니다. 토큰 효율에도, 에이전트 성능에도 좋습니다. |
> **Editions legend.** **Ultimate** = OpenCode 전용 (`bunx omo install`). **Light** = Codex CLI 전용 (`bunx omo install --platform=codex`). **Both** = 두 에디션 모두 제공, 종종 내부 구현은 약간 다름.
### Discipline Agents
@@ -340,6 +347,14 @@ oh-my-openagent를 제거하려면:
# 더 이상 플러그인이 로드되지 않아야 합니다
```
4. **omo-codex (Codex CLI Light 에디션) 제거**
```bash
rm -rf ~/.codex/plugins/cache/sisyphuslabs
```
그런 다음 `~/.codex/config.toml`을 열어 `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]`, `[hooks.state."omo@sisyphuslabs:..."]` 블록들을 삭제하세요.
## Features
진작 있었어야 했다고 느낄 기능들입니다. 한 번 쓰면 되돌아갈 수 없습니다.
+82 -24
View File
@@ -101,28 +101,72 @@ Install oh-my-openagent. Type `ultrawork`. Done.
## Installation
oh-my-openagent ships in two editions of the same product:
- **Ultimate Edition (omo for OpenCode)** — full omo. 11 agents, 54+ lifecycle hooks, 5 built-in MCPs, all slash commands, Team Mode, ulw-loop, ultrawork, hashline edits — everything.
- **Light Edition (omo for Codex CLI)** — the portable components that fit Codex's plugin system: `rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`, `start-work-continuation`, and `telemetry`. No agent orchestration, no `team_*` tools, no built-in MCPs beyond LSP — Codex CLI's own surface does that work.
Pick the edition(s) you want.
### TL;DR
| You want | Run | What lands on disk |
| :--- | :--- | :--- |
| **Ultimate** (OpenCode) | `bunx omo install` (TUI walks you through it) | Plugin registered in `opencode.json` + agent/model config + provider auth prompts |
| **Light** (Codex CLI) | `bunx omo install --platform=codex` or `bunx lazycodex install` | `~/.codex/plugins/cache/sisyphuslabs/omo/` + local Codex marketplace cache + `~/.codex/config.toml` marketplace/plugin/agent blocks + optional autonomous permissions + component CLIs in `~/.local/bin` |
| **Both** | `bunx omo install --platform=both` | Both of the above |
`--platform` defaults to `opencode` (Ultimate). The `bunx lazycodex install` alias is a shortcut for `bunx omo install --platform=codex`; use whichever reads cleaner.
### For Humans
Copy and paste this prompt to your LLM agent (Claude Code, AmpCode, Cursor, etc.):
**Strongly recommended: let an LLM agent install this for you.** The Ultimate edition setup involves subscription detection, model selection across 11 agents, and per-provider authentication — humans fat-finger these. An LLM agent reads the full guide and walks every step correctly.
Paste this prompt into Claude Code, AmpCode, Cursor, or any agent:
```
Install and configure oh-my-openagent by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
```
Or read the [Installation Guide](docs/guide/installation.md), but seriously, let an agent do it. Humans fat-finger configs.
If you only want the **Light edition** (Codex CLI), the installer asks whether to configure Codex for autonomous full-permissions mode. You can run it yourself in one line:
```bash
bunx omo install --platform=codex
# equivalent:
bunx lazycodex install
# non-interactive recommended mode:
bunx lazycodex install --no-tui --codex-autonomous
```
> **Do not** use `npm install -g`, `bun add -g`, or `bun install -g`. Global installation is not officially supported — oh-my-openagent is a plugin that must resolve from where OpenCode/Codex loads plugins. Always invoke via `bunx`.
### For LLM Agents
Fetch the installation guide and follow it:
Fetch the full guide and follow it step by step:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
```
**Note**: The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the transition). Inside `opencode.json`, the compatibility layer now prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json` or `oh-my-opencode.jsonc`; both legacy and renamed basenames are recognized during the transition.
The guide covers: platform selection, the subscription interview, provider authentication (Anthropic / Gemini / Copilot / Z.ai / OpenCode Zen), the agent-to-model matching matrix, modes (`ultrawork`, `search`, `analyze`, `team`, `hyperplan`), slash commands, the Light edition's 5 Codex components, Team Mode, and uninstall. Don't summarize it; read it end to end.
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). A single event is sent at most once per UTC day per machine using a hashed installation identifier, never the raw hostname, and PostHog person profiles are not created. Disable with `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`. See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
### Note on package and command names
The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the rename transition). Inside `opencode.json`, the compatibility layer prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json[c]`; both legacy and renamed basenames are recognized.
All four `bunx` aliases - `oh-my-opencode`, `oh-my-openagent`, `omo`, `lazycodex` - invoke the same compiled CLI. `omo` is the recommended short form for documentation and prompts. `lazycodex` is a single-purpose npm/bin alias: `bunx lazycodex install` is exactly equivalent to `bunx omo install --platform=codex`. It is not the Codex marketplace name. Codex sees marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`.
### Telemetry
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). For both products, a single event is sent **at most once per UTC day per machine** using a SHA256-hashed installation identifier (never the raw hostname), and PostHog person profiles are not created. The main plugin emits `oh_my_openagent_daily_active`; the Codex CLI Light edition emits `omo_codex_daily_active` from two sources (`install_completed` and `session_start`).
Opt out per product:
- Main plugin: `OMO_DISABLE_POSTHOG=1` or `OMO_SEND_ANONYMOUS_TELEMETRY=0`
- Codex CLI Light edition: `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` (the global flags also disable Codex)
See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
---
@@ -157,24 +201,30 @@ Even with only the following subscriptions, `ultrawork` works well (this project
- [GLM Coding Plan ($10)](https://z.ai/subscribe)
- If you're eligible for pay-per-token, using Kimi and Gemini models won't cost much.
| | Feature | What it does |
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
| 👥 | **Team Mode** (v4.0, opt-in) | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | One word. Every agent activates. Doesn't stop until done. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Analyzes true user intent before classifying or acting. No more literal misinterpretations. |
| 🔗 | **Hash-Anchored Edit Tool** | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | Workspace rename, pre-build diagnostics, AST-aware rewrites. IDE precision for agents. |
| 🧠 | **Background Agents** | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
| 📚 | **Built-in MCPs** | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Self-referential loop. Doesn't stop until 100% done. |
| | **Todo Enforcer** | Agent goes idle? System yanks it back. Your task gets done, period. |
| 💬 | **Comment Checker** | No AI slop in comments. Code reads like a senior wrote it. |
| 🖥️ | **Tmux Integration** | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
| 🔌 | **Claude Code Compatible** | Your hooks, commands, skills, MCPs, and plugins? All work here. |
| 🎯 | **Skill-Embedded MCPs** | Skills carry their own MCP servers. No context bloat. |
| 📋 | **Prometheus Planner** | Interview-mode strategic planning before any execution. |
| 🔍 | **`/init-deep`** | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
| | Feature | Edition | What it does |
| :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Ultimate | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
| 🧩 | **Codex CLI Light Edition** | Light | Portable OMO components (rules, comment-checker, LSP, ultrawork, ulw-loop, start-work continuation, telemetry) running inside OpenAI Codex CLI. Install via `bunx omo install --platform=codex`. |
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Both | One word. Every agent activates. Doesn't stop until done. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | Analyzes true user intent before classifying or acting. No more literal misinterpretations. (Light edition only recognises the `ultrawork`/`ulw` keyword.) |
| 🔗 | **Hash-Anchored Edit Tool** | Ultimate | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP integration** | Both | Diagnostics, navigation, symbols, workspace rename. IDE precision for agents. Same LSP MCP server in both editions. |
| 🔎 | **AST-Grep** | Ultimate | Pattern-aware code search and rewriting across 25 languages. |
| 🧠 | **Background Agents** | Ultimate | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
| 📚 | **Built-in MCPs** (web/docs/code search) | Ultimate | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Self-referential loop. Doesn't stop until 100% done. |
| | **Todo Enforcer** | Ultimate | Agent goes idle? System yanks it back. Your task gets done, period. |
| 💬 | **Comment Checker** | Both | No AI slop in comments. Code reads like a senior wrote it. |
| 📐 | **Rules Injection** (`AGENTS.md` / `.omo/rules/**`) | Both | Project rules and AGENTS.md auto-loaded into the agent's context at every prompt. |
| 🎯 | **Ulw Loop** | Both | Durable multi-goal orchestration with evidence audit, backed by `.omo/ulw-loop/`. |
| 🖥️ | **Tmux Integration** | Ultimate | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
| 🔌 | **Claude Code Compatible** | Ultimate | Your hooks, commands, skills, MCPs, and plugins? All work here. |
| 🧬 | **Skill-Embedded MCPs** | Ultimate | Skills carry their own MCP servers. No context bloat. |
| 📋 | **Prometheus Planner** | Ultimate | Interview-mode strategic planning before any execution. |
| 🔍 | **`/init-deep`** | Ultimate | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
> **Edition legend.** **Ultimate** = OpenCode-only (`bunx omo install`). **Light** = Codex CLI-only (`bunx omo install --platform=codex`). **Both** = shipped in both editions, often with slightly different implementations under the hood.
### Discipline Agents
@@ -346,6 +396,14 @@ To remove oh-my-openagent:
# Plugin should no longer be loaded
```
4. **Remove omo-codex (Codex CLI Light edition)**
```bash
rm -rf ~/.codex/plugins/cache/sisyphuslabs
```
Then open `~/.codex/config.toml` and remove `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]`, and any `[hooks.state."omo@sisyphuslabs:..."]` blocks.
## Features
Features you'll think should've always existed. Once you use them, you can't go back.
+33 -18
View File
@@ -121,6 +121,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
Анонимная телеметрия включена по умолчанию для подсчёта активных установок (DAU/WAU/MAU). Не более одного события на машину за UTC-сутки, использует хешированный идентификатор установки, никогда не использует исходное имя хоста, и не создаёт PostHog person profile. Можно отключить через `OMO_SEND_ANONYMOUS_TELEMETRY=0` или `OMO_DISABLE_POSTHOG=1`. См. [Политику конфиденциальности](docs/legal/privacy-policy.md) и [Условия обслуживания](docs/legal/terms-of-service.md).
**Ultimate и Light:** oh-my-openagent поставляется в двух редакциях одного продукта. **Ultimate** (`bunx omo install` или `--platform=opencode`, по умолчанию) — полнофункциональная редакция поверх OpenCode: 11 агентов, 54+ хука, Team Mode, все MCP, все слэш-команды, режимы IntentGate. **Light** (`bunx omo install --platform=codex`) — только 5 компонентов omo, которые портируются в систему плагинов OpenAI Codex CLI: `rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`. `bunx lazycodex install` — это сокращённый псевдоним для `--platform=codex`. Чтобы установить обе редакции одной командой, используйте `--platform=both`. Телеметрию только для Codex можно отключить через `OMO_CODEX_DISABLE_POSTHOG=1` или `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`.
------
## Пропустите этот README
@@ -149,24 +151,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
- [Тариф GLM Coding ($10)](https://z.ai/subscribe)
- Если у вас есть доступ к оплате за токены, использование моделей Kimi и Gemini обойдётся недорого.
| | Функция | Что делает |
| --- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Дисциплинированные агенты** | Sisyphus оркестрирует Hephaestus, Oracle, Librarian, Explore. Полноценная AI-команда разработки в параллельном режиме. |
| 👥 | **Team Mode** (v4.0, opt-in) | Лид-агент + до 8 параллельных участников, визуализация в tmux в реальном времени, выделенные инструменты `team_*`. Питает `hyperplan` (5 враждебных критиков) и `security-research` (3 охотника + 2 PoC-инженера). [Документация →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Одно слово. Все агенты активируются. Не останавливается, пока задача не выполнена. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Анализирует истинное намерение пользователя перед классификацией и действием. Никакого буквального неверного толкования. |
| 🔗 | **Инструмент правок на основе хэш-якорей** | Хэш содержимого `LINE#ID` проверяет каждое изменение. Ноль ошибок с устаревшими строками. Вдохновлено [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | Переименование в рабочем пространстве, диагностика перед сборкой, переписывание с учётом AST. Точность IDE для агентов. |
| 🧠 | **Фоновые агенты** | Запускайте 5+ специалистов параллельно. Контекст остаётся компактным. Результаты — когда готовы. |
| 📚 | **Встроенные MCP** | Exa (веб-поиск), Context7 (официальная документация), Grep.app (поиск по GitHub). Всегда включены. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Самореферентный цикл. Не останавливается, пока задача не выполнена на 100%. |
| | **Todo Enforcer** | Агент завис? Система немедленно возвращает его в работу. Ваша задача будет выполнена, точка. |
| 💬 | **Comment Checker** | Никакого AI-мусора в комментариях. Код читается так, словно его писал опытный разработчик. |
| 🖥️ | **Интеграция с Tmux** | Полноценный интерактивный терминал. REPL, дебаггеры, TUI. Всё живое. |
| 🔌 | **Совместимость с Claude Code** | Ваши хуки, команды, навыки, MCP и плагины? Всё работает без изменений. |
| 🎯 | **MCP, встроенные в навыки** | Навыки несут собственные MCP-серверы. Никакого раздувания контекста. |
| 📋 | **Prometheus Planner** | Стратегическое планирование в режиме интервью перед любым выполнением. |
| 🔍 | **`/init-deep`** | Автоматически генерирует иерархические файлы `AGENTS.md` по всему проекту. Отлично работает на эффективность токенов и производительность агента. |
| | Функция | Editions | Что делает |
| --- | -------------------------------------------------------- | :------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Дисциплинированные агенты** | Ultimate | Sisyphus оркестрирует Hephaestus, Oracle, Librarian, Explore. Полноценная AI-команда разработки в параллельном режиме. |
| 🧩 | **Codex CLI Light Edition** | Light | 5 компонентов omo, портированных в OpenAI Codex CLI (rules, comment-checker, LSP, ultrawork, ulw-loop). Установка: `bunx omo install --platform=codex`. |
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | Лид-агент + до 8 параллельных участников, визуализация в tmux в реальном времени, выделенные инструменты `team_*`. Питает `hyperplan` (5 враждебных критиков) и `security-research` (3 охотника + 2 PoC-инженера). [Документация →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Both | Одно слово. Все агенты (Ultimate) или Codex-компонент `ultrawork` (Light) активируются. Не останавливается, пока задача не выполнена. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | Анализирует истинное намерение пользователя перед классификацией и действием. Триггеры `search` / `analyze` / `team` / `hyperplan`. (Light хукает только `ulw` / `ultrawork`.) |
| 🔗 | **Инструмент правок на основе хэш-якорей** | Ultimate | Хэш содержимого `LINE#ID` проверяет каждое изменение. Ноль ошибок с устаревшими строками. Вдохновлено [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex использует собственный `apply_patch`.) |
| 🛠️ | **LSP + AST-Grep** | Ultimate | Переименование в рабочем пространстве, диагностика перед сборкой, переписывание с учётом AST. Точность IDE для агентов. (LSP также работает в Light через компонент `lsp`; AST-Grep только Ultimate.) |
| 🧠 | **Фоновые агенты** | Ultimate | Запускайте 5+ специалистов параллельно. Контекст остаётся компактным. Результаты — когда готовы. |
| 📚 | **Встроенные MCP** | Ultimate | Exa (веб-поиск), Context7 (официальная документация), Grep.app (поиск по GitHub). Всегда включены. (В Light только LSP MCP.) |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Самореферентный цикл. Не останавливается, пока задача не выполнена на 100%. |
| | **Todo Enforcer** (Boulder) | Ultimate | Агент завис? Система немедленно возвращает его в работу. Ваша задача будет выполнена, точка. |
| 💬 | **Comment Checker** | Both | Никакого AI-мусора в комментариях. Тот же бинарник `@code-yeongyu/comment-checker` работает в обеих редакциях. |
| 📜 | **Rules Injection** | Both | Иерархическое внедрение контекста из `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**`. В Ultimate это хук, в Light — компонент `rules`. |
| 🧬 | **Ulw Loop** | Light | Долговечная оркестрация нескольких целей с аудитом доказательств в `.omo/ulw-loop/`. Сейчас только в Codex; порт в сторону OpenCode в дорожной карте. |
| 🖥️ | **Интеграция с Tmux** | Ultimate | Полноценный интерактивный терминал. REPL, дебаггеры, TUI. Всё живое. |
| 🔌 | **Совместимость с Claude Code** | Ultimate | Ваши хуки, команды, навыки, MCP и плагины? Всё работает без изменений. (У Codex своя нативная плагин-система.) |
| 🎯 | **MCP, встроенные в навыки** | Ultimate | Навыки несут собственные MCP-серверы. Никакого раздувания контекста. |
| 📋 | **Prometheus Planner** | Ultimate | Стратегическое планирование в режиме интервью перед любым выполнением. |
| 🔍 | **`/init-deep`** | Ultimate | Автоматически генерирует иерархические файлы `AGENTS.md` по всему проекту. Отлично работает на эффективность токенов и производительность агента. |
> **Editions, легенда.** **Ultimate** = только OpenCode (`bunx omo install`). **Light** = только Codex CLI (`bunx omo install --platform=codex`). **Both** = поставляется в обеих редакциях, часто с немного отличающейся реализацией.
### Дисциплинированные агенты
@@ -338,6 +345,14 @@ project/
# Плагин больше не должен загружаться
```
4. **Удалите omo-codex (Codex CLI Light edition)**
```bash
rm -rf ~/.codex/plugins/cache/sisyphuslabs
```
Затем откройте `~/.codex/config.toml` и удалите блоки `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]` и `[hooks.state."omo@sisyphuslabs:..."]`.
## Функции
Функции, которые, как вы будете думать, должны были существовать всегда. Попробовав раз, вы не сможете вернуться назад.
+33 -18
View File
@@ -123,6 +123,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
匿名遥测默认开启,用于统计活跃安装数(DAU/WAU/MAU)。每台机器每个 UTC 日最多发送一次事件,使用哈希化的安装标识符,绝不会使用原始主机名,且不会创建 PostHog person profile。可通过 `OMO_SEND_ANONYMOUS_TELEMETRY=0``OMO_DISABLE_POSTHOG=1` 禁用。详见 [隐私政策](docs/legal/privacy-policy.md) 和 [服务条款](docs/legal/terms-of-service.md)。
**Ultimate 与 Light:** oh-my-openagent 以同一产品的两个版本发布。**Ultimate 版本**`bunx omo install``--platform=opencode`,默认值)在 OpenCode 上提供完整功能 —— 11 个智能体、54+ 个生命周期钩子、Team Mode、所有 MCP、所有斜杠命令、IntentGate 模式。**Light 版本**`bunx omo install --platform=codex`)仅提供能够干净地移植到 OpenAI Codex CLI 插件系统的 5 个组件(`rules``comment-checker``lsp``ultrawork``ulw-loop`)。`bunx lazycodex install``--platform=codex` 的快捷别名。要同时安装两个版本,使用 `--platform=both`。Codex 专用遥测可通过 `OMO_CODEX_DISABLE_POSTHOG=1``OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` 禁用。
---
## 跳过这个 README 吧
@@ -156,24 +158,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
- [GLM Coding 套餐 ($10)](https://z.ai/subscribe)
- 如果你能使用按 token 计费的方式,用 Kimi 和 Gemini 模型花不了多少钱。
| | 特性 | 功能说明 |
| :---: | :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 🤖 | **自律军团 (Discipline Agents)** | Sisyphus 负责调度 Hephaestus、Oracle、Librarian 和 Explore。一支完整的 AI 开发团队并行工作。 |
| 👥 | **Team Mode** (v4.0, 选择性启用) | 领导 Agent + 最多 8 个并行成员,实时 tmux 可视化,专用 `team_*` 工具家族。驱动 `hyperplan`(5 个敌对评论者) 和 `security-research`(3 个猎手 + 2 个 PoC 工程师)。[文档 →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | 一键触发,所有智能体出动。任务完成前绝不罢休。 |
| 🚪 | **[IntentGate 意图门](https://factory.ai/news/terminal-bench)** | 真正行动前,先分析用户的真实意图。彻底告别被字面意思误导的 AI 废话。 |
| 🔗 | **基于哈希的编辑工具** | 每次修改都通过 `LINE#ID` 内容哈希验证、0% 错误修改。灵感来自 [oh-my-pi](https://github.com/can1357/oh-my-pi)。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | 工作区级别的重命名、构建前诊断、基于 AST 的重写。为 Agent 提供 IDE 级别的精度。 |
| 🧠 | **后台智能体** | 同时发射 5+ 个专家并行工作。保持上下文干净,随时获取成果。 |
| 📚 | **内置 MCP** | Exa(网络搜索)、Context7(官方文档)、Grep.app(GitHub 源码搜索)。默认开启。 |
| 🔁 | **Ralph Loop / `/ulw-loop`** | 自我引用闭环。达不到 100% 完成度绝不停止。 |
| | **Todo 强制执行** | Agent 想要摸鱼?系统直接揪着领子拽回来。你的任务,必须完成。 |
| 💬 | **注释审查员** | 剔除带有浓烈 AI 味的冗余注释。写出的代码就像老练的高级工程师写的。 |
| 🖥️ | **Tmux 集成** | 完整的交互式终端支持。跑 REPL、用调试器、用 TUI 工具,全都在实时会话中完成。 |
| 🔌 | **Claude Code 兼容** | 你现有的 Hooks、命令、技能、MCP 和插件?全都能无缝迁移过来。 |
| 🎯 | **技能内嵌 MCP** | 技能自带其所需的 MCP 服务器。按需开启,不会撑爆你的上下文窗口。 |
| 📋 | **Prometheus 规划师** | 动手写代码前,先通过访谈模式做好战略规划。 |
| 🔍 | **`/init-deep`** | 在整个项目目录层级中自动生成 `AGENTS.md`。不仅省 Token,还能大幅提升 Agent 理解力。 |
| | 特性 | Editions | 功能说明 |
| :---: | :-------------------------------------------------------------- | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 🤖 | **自律军团 (Discipline Agents)** | Ultimate | Sisyphus 负责调度 Hephaestus、Oracle、Librarian 和 Explore。一支完整的 AI 开发团队并行工作。 |
| 🧩 | **Codex CLI Light Edition** | Light | 在 OpenAI Codex CLI 中运行的 omo 的 5 个可移植组件 (rules, comment-checker, LSP, ultrawork, ulw-loop)。安装: `bunx omo install --platform=codex` |
| 👥 | **Team Mode** (v4.0, 选择性启用) | Ultimate | 领导 Agent + 最多 8 个并行成员,实时 tmux 可视化,专用 `team_*` 工具家族。驱动 `hyperplan`(5 个敌对评论者) 和 `security-research`(3 个猎手 + 2 个 PoC 工程师)。[文档 →](docs/guide/team-mode.md) |
| | **`ultrawork` / `ulw`** | Both | 一键触发,所有智能体(Ultimate)或 Codex `ultrawork` 组件(Light)出动。任务完成前绝不罢休。 |
| 🚪 | **[IntentGate 意图门](https://factory.ai/news/terminal-bench)** | Ultimate | 真正行动前,先分析用户的真实意图。触发 `search` / `analyze` / `team` / `hyperplan`。(Light 仅 hook `ulw` / `ultrawork`。) |
| 🔗 | **基于哈希的编辑工具** | Ultimate | 每次修改都通过 `LINE#ID` 内容哈希验证、0% 错误修改。灵感来自 [oh-my-pi](https://github.com/can1357/oh-my-pi)。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex 使用其原生 `apply_patch`。) |
| 🛠️ | **LSP + AST-Grep** | Ultimate | 工作区级别的重命名、构建前诊断、基于 AST 的重写。为 Agent 提供 IDE 级别的精度。(LSP 在 Light 中也通过 `lsp` 组件提供; AST-Grep 仅 Ultimate。) |
| 🧠 | **后台智能体** | Ultimate | 同时发射 5+ 个专家并行工作。保持上下文干净,随时获取成果。 |
| 📚 | **内置 MCP** | Ultimate | Exa(网络搜索)、Context7(官方文档)、Grep.appGitHub 源码搜索)。默认开启。(Light 仅 LSP MCP。) |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 自我引用闭环。达不到 100% 完成度绝不停止。 |
| | **Todo 强制执行** (Boulder) | Ultimate | Agent 想要摸鱼?系统直接揪着领子拽回来。你的任务,必须完成。 |
| 💬 | **注释审查员** | Both | 剔除带有浓烈 AI 味的冗余注释。同一个 `@code-yeongyu/comment-checker` 二进制在两个版本中运行。 |
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` 的分层上下文注入。Ultimate 中为 hookLight 中为 `rules` 组件。 |
| 🧬 | **Ulw Loop** | Light | 基于 `.omo/ulw-loop/` 证据审计的持久化多目标编排。目前仅 Codex 可用; OpenCode 侧的移植在路线图上。 |
| 🖥️ | **Tmux 集成** | Ultimate | 完整的交互式终端支持。跑 REPL、用调试器、用 TUI 工具,全都在实时会话中完成。 |
| 🔌 | **Claude Code 兼容** | Ultimate | 你现有的 Hooks、命令、技能、MCP 和插件?全都能无缝迁移过来。(Codex 拥有其自己的原生插件系统。) |
| 🎯 | **技能内嵌 MCP** | Ultimate | 技能自带其所需的 MCP 服务器。按需开启,不会撑爆你的上下文窗口。 |
| 📋 | **Prometheus 规划师** | Ultimate | 动手写代码前,先通过访谈模式做好战略规划。 |
| 🔍 | **`/init-deep`** | Ultimate | 在整个项目目录层级中自动生成 `AGENTS.md`。不仅省 Token,还能大幅提升 Agent 理解力。 |
> **Editions 图例。** **Ultimate** = 仅 OpenCode (`bunx omo install`)。**Light** = 仅 Codex CLI (`bunx omo install --platform=codex`)。**Both** = 两个版本均提供 (内部实现可能略有不同)。
### 自律军团 (Discipline Agents)
@@ -345,6 +352,14 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动
# 这个时候就应该没有任何关于插件的输出信息了
```
4. **移除 omo-codexCodex CLI Light 版本)**
```bash
rm -rf ~/.codex/plugins/cache/sisyphuslabs
```
然后打开 `~/.codex/config.toml`,删除 `[marketplaces.sisyphuslabs]`、`[plugins."omo@sisyphuslabs"]` 以及所有 `[hooks.state."omo@sisyphuslabs:..."]` 区块。
## Features
那种"这个功能本来就该一直存在"的感觉。一用就回不去。
+2 -2
View File
@@ -53,7 +53,8 @@
"frontend-ui-ux",
"git-master",
"review-work",
"ai-slop-remover",
"remove-ai-slops",
"init-deep",
"security-research",
"security-review",
"team-mode"
@@ -71,7 +72,6 @@
"items": {
"type": "string",
"enum": [
"init-deep",
"ralph-loop",
"ulw-loop",
"cancel-ralph",
+47 -2
View File
@@ -5,7 +5,14 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import {
getPlatformPackageCandidates,
getBinaryPath,
getPackageBareName,
resolvePlatformPackageBaseName,
} from "./platform.js";
const require = createRequire(import.meta.url);
@@ -72,6 +79,10 @@ function getSignalExitCode(signal) {
}
function getPackageBaseName() {
return resolvePlatformPackageBaseName(getWrapperPackageName());
}
function getWrapperPackageName() {
try {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
return packageJson.name || "oh-my-opencode";
@@ -80,10 +91,37 @@ function getPackageBaseName() {
}
}
function getWrapperPackageRoot() {
return fileURLToPath(new URL("..", import.meta.url));
}
/**
* Determine which bin name the user invoked us with (oh-my-opencode, oh-my-openagent, omo, lazycodex).
* Propagated to the compiled CLI binary via OMO_INVOCATION_NAME so it can route accordingly
* (e.g. `lazycodex` defaults to the Codex install flow).
* @returns {string}
*/
function getInvocationName(wrapperPackageName) {
if (process.env.OMO_INVOCATION_NAME) {
return process.env.OMO_INVOCATION_NAME;
}
if (getPackageBareName(wrapperPackageName) === "lazycodex") {
return "lazycodex";
}
const argv1 = process.argv[1] ?? "";
if (!argv1) {
return "oh-my-opencode";
}
return basename(argv1, ".js").replace(/\.exe$/, "");
}
function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
const packageBaseName = getPackageBaseName();
const wrapperPackageName = getWrapperPackageName();
const invocationName = getInvocationName(wrapperPackageName);
const packageBaseName = resolvePlatformPackageBaseName(wrapperPackageName);
const avx2Supported = supportsAvx2();
let packageCandidates;
@@ -119,11 +157,18 @@ function main() {
process.exit(1);
}
const childEnv = {
...process.env,
OMO_INVOCATION_NAME: invocationName,
OMO_WRAPPER_PACKAGE_ROOT: getWrapperPackageRoot(),
};
for (let index = 0; index < resolvedBinaries.length; index += 1) {
const currentBinary = resolvedBinaries[index];
const hasFallback = index < resolvedBinaries.length - 1;
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
stdio: "inherit",
env: childEnv,
});
if (result.error) {
+174
View File
@@ -0,0 +1,174 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { chmod, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getPlatformPackageCandidates } from "./platform.js";
const testRoots: string[] = [];
afterEach(async () => {
await Promise.all(testRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
describe("lazycodex bin wrapper", () => {
test("runs the platform binary so npx lazycodex does not require Bun", async () => {
// #given
const fixture = await createLazyCodexFixture();
const nodePath = Bun.which("node") ?? "node";
// #when
const result = spawnSync(nodePath, [fixture.lazycodexBin, "install", "--no-tui"], {
encoding: "utf8",
env: {
...process.env,
CAPTURE_DIR: fixture.captureDir,
PATH: fixture.fakeBinDir,
},
});
// #then
expect(result.status).toBe(23);
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
"install",
"--no-tui",
]);
});
test("runs the platform binary when published under an npm scope", async () => {
// #given
const fixture = await createLazyCodexFixture({ packageName: "@code-yeongyu/lazycodex" });
const nodePath = Bun.which("node") ?? "node";
// #when
const result = spawnSync(nodePath, [fixture.lazycodexBin, "install", "--no-tui"], {
encoding: "utf8",
env: {
...process.env,
CAPTURE_DIR: fixture.captureDir,
PATH: fixture.fakeBinDir,
},
});
// #then
expect(result.status).toBe(23);
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
"install",
"--no-tui",
]);
});
test("routes npm shim execution from the lazycodex package to the Codex installer", async () => {
// #given
const fixture = await createLazyCodexFixture({ wrapperFileName: "oh-my-opencode.js" });
const nodePath = Bun.which("node") ?? "node";
// #when
const result = spawnSync(nodePath, [fixture.wrapperBin, "install", "--no-tui"], {
encoding: "utf8",
env: {
...process.env,
CAPTURE_DIR: fixture.captureDir,
PATH: fixture.fakeBinDir,
},
});
// #then
expect(result.status).toBe(23);
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
"install",
"--no-tui",
]);
});
});
async function createLazyCodexFixture(options: { packageName?: string; wrapperFileName?: string } = {}) {
const root = await mkdtemp(join(tmpdir(), "lazycodex-bin-wrapper-"));
testRoots.push(root);
const binDir = join(root, "bin");
const distCli = join(root, "dist", "cli", "index.js");
const fakeBinDir = join(root, "fake-bin");
const captureDir = join(root, "capture");
await mkdir(binDir, { recursive: true });
await mkdir(dirname(distCli), { recursive: true });
await mkdir(fakeBinDir, { recursive: true });
await mkdir(captureDir, { recursive: true });
const wrapperFileName = options.wrapperFileName ?? "lazycodex";
const wrapperBin = join(binDir, wrapperFileName);
await cp(fileURLToPath(new URL("./oh-my-opencode.js", import.meta.url)), wrapperBin);
if (wrapperFileName !== "lazycodex") {
await symlink(wrapperFileName, join(binDir, "lazycodex"));
}
await cp(fileURLToPath(new URL("./platform.js", import.meta.url)), join(binDir, "platform.js"));
await writeFile(join(root, "package.json"), JSON.stringify({ name: options.packageName ?? "lazycodex", type: "module" }));
await writeFile(distCli, "#!/usr/bin/env bun\n");
await writePlatformPackages(root);
const fakeBun = join(fakeBinDir, "bun");
await writeFile(
fakeBun,
[
"#!/bin/sh",
"printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"",
"printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"",
"exit 23",
"",
].join("\n"),
);
await chmod(fakeBun, 0o755);
return {
bundledCli: distCli,
captureDir,
fakeBinDir,
lazycodexBin: join(binDir, "lazycodex"),
root,
wrapperBin,
};
}
async function canonicalizePackageRootCapture(fixture: { readonly captureDir: string }): Promise<string> {
return realpath((await readFile(join(fixture.captureDir, "wrapper-root"), "utf8")).trim());
}
async function writePlatformPackages(root: string): Promise<void> {
const packages = getPlatformPackageCandidates({
platform: process.platform,
arch: process.arch,
libcFamily: process.platform === "linux" ? "glibc" : undefined,
packageBaseName: "oh-my-openagent",
});
for (const packageName of packages) {
const binaryPath = join(root, "node_modules", packageName, "bin", process.platform === "win32" ? "oh-my-opencode.exe" : "oh-my-opencode");
await mkdir(dirname(binaryPath), { recursive: true });
await writeFile(
binaryPath,
[
"#!/bin/sh",
"printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"",
"printf '%s\\n' \"$OMO_WRAPPER_PACKAGE_ROOT\" > \"$CAPTURE_DIR/wrapper-root\"",
"printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"",
"exit 23",
"",
].join("\n"),
);
await chmod(binaryPath, 0o755);
}
if (process.platform === "linux") {
const detectLibcPath = join(root, "node_modules", "detect-libc", "index.js");
await mkdir(dirname(detectLibcPath), { recursive: true });
await writeFile(detectLibcPath, 'exports.familySync = () => "glibc";\n');
}
}
+6
View File
@@ -2,6 +2,7 @@ export declare function getPlatformPackage(options: {
platform: string;
arch: string;
libcFamily?: string | null;
packageBaseName?: string;
}): string;
export declare function getPlatformPackageCandidates(options: {
@@ -9,6 +10,11 @@ export declare function getPlatformPackageCandidates(options: {
arch: string;
libcFamily?: string | null;
preferBaseline?: boolean;
packageBaseName?: string;
}): string[];
export declare function getBinaryPath(pkg: string, platform: string): string;
export declare function getPackageBareName(packageName: string): string;
export declare function resolvePlatformPackageBaseName(wrapperPackageName: string): string;
+19
View File
@@ -1,6 +1,25 @@
// bin/platform.js
// Shared platform detection module - used by wrapper and postinstall
const PLATFORM_PACKAGE_BASE_BY_WRAPPER_NAME = {
lazycodex: "oh-my-openagent",
};
export function getPackageBareName(packageName) {
return packageName.split("/").pop() || packageName;
}
/**
* Resolve platform package base from a wrapper package name.
* Wrapper aliases can intentionally reuse an existing platform package family.
* @param {string} wrapperPackageName
* @returns {string}
*/
export function resolvePlatformPackageBaseName(wrapperPackageName) {
const bareName = getPackageBareName(wrapperPackageName);
return PLATFORM_PACKAGE_BASE_BY_WRAPPER_NAME[bareName] ?? wrapperPackageName;
}
/**
* Get the platform-specific package name
* @param {{ platform: string, arch: string, libcFamily?: string | null, packageBaseName?: string }} options
+66 -1
View File
@@ -1,6 +1,71 @@
// bin/platform.test.ts
import { describe, expect, test } from "bun:test";
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
import {
getBinaryPath,
getPackageBareName,
getPlatformPackage,
getPlatformPackageCandidates,
resolvePlatformPackageBaseName,
} from "./platform.js";
describe("getPackageBareName", () => {
test("strips npm scope from package name", () => {
// #given
const packageName = "@code-yeongyu/lazycodex";
// #when
const bareName = getPackageBareName(packageName);
// #then
expect(bareName).toBe("lazycodex");
});
});
describe("resolvePlatformPackageBaseName", () => {
test("maps lazycodex wrapper to oh-my-openagent platform package family", () => {
// #given
const wrapperPackageName = "lazycodex";
// #when
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
// #then
expect(resolvedPlatformBase).toBe("oh-my-openagent");
});
test("maps scoped lazycodex wrapper to oh-my-openagent platform package family", () => {
// #given
const wrapperPackageName = "@code-yeongyu/lazycodex";
// #when
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
// #then
expect(resolvedPlatformBase).toBe("oh-my-openagent");
});
test("keeps oh-my-opencode wrapper mapped to oh-my-opencode platform package family", () => {
// #given
const wrapperPackageName = "oh-my-opencode";
// #when
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
// #then
expect(resolvedPlatformBase).toBe("oh-my-opencode");
});
test("keeps oh-my-openagent wrapper mapped to oh-my-openagent platform package family", () => {
// #given
const wrapperPackageName = "oh-my-openagent";
// #when
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
// #then
expect(resolvedPlatformBase).toBe("oh-my-openagent");
});
});
describe("getPlatformPackage", () => {
// #region Darwin platforms
+22 -1
View File
@@ -31,8 +31,10 @@
"@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/hashline-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/omo-codex": "workspace:*",
"@oh-my-opencode/prompts-core": "workspace:*",
"@oh-my-opencode/rules-engine": "workspace:*",
"@oh-my-opencode/shared-skills": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^4.0.3",
@@ -74,7 +76,7 @@
"name": "@oh-my-opencode/ast-grep-mcp",
"version": "0.0.0",
"bin": {
"ast-grep-mcp": "dist/cli.js",
"omo-ast-grep": "dist/cli.js",
},
"dependencies": {
"@ast-grep/cli": "^0.41.1",
@@ -109,6 +111,17 @@
"@oh-my-opencode/utils": "workspace:*",
},
},
"packages/omo-codex": {
"name": "@oh-my-opencode/omo-codex",
"version": "0.1.0",
"dependencies": {
"@oh-my-opencode/utils": "workspace:*",
"posthog-node": "^5.34.3",
},
"devDependencies": {
"bun-types": "1.3.14",
},
},
"packages/prompts-core": {
"name": "@oh-my-opencode/prompts-core",
"version": "0.1.0",
@@ -124,6 +137,10 @@
"picomatch": "^4.0.4",
},
},
"packages/shared-skills": {
"name": "@oh-my-opencode/shared-skills",
"version": "0.1.0",
},
"packages/utils": {
"name": "@oh-my-opencode/utils",
"version": "0.1.0",
@@ -218,10 +235,14 @@
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
"@oh-my-opencode/omo-codex": ["@oh-my-opencode/omo-codex@workspace:packages/omo-codex"],
"@oh-my-opencode/prompts-core": ["@oh-my-opencode/prompts-core@workspace:packages/prompts-core"],
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
"@oh-my-opencode/shared-skills": ["@oh-my-opencode/shared-skills@workspace:packages/shared-skills"],
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.10", "", { "dependencies": { "@opencode-ai/sdk": "1.15.10", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw=="],
+1 -1
View File
@@ -1,6 +1,6 @@
[test]
preload = ["./test-setup.ts"]
pathIgnorePatterns = ["packages/web/**", "packages/lsp-tools-mcp/**"]
pathIgnorePatterns = ["packages/web/**", "packages/lsp-tools-mcp/**", "packages/omo-codex/plugin/**"]
[loader]
".md" = "text"
+1
View File
@@ -21,6 +21,7 @@
| Known issues & workarounds | [docs/reference/known-issues.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/known-issues.md) |
| `prompt_async_gate` deep-dive | [docs/reference/prompt-async-gate-rfc.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/prompt-async-gate-rfc.md) |
| Release process | [docs/reference/release-process.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/release-process.md) |
| Claiming the lazycodex npm name | [docs/reference/lazycodex-npm-reservation.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/lazycodex-npm-reservation.md) |
| Rules-injector cross-module comparison | [docs/reference/rules-injection-cross-module-comparison.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/rules-injection-cross-module-comparison.md) |
| Sample configs | [docs/examples/](file:///Users/yeongyu/local-workspaces/omo/docs/examples/) (default, coding-focused, planning-focused) |
| Privacy & ToS | [docs/legal/](file:///Users/yeongyu/local-workspaces/omo/docs/legal/) |
+577 -262
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -17,6 +17,7 @@ We collect limited non-personal information needed to operate and improve the Se
When anonymous telemetry is enabled, the Application may collect a single anonymous usage event:
- `omo_daily_active`, sent at most once per UTC day per machine when the plugin loads or when the `run` CLI is invoked, used to estimate daily, weekly, and monthly active installations
- `omo_codex_daily_active`, sent at most once per UTC day per machine when the `omo-codex` adapter is installed (`reason: "install_completed"`) or when its Codex plugin runtime fires on a Codex `SessionStart` hook (`reason: "session_start"`), with the same opt-out posture as `omo_daily_active`
- Anonymous machine metadata bundled with that event, such as package version, plugin name, runtime, OS family, locale, and timezone
- A pseudonymous installation identifier derived from a one-way hash of the local hostname
@@ -36,8 +37,14 @@ Telemetry can be disabled at any time by setting one of these environment variab
export OMO_SEND_ANONYMOUS_TELEMETRY=0
# or
export OMO_DISABLE_POSTHOG=1
# codex-only opt-out flags
export OMO_CODEX_DISABLE_POSTHOG=1
export OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0
```
`OMO_CODEX_DISABLE_POSTHOG` and `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` suppress only `omo-codex` telemetry.
Global flags (`OMO_DISABLE_POSTHOG`, `OMO_SEND_ANONYMOUS_TELEMETRY`) suppress telemetry for both oh-my-openagent/oh-my-opencode and omo-codex.
When telemetry is disabled, PostHog events are not sent.
## 3. Third-Party Services
+36 -10
View File
@@ -7,6 +7,15 @@ Complete reference for the published CLI package. During the rename transition,
Plugin registration inside `opencode.json` prefers `oh-my-openagent`.
## Bin Commands
All published packages expose the same compiled CLI with these bin entries:
- `oh-my-opencode` (legacy name, still primary)
- `oh-my-openagent` (renamed primary)
- `omo` (short alias, recommended in docs and prompts)
- `lazycodex` (Light edition shortcut; `lazycodex install` is equivalent to `omo install --platform=codex` unless `--platform` is explicitly overridden)
## Basic Usage
```bash
@@ -47,18 +56,35 @@ bunx oh-my-openagent install
| Option | Description |
| --- | --- |
| `--no-tui` | Run in non-interactive mode (requires all needed options) |
| `--claude <value>` | Claude subscription: `no`, `yes`, `max20` |
| `--openai <value>` | OpenAI/ChatGPT subscription: `no`, `yes` |
| `--gemini <value>` | Gemini integration: `no`, `yes` |
| `--copilot <value>` | GitHub Copilot subscription: `no`, `yes` |
| `--opencode-zen <value>` | OpenCode Zen access: `no`, `yes` |
| `--zai-coding-plan <value>` | Z.ai Coding Plan subscription: `no`, `yes` |
| `--kimi-for-coding <value>` | Kimi For Coding subscription: `no`, `yes` |
| `--opencode-go <value>` | OpenCode Go subscription: `no`, `yes` |
| `--vercel-ai-gateway <value>` | Vercel AI Gateway: `no`, `yes` |
| `--platform <value>` | Install target edition: `opencode` (Ultimate, default), `codex` (Light), or `both` |
| `--claude <value>` | Claude subscription: `no`, `yes`, `max20` (Ultimate only) |
| `--openai <value>` | OpenAI/ChatGPT subscription: `no`, `yes` (Ultimate only) |
| `--gemini <value>` | Gemini integration: `no`, `yes` (Ultimate only) |
| `--copilot <value>` | GitHub Copilot subscription: `no`, `yes` (Ultimate only) |
| `--opencode-zen <value>` | OpenCode Zen access: `no`, `yes` (Ultimate only) |
| `--zai-coding-plan <value>` | Z.ai Coding Plan subscription: `no`, `yes` (Ultimate only) |
| `--kimi-for-coding <value>` | Kimi For Coding subscription: `no`, `yes` (Ultimate only) |
| `--opencode-go <value>` | OpenCode Go subscription: `no`, `yes` (Ultimate only) |
| `--vercel-ai-gateway <value>` | Vercel AI Gateway: `no`, `yes` (Ultimate only) |
| `--codex-autonomous` | Configure Codex with `approval_policy = "never"`, `sandbox_mode = "danger-full-access"`, and `network_access = "enabled"` when installing Light or Both |
| `--no-codex-autonomous` | Leave existing Codex permission settings unchanged when installing Light or Both |
| `--skip-auth` | Skip authentication setup hints |
Anonymous telemetry uses PostHog with a hashed installation identifier. Disable with `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`.
When using the `lazycodex` bin alias, `install` defaults to `--platform=codex`. `lazycodex` is only the npm/bin alias and marketplace repository name. The Codex config uses marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`, with the marketplace source set to the local built cache under `~/.codex/plugins/cache/sisyphuslabs`.
Subscription flags (`--claude`, `--openai`, etc.) only apply when `--platform` is `opencode` or `both`. They are rejected under `--platform=codex` because the Light edition does not write OpenCode model config. `--codex-autonomous` and `--no-codex-autonomous` only affect installs where the selected platform includes Codex.
### Telemetry and opt-out
Anonymous telemetry uses PostHog with a hashed installation identifier. Two streams exist:
- `omo_daily_active`: fired by the main plugin and `oh-my-openagent run`.
- `omo_codex_daily_active`: fired by `omo install --platform=codex` or `--platform=both` (`reason: "install_completed"`) and by the Codex plugin's `SessionStart` hook on every Codex session (`reason: "session_start"`). Both sources share the same UTC-day deduplication, so daily/weekly/monthly active counts reflect real Codex usage, not just install events.
Opt-out env vars:
- Global opt-out for oh-my-openagent and omo-codex: `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`
- Codex-only opt-out for `omo_codex_daily_active`: `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_CODEX_DISABLE_POSTHOG=1`
---
+2
View File
@@ -1007,6 +1007,8 @@ When enabled, OmO registers the hash-anchored `edit` tool and activates the `has
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) |
| `OMO_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry |
| `OMO_DISABLE_POSTHOG` | Legacy telemetry opt-out flag. Set to `1` or `true` to disable PostHog |
| `OMO_CODEX_DISABLE_POSTHOG` | Set to `1` or `true` to disable PostHog telemetry for the `omo-codex` adapter only. Does not affect oh-my-opencode telemetry |
| `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry for `omo-codex` only |
| `POSTHOG_API_KEY` | Optional override for the built-in PostHog project API key |
| `POSTHOG_HOST` | Override the PostHog ingestion host. Defaults to `https://us.i.posthog.com` |
+1 -1
View File
@@ -300,7 +300,7 @@ Skills provide specialized workflows with embedded MCP servers and detailed inst
| **dev-browser** | Stateful browser scripting | Browser automation with persistent page state for iterative workflows and authenticated sessions. |
| **frontend-ui-ux** | UI/UX tasks, styling | Designer-turned-developer persona. Crafts stunning UI/UX even without design mockups. Emphasizes bold aesthetic direction, distinctive typography, cohesive color palettes. |
| **review-work** | "review work", "review my work", "QA my work" | Post-implementation review orchestrator. Launches 5 parallel background sub-agents for comprehensive review: goal verification, code quality, security, hands-on QA, and context mining. All must pass for review to pass. |
| **ai-slop-remover**| "remove AI slop", "de-AI", "humanize" | Removes AI-generated code smells from files while preserving functionality. Identifies and eliminates verbose comments, redundant error handling, over-engineered patterns, and generic AI phrasing. |
| **$omo:remove-ai-slops**| "remove AI slop", "de-AI", "humanize" | Removes AI-generated code smells from files while preserving functionality. Identifies and eliminates verbose comments, redundant error handling, over-engineered patterns, and generic AI phrasing. |
#### git-master Core Principles
@@ -0,0 +1,13 @@
# Reserving the lazycodex npm name (first-publish playbook)
`lazycodex` is the npm/bin alias for the Codex CLI Light edition and the Git repository that hosts the native Codex marketplace bundle. It is not the marketplace identity. Codex installs marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`.
The `publish.yml` workflow includes `lazycodex` in trusted-publisher preflight, but that check is soft for first publish.
If `lazycodex` is not yet claimed on npm, the workflow warns and continues so existing package releases are not blocked.
To claim the name, run a one-time manual `npm publish` for `lazycodex` from a trusted environment (for example local shell with `NPM_AUTH_TOKEN`).
After the first manual publish, configure GitHub Actions trusted publishing at:
https://www.npmjs.com/package/lazycodex/access
Set Provider to GitHub Actions, Organization to `code-yeongyu`, Repository to `oh-my-openagent`, and Workflow filename to `publish.yml`.
After this setup, subsequent releases from `publish.yml` can publish `lazycodex` automatically.
The same release workflow also syncs `packages/omo-codex/marketplace.json` and `packages/omo-codex/plugin/` into `code-yeongyu/lazycodex` as `.agents/plugins/marketplace.json` and `plugins/omo/`. That cross-repo push requires the `LAZYCODEX_SYNC_TOKEN` repository secret.
@@ -3,7 +3,7 @@
Comparison and porting record for the three rule injection implementations
maintained out of `/Users/yeongyu/local-workspaces`:
- **codex-rules** — Codex hook plugin (`codex-plugins/plugins/codex-rules`, repo `code-yeongyu/codex-rules`, branch `main`).
- **codex-rules** — Codex hook plugin now bundled under the OMO Codex marketplace plugin (`packages/omo-codex/plugin/components/rules`, marketplace `sisyphuslabs`, plugin `omo`). The original standalone repo was `code-yeongyu/codex-rules`, branch `main`.
- **pi-rules** — pi-mono extension (`pi-extensions/pi-rules`, repo `code-yeongyu/pi-rules`, branch `main`).
- **omo rules-injector** — opencode plugin path (`omo/src/hooks/rules-injector`, repo `code-yeongyu/oh-my-openagent`, branch `dev`).
@@ -18,7 +18,7 @@ maintained out of `/Users/yeongyu/local-workspaces`:
Installation state after the porting round:
- **omo**`~/.bun/install/global/node_modules/oh-my-opencode` is a symlink to the local workspace, so `bun run build` immediately publishes the rebuilt `dist/`. Verified via `grep -c transcriptHydration dist/index.js` → 6.
- **codex-rules**`node scripts/install-local.mjs ...` was rerun and the cache at `~/.codex/plugins/cache/code-yeongyu-codex-plugins/codex-rules/0.1.0` was refreshed.
- **codex-rules**now installed through the aggregate OMO Codex plugin cache at `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/components/rules`, enabled by `[plugins."omo@sisyphuslabs"]`.
- **pi-rules** — pi-mono consumes the package source directly; no separate install step.
## 1. Performance baseline
+19 -4
View File
@@ -15,11 +15,15 @@
"packages/comment-checker-core",
"packages/hashline-core",
"packages/boulder-state",
"packages/agents-md-core"
"packages/agents-md-core",
"packages/shared-skills",
"packages/omo-codex"
],
"bin": {
"oh-my-opencode": "bin/oh-my-opencode.js",
"oh-my-openagent": "bin/oh-my-opencode.js"
"oh-my-openagent": "bin/oh-my-opencode.js",
"omo": "bin/oh-my-opencode.js",
"lazycodex": "bin/oh-my-opencode.js"
},
"files": [
"dist",
@@ -30,7 +34,14 @@
".agents/command",
".agents/skills",
"packages/lsp-tools-mcp/dist",
"packages/ast-grep-mcp/dist"
"packages/ast-grep-mcp/dist",
"packages/shared-skills/package.json",
"packages/shared-skills/index.mjs",
"packages/shared-skills/skills",
"packages/omo-codex/marketplace.json",
"packages/omo-codex/plugin",
"packages/omo-codex/plugin/.codex-plugin",
"packages/omo-codex/scripts"
],
"exports": {
".": {
@@ -54,9 +65,11 @@
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test",
"test:codex": "bun run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
"test:windows-codex": "bun run test:codex",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
},
"keywords": [
@@ -105,8 +118,10 @@
"@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/hashline-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/omo-codex": "workspace:*",
"@oh-my-opencode/prompts-core": "workspace:*",
"@oh-my-opencode/rules-engine": "workspace:*",
"@oh-my-opencode/shared-skills": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
"@types/js-yaml": "^4.0.9",
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"bin": {
"ast-grep-mcp": "dist/cli.js"
"omo-ast-grep": "dist/cli.js"
},
"exports": {
".": {
+8 -2
View File
@@ -1,14 +1,20 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { writeMcpLifecycleLog } from "./mcp-lifecycle-log";
import { runMcpStdioServer } from "./mcp";
async function main(): Promise<void> {
const [command = "mcp"] = argv.slice(2);
if (command === "mcp") {
await runMcpStdioServer();
await runMcpStdioServer(process.stdin, process.stdout, {}, {
log: writeMcpLifecycleLog,
onIdleTimeout: () => {
process.exit(0);
},
});
return;
}
stderr.write("Usage: ast-grep-mcp [mcp]\n");
stderr.write("Usage: omo-ast-grep [mcp]\n");
process.exitCode = 2;
}
@@ -0,0 +1,33 @@
import { appendFileSync, renameSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
type LogFieldValue = boolean | number | string | null;
const LOG_FILE_NAME = "omo-ast-grep-mcp.log";
const MAX_LOG_BYTES = 5 * 1024 * 1024;
export function mcpLifecycleLogPath(): string {
return join(tmpdir(), LOG_FILE_NAME);
}
export function writeMcpLifecycleLog(event: string, fields: Record<string, LogFieldValue> = {}): void {
const path = mcpLifecycleLogPath();
try {
rotateLogIfNeeded(path);
appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
function rotateLogIfNeeded(path: string): void {
try {
if (statSync(path).size < MAX_LOG_BYTES) return;
renameSync(path, `${path}.1`);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { PassThrough } from "node:stream";
import { runMcpStdioServer } from "./mcp";
describe("ast-grep MCP stdio server", () => {
it("#given Codex sends a content-length framed initialize #when stdio server handles it #then responds with a framed initialize result", async () => {
const input = new PassThrough();
const output = new PassThrough();
const received = nextOutput(output);
const server = runMcpStdioServer(input, output);
writeContentLengthFrame(input, {
jsonrpc: "2.0",
id: 5,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "codex", version: "0.0.0" },
},
});
const response = parseContentLengthFrame(await received);
input.end();
await server;
expect(response).toEqual({
jsonrpc: "2.0",
id: 5,
result: {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "ast_grep", version: "0.1.0" },
protocolVersion: "2024-11-05",
},
});
});
});
function writeContentLengthFrame(input: PassThrough, message: unknown): void {
const body = JSON.stringify(message);
input.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
}
function nextOutput(output: PassThrough): Promise<string> {
return new Promise((resolve) => {
output.once("data", (chunk: Buffer | string) => {
resolve(String(chunk));
});
});
}
function parseContentLengthFrame(raw: string): unknown {
const separator = raw.indexOf("\r\n\r\n");
expect(separator).toBeGreaterThan(0);
const headers = raw.slice(0, separator);
const match = /^Content-Length: (\d+)$/im.exec(headers);
expect(match).not.toBeNull();
if (match === null) throw new TypeError(`Missing Content-Length header: ${raw}`);
const lengthValue = match[1];
if (lengthValue === undefined) throw new TypeError(`Invalid Content-Length header: ${raw}`);
const bodyStart = separator + "\r\n\r\n".length;
return JSON.parse(raw.slice(bodyStart, bodyStart + Number(lengthValue)));
}
@@ -0,0 +1,94 @@
import type { Readable, Writable } from "node:stream";
import type { AstGrepMcpOptions, JsonRpcResponse } from "./mcp";
import { readStdioJsonRpcMessages, writeStdioJsonRpcResponse } from "./mcp-stdio-transport";
export type McpLifecycleLog = (event: string, fields?: Record<string, boolean | number | string | null>) => void;
export interface McpStdioServerOptions {
readonly idleTimeoutMs?: number;
readonly onIdleTimeout?: () => void | Promise<void>;
readonly log?: McpLifecycleLog;
}
export type McpRequestHandler = (
input: unknown,
options: AstGrepMcpOptions,
) => Promise<JsonRpcResponse | undefined>;
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
const noopLog: McpLifecycleLog = () => {};
export async function runJsonRpcStdioServer(
handler: McpRequestHandler,
input: Readable,
output: Writable,
options: AstGrepMcpOptions,
stdioOptions: McpStdioServerOptions = {},
): Promise<void> {
const log = stdioOptions.log ?? noopLog;
const idleTimeoutMs = stdioOptions.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
const idleTimer = createIdleTimer(idleTimeoutMs, log, stdioOptions.onIdleTimeout);
log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs });
idleTimer.arm();
try {
for await (const message of readStdioJsonRpcMessages(input)) {
if (idleTimer.closed()) break;
idleTimer.arm();
if (message.kind === "parse_error") {
log("parse_error", { message: message.message });
writeStdioJsonRpcResponse(output, errorResponse(null, -32700, "Parse error", message.message), message.responseMode);
continue;
}
const parsed = message.payload;
const id = isRecord(parsed) ? jsonRpcId(parsed.id) : null;
const method = isRecord(parsed) && typeof parsed.method === "string" ? parsed.method : null;
log("request", { id: id === null ? null : String(id), method });
const response = await handler(parsed, options);
if (response) {
writeStdioJsonRpcResponse(output, response, message.responseMode);
log("response", { id: String(response.id), method, is_error: response.error !== undefined });
}
}
} finally {
idleTimer.clear();
log("stdio_stopped");
}
}
function createIdleTimer(idleTimeoutMs: number, log: McpLifecycleLog, onIdleTimeout?: () => void | Promise<void>) {
let timer: NodeJS.Timeout | null = null;
let isClosed = false;
return {
arm: () => {
if (timer !== null) clearTimeout(timer);
if (idleTimeoutMs <= 0) return;
timer = setTimeout(() => {
isClosed = true;
log("idle_timeout", { idle_timeout_ms: idleTimeoutMs });
void onIdleTimeout?.();
}, idleTimeoutMs);
timer.unref();
},
clear: () => {
if (timer === null) return;
clearTimeout(timer);
timer = null;
},
closed: () => isClosed,
};
}
function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse {
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
}
function jsonRpcId(value: unknown): string | number | null {
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,138 @@
import type { Readable, Writable } from "node:stream";
export type StdioJsonRpcResponseMode = "line" | "framed";
export type StdioJsonRpcMessage =
| {
readonly kind: "request";
readonly payload: unknown;
readonly responseMode: StdioJsonRpcResponseMode;
}
| {
readonly kind: "parse_error";
readonly message: string;
readonly responseMode: StdioJsonRpcResponseMode;
};
type ReadResult =
| { readonly kind: "incomplete" }
| {
readonly kind: "complete";
readonly message?: StdioJsonRpcMessage;
readonly remaining: Buffer<ArrayBufferLike>;
};
const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
export async function* readStdioJsonRpcMessages(input: Readable): AsyncGenerator<StdioJsonRpcMessage> {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
for await (const chunk of input) {
buffer = Buffer.concat([buffer, bufferFromChunk(chunk)]);
while (true) {
const result = readNextMessage(buffer);
if (result.kind === "incomplete") break;
buffer = result.remaining;
if (result.message) yield result.message;
}
}
const trailing = buffer.toString("utf8").trim();
if (trailing.length > 0) {
yield parseJsonPayload(trailing, "line");
}
}
export function writeStdioJsonRpcResponse(output: Writable, response: unknown, responseMode: StdioJsonRpcResponseMode): void {
const body = JSON.stringify(response);
if (responseMode === "framed") {
output.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
return;
}
output.write(`${body}\n`);
}
function readNextMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
if (buffer.length === 0) return { kind: "incomplete" };
return startsWithContentLength(buffer) ? readFramedMessage(buffer) : readLineMessage(buffer);
}
function readLineMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
const newlineIndex = buffer.indexOf(0x0a);
if (newlineIndex === -1) return { kind: "incomplete" };
const line = buffer.subarray(0, newlineIndex).toString("utf8").replace(/\r$/, "");
if (line.trim().length === 0) {
return {
kind: "complete",
remaining: buffer.subarray(newlineIndex + 1),
};
}
return {
kind: "complete",
message: parseJsonPayload(line, "line"),
remaining: buffer.subarray(newlineIndex + 1),
};
}
function readFramedMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
const separatorIndex = buffer.indexOf(HEADER_SEPARATOR);
if (separatorIndex === -1) return { kind: "incomplete" };
const headers = buffer.subarray(0, separatorIndex).toString("ascii");
const contentLength = parseContentLength(headers);
const bodyStart = separatorIndex + HEADER_SEPARATOR.length;
if (contentLength === undefined) {
return {
kind: "complete",
message: {
kind: "parse_error",
message: "Missing or invalid Content-Length header",
responseMode: "framed",
},
remaining: buffer.subarray(bodyStart),
};
}
const bodyEnd = bodyStart + contentLength;
if (buffer.length < bodyEnd) return { kind: "incomplete" };
const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
return {
kind: "complete",
message: parseJsonPayload(body, "framed"),
remaining: buffer.subarray(bodyEnd),
};
}
function startsWithContentLength(buffer: Buffer<ArrayBufferLike>): boolean {
const prefix = buffer.subarray(0, "content-length:".length).toString("ascii").toLowerCase();
return prefix === "content-length:";
}
function parseContentLength(headers: string): number | undefined {
for (const line of headers.split("\r\n")) {
const match = /^content-length:\s*(\d+)$/i.exec(line);
if (match === null) continue;
const value = match[1];
if (value === undefined) return undefined;
return Number(value);
}
return undefined;
}
function parseJsonPayload(payload: string, responseMode: StdioJsonRpcResponseMode): StdioJsonRpcMessage {
try {
return { kind: "request", payload: JSON.parse(payload), responseMode };
} catch (error) {
return { kind: "parse_error", message: messageFromError(error), responseMode };
}
}
function bufferFromChunk(chunk: unknown): Buffer<ArrayBufferLike> {
if (Buffer.isBuffer(chunk)) return chunk;
if (typeof chunk === "string") return Buffer.from(chunk);
throw new TypeError(`Unsupported stdio chunk type: ${typeof chunk}`);
}
function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+18 -1
View File
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleAstGrepMcpRequest } from "./mcp";
import { PassThrough } from "node:stream";
import { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
import type { RunOptions } from "./runner";
import type { SgResult } from "./types";
@@ -259,4 +260,20 @@ describe("ast-grep MCP", () => {
expect(searchTool?.description).toContain("This is NOT regex");
expect(searchTool?.description).toContain("Meta-variables");
});
it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => {
const input = new PassThrough();
const output = new PassThrough();
let idleCallCount = 0;
await runMcpStdioServer(input, output, {}, {
idleTimeoutMs: 1,
onIdleTimeout: () => {
idleCallCount++;
input.end();
},
});
expect(idleCallCount).toBe(1);
});
});
+6 -16
View File
@@ -1,5 +1,6 @@
import { createInterface } from "node:readline";
import type { Readable, Writable } from "node:stream";
import { CLI_LANGUAGES } from "./constants";
import { runJsonRpcStdioServer, type McpStdioServerOptions } from "./mcp-stdio-server";
import { getPatternHint } from "./pattern-hints";
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
import { runSg, type RunOptions } from "./runner";
@@ -116,23 +117,12 @@ export async function handleAstGrepMcpRequest(input: unknown, options: AstGrepMc
}
export async function runMcpStdioServer(
input: NodeJS.ReadableStream = process.stdin,
output: NodeJS.WritableStream = process.stdout,
input: Readable = process.stdin,
output: Writable = process.stdout,
options: AstGrepMcpOptions = {},
stdioOptions: McpStdioServerOptions = {},
): Promise<void> {
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of lines) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
continue;
}
const response = await handleAstGrepMcpRequest(parsed, options);
if (response) output.write(`${JSON.stringify(response)}\n`);
}
await runJsonRpcStdioServer(handleAstGrepMcpRequest, input, output, options, stdioOptions);
}
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
@@ -0,0 +1,48 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
type PackageJson = {
readonly name: string;
readonly type: string;
readonly bin: Record<string, string>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
describe("package metadata", () => {
it("#given packaged ast-grep MCP files #when validating entrypoints #then package metadata exposes the omo CLI", () => {
// given
const packageJson = readPackageJson(fileURLToPath(new URL("../package.json", import.meta.url)));
const cliSource = readFileSync(fileURLToPath(new URL("cli.ts", import.meta.url)), "utf8");
// then
expect(packageJson.name).toBe("@oh-my-opencode/ast-grep-mcp");
expect(packageJson.type).toBe("module");
expect(packageJson.bin["omo-ast-grep"]).toBe("dist/cli.js");
expect(packageJson.bin["ast-grep-mcp"]).toBeUndefined();
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(cliSource).toContain("Usage: omo-ast-grep [mcp]");
});
});
function isPackageJson(value: unknown): value is PackageJson {
return (
isRecord(value) &&
value["name"] === "@oh-my-opencode/ast-grep-mcp" &&
value["type"] === "module" &&
isStringRecord(value["bin"])
);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+1
View File
@@ -20,6 +20,7 @@ export {
getWorkByPlanName,
getWorkForSession,
getWorkResumeOptions,
normalizeSessionId,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
@@ -1,5 +1,6 @@
export { getBoulderFilePath, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "./path"
export { findPrometheusPlans, getPlanName, getPlanProgress } from "./plan-progress"
export { normalizeSessionId } from "./shared"
export {
getActiveWorks,
getBoulderWorks,
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"
import type { BoulderState, BoulderWorkResumeOption, BoulderWorkState, TaskSessionState } from "../types"
import { getBoulderFilePath, resolveBoulderPlanPathForWork } from "./path"
import { getPlanProgress } from "./plan-progress"
import { buildWorkFromMirror, isValidWorkStatus, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared"
import { buildWorkFromMirror, isValidWorkStatus, normalizeSessionId, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared"
export function readBoulderState(directory: string): BoulderState | null {
const filePath = getBoulderFilePath(directory)
@@ -33,8 +33,9 @@ export function readBoulderState(directory: string): BoulderState | null {
}
function normalizeState(state: Record<string, unknown>): void {
normalizeSessionFields(state)
const sessionIds = Array.isArray(state.session_ids) ? state.session_ids : []
state.session_ids = sessionIds
const sessionOrigins = state.session_origins && typeof state.session_origins === "object" && !Array.isArray(state.session_origins)
? (state.session_origins as Record<string, unknown>)
@@ -55,6 +56,38 @@ function normalizeState(state: Record<string, unknown>): void {
if (!state.task_sessions || typeof state.task_sessions !== "object" || Array.isArray(state.task_sessions)) {
state.task_sessions = {}
}
normalizeWorkSessionFields(state.works)
}
function normalizeSessionFields(target: Record<string, unknown>): void {
const sessionIds = Array.isArray(target.session_ids)
? target.session_ids.filter((sessionId): sessionId is string => typeof sessionId === "string").map((sessionId) => normalizeSessionId(sessionId))
: []
target.session_ids = sessionIds
const sessionOrigins = target.session_origins && typeof target.session_origins === "object" && !Array.isArray(target.session_origins)
? normalizeSessionOrigins(target.session_origins as Record<string, unknown>)
: {}
target.session_origins = sessionOrigins
}
function normalizeSessionOrigins(sessionOrigins: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(sessionOrigins).map(([sessionId, origin]) => [normalizeSessionId(sessionId), origin]),
)
}
function normalizeWorkSessionFields(works: unknown): void {
if (!works || typeof works !== "object" || Array.isArray(works)) {
return
}
for (const work of Object.values(works)) {
if (work && typeof work === "object" && !Array.isArray(work)) {
normalizeSessionFields(work as Record<string, unknown>)
}
}
}
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
@@ -113,15 +146,16 @@ export function getWorkForSession(directory: string, sessionId: string): Boulder
return null
}
const normalizedSessionId = normalizeSessionId(sessionId)
const works = getBoulderWorks(state)
.filter((work) => work.session_ids.includes(sessionId))
.filter((work) => work.session_ids.includes(normalizedSessionId))
.sort((left, right) => (parseIsoToMs(right.updated_at ?? right.started_at) ?? 0) - (parseIsoToMs(left.updated_at ?? left.started_at) ?? 0))
if (works.length > 0) {
return works[0] ?? null
}
return state.session_ids.includes(sessionId) ? buildWorkFromMirror(state) : null
return state.session_ids.includes(normalizedSessionId) ? buildWorkFromMirror(state) : null
}
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
+12 -10
View File
@@ -1,6 +1,6 @@
import type { BoulderSessionOrigin, BoulderState, BoulderWorkState } from "../types"
import { getBoulderWorks, readBoulderState } from "./read-state"
import { nowIsoString, projectWorkToMirror } from "./shared"
import { normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared"
import { writeBoulderState } from "./write-state"
export function appendSessionId(
@@ -8,9 +8,10 @@ export function appendSessionId(
sessionId: string,
origin: "direct" | "appended" = "direct",
): BoulderState | null {
const normalizedSessionId = normalizeSessionId(sessionId)
const activeWorkId = readBoulderState(directory)?.active_work_id
if (activeWorkId) {
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
return appendSessionIdForWork(directory, activeWorkId, normalizedSessionId, origin)
}
const state = readBoulderState(directory)
@@ -22,15 +23,15 @@ export function appendSessionId(
state.session_origins = {}
}
if (!state.session_ids?.includes(sessionId)) {
if (!state.session_ids?.includes(normalizedSessionId)) {
if (!Array.isArray(state.session_ids)) {
state.session_ids = []
}
const originalSessionIds = [...state.session_ids]
const originalSessionOrigins = { ...state.session_origins }
state.session_ids.push(sessionId)
state.session_origins[sessionId] = origin
state.session_ids.push(normalizedSessionId)
state.session_origins[normalizedSessionId] = origin
if (writeBoulderState(directory, state)) {
return state
}
@@ -40,8 +41,8 @@ export function appendSessionId(
return null
}
if (!state.session_origins[sessionId]) {
state.session_origins[sessionId] = origin
if (!state.session_origins[normalizedSessionId]) {
state.session_origins[normalizedSessionId] = origin
if (!writeBoulderState(directory, state)) {
return null
}
@@ -56,6 +57,7 @@ export function appendSessionIdForWork(
sessionId: string,
origin: BoulderSessionOrigin = "direct",
): BoulderState | null {
const normalizedSessionId = normalizeSessionId(sessionId)
const state = readBoulderState(directory)
if (!state) {
return null
@@ -69,10 +71,10 @@ export function appendSessionIdForWork(
const updatedWork: BoulderWorkState = {
...targetWork,
session_ids: targetWork.session_ids.includes(sessionId)
session_ids: targetWork.session_ids.includes(normalizedSessionId)
? [...targetWork.session_ids]
: [...targetWork.session_ids, sessionId],
session_origins: { ...(targetWork.session_origins ?? {}), [sessionId]: origin },
: [...targetWork.session_ids, normalizedSessionId],
session_origins: { ...(targetWork.session_origins ?? {}), [normalizedSessionId]: origin },
updated_at: nowIsoString(),
}
@@ -2,6 +2,18 @@ import type { BoulderState, BoulderWorkState, BoulderWorkStatus } from "../types
export const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
type SessionPlatform = "codex" | "opencode"
const SESSION_ID_PREFIX_PATTERN = /^(codex|opencode):/
export function normalizeSessionId(sessionId: string, platform: SessionPlatform = "opencode"): string {
if (SESSION_ID_PREFIX_PATTERN.test(sessionId)) {
return sessionId
}
return `${platform}:${sessionId}`
}
export function nowIsoString(): string {
return new Date().toISOString()
}
+9 -4
View File
@@ -1,6 +1,6 @@
import type { BoulderState, BoulderWorkState, TaskSessionState } from "../types"
import { getBoulderWorks, readBoulderState } from "./read-state"
import { getElapsedMs, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared"
import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared"
import { writeBoulderState } from "./write-state"
export function upsertTaskSessionState(
@@ -24,12 +24,13 @@ export function upsertTaskSessionState(
return null
}
const normalizedSessionId = normalizeSessionId(input.sessionId)
const taskSessions = state.task_sessions ?? {}
taskSessions[input.taskKey] = {
task_key: input.taskKey,
task_label: input.taskLabel,
task_title: input.taskTitle,
session_id: input.sessionId,
session_id: normalizedSessionId,
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.category !== undefined ? { category: input.category } : {}),
updated_at: nowIsoString(),
@@ -66,12 +67,13 @@ export function upsertTaskSessionStateForWork(
return null
}
const normalizedSessionId = normalizeSessionId(input.sessionId)
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
const nextTaskSession: TaskSessionState = {
task_key: input.taskKey,
task_label: input.taskLabel,
task_title: input.taskTitle,
session_id: input.sessionId,
session_id: normalizedSessionId,
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.category !== undefined ? { category: input.category } : {}),
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
@@ -116,7 +118,10 @@ export function startTaskTimer(
startedAt?: string
},
): BoulderState | null {
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
const nextState = upsertTaskSessionStateForWork(directory, workId, {
...input,
sessionId: normalizeSessionId(input.sessionId),
})
if (!nextState) {
return null
}
@@ -5,7 +5,7 @@ import type { BoulderState, BoulderWorkState } from "../types"
import { getBoulderFilePath } from "./path"
import { getPlanName } from "./plan-progress"
import { getBoulderWorks, readBoulderState } from "./read-state"
import { getElapsedMs, nowIsoString, projectWorkToMirror } from "./shared"
import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared"
export function writeBoulderState(directory: string, state: BoulderState): boolean {
const filePath = getBoulderFilePath(directory)
@@ -67,6 +67,7 @@ export function generateWorkId(planName: string): string {
export function createBoulderState(planPath: string, sessionId: string, agent?: string, worktreePath?: string): BoulderState {
const startedAt = nowIsoString()
const normalizedSessionId = normalizeSessionId(sessionId)
const workId = generateWorkId(getPlanName(planPath))
const work: BoulderWorkState = {
work_id: workId,
@@ -75,8 +76,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?:
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [sessionId],
session_origins: { [sessionId]: "direct" },
session_ids: [normalizedSessionId],
session_origins: { [normalizedSessionId]: "direct" },
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
task_sessions: {},
@@ -90,8 +91,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?:
started_at: startedAt,
status: "active",
updated_at: startedAt,
session_ids: [sessionId],
session_origins: { [sessionId]: "direct" },
session_ids: [normalizedSessionId],
session_origins: { [normalizedSessionId]: "direct" },
plan_name: getPlanName(planPath),
task_sessions: {},
...(agent !== undefined ? { agent } : {}),
@@ -132,6 +133,7 @@ export function addBoulderWork(
const workId = generateWorkId(getPlanName(input.planPath))
const startedAt = input.startedAt ?? nowIsoString()
const normalizedSessionId = normalizeSessionId(input.sessionId)
const nextWork: BoulderWorkState = {
work_id: workId,
active_plan: input.planPath,
@@ -139,8 +141,8 @@ export function addBoulderWork(
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [input.sessionId],
session_origins: { [input.sessionId]: "direct" },
session_ids: [normalizedSessionId],
session_origins: { [normalizedSessionId]: "direct" },
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
task_sessions: {},
@@ -0,0 +1,63 @@
/// <reference path="../../../bun-test.d.ts" />
import { describe, expect, test } from "bun:test"
import * as boulderState from "../src"
describe("normalizeSessionId", () => {
test("#given a bare id #when normalized without a platform #then opencode is used by default", () => {
// given
expect(typeof boulderState.normalizeSessionId).toBe("function")
// when
const normalized = boulderState.normalizeSessionId("sess_abc")
// then
expect(normalized).toBe("opencode:sess_abc")
})
test("#given a bare id and codex platform #when normalized #then codex is used", () => {
// given
expect(typeof boulderState.normalizeSessionId).toBe("function")
// when
const normalized = boulderState.normalizeSessionId("sess_abc", "codex")
// then
expect(normalized).toBe("codex:sess_abc")
})
test("#given an opencode-prefixed id #when normalized #then the id is unchanged", () => {
// given
const input = "opencode:sess_abc"
expect(typeof boulderState.normalizeSessionId).toBe("function")
// when
const normalized = boulderState.normalizeSessionId(input)
// then
expect(normalized).toBe(input)
})
test("#given a codex-prefixed id and opencode platform #when normalized #then the existing prefix wins", () => {
// given
expect(typeof boulderState.normalizeSessionId).toBe("function")
// when
const normalized = boulderState.normalizeSessionId("codex:sess_abc", "opencode")
// then
expect(normalized).toBe("codex:sess_abc")
})
test("#given an empty id #when normalized #then opencode empty id is preserved", () => {
// given
expect(typeof boulderState.normalizeSessionId).toBe("function")
// when
const normalized = boulderState.normalizeSessionId("")
// then
expect(normalized).toBe("opencode:")
})
})
+30
View File
@@ -0,0 +1,30 @@
# Sisyphus Labs Codex Marketplace
Native Codex marketplace for the `omo` plugin.
## Plugin
`omo` is one Codex plugin namespace with isolated internal components:
- `components/comment-checker`: runs comment-checker automatically after successful `apply_patch` edits.
- `components/rules`: injects local project rule files into Codex context through lifecycle hooks.
- `components/lsp`: exposes Language Server Protocol diagnostics, navigation, symbols, and rename tools through MCP and post-edit hooks.
- `components/ultrawork`: injects the ultrawork orchestration directive when a user prompt contains `ultrawork` or `ulw`.
- `components/ulw-loop`: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit (`.omo/ulw-loop/`).
- `components/start-work-continuation`: resumes `.omo/boulder.json` start-work plans from stop boundaries.
- `components/telemetry`: emits anonymous daily active telemetry when enabled.
## Install
```bash
bunx lazycodex install
```
The installer builds `omo`, copies a clean versioned cache entry into `~/.codex/plugins/cache/sisyphuslabs/omo`, installs runtime dependencies in the cache, writes stable bundled-agent TOMLs through `~/.codex/.tmp/marketplaces/sisyphuslabs/plugins/omo`, registers the `sisyphuslabs` marketplace from the local built cache, and enables `[plugins."omo@sisyphuslabs"]` in `~/.codex/config.toml`.
It also enables both `plugins = true` and `plugin_hooks = true` under `[features]` so bundled hook files run.
If your local Codex build exposes plugin install commands, you can use those instead. For older local builds, the installer replaces the manual copy fallback:
```text
~/.codex/plugins/cache/sisyphuslabs/omo/0.1.0
```
+80
View File
@@ -0,0 +1,80 @@
# @oh-my-opencode/omo-codex
Codex harness adapter for **oh-my-openagent**. Brings the OMO experience (rules injection, comment checker, LSP MCP, ultrawork, ulw-loop, start-work continuation, telemetry) into [OpenAI Codex CLI](https://github.com/openai/codex) through Codex's native plugin system.
## Layout
| Path | Purpose |
|------|---------|
| `plugin/` | Vendored Codex plugin namespace `omo` with isolated components. Shipped to the user via `~/.codex/plugins/cache/`. |
| `marketplace.json` | Codex marketplace manifest. Identifies `omo` as the single installable plugin. |
| `scripts/` | Node ESM build scripts for Codex cache installation and marketplace config updates. |
| `src/` | TypeScript runtime: installer + telemetry consumed by the omodex CLI. |
| `MARKETPLACE.md` | Native Codex marketplace notes for `sisyphuslabs` / `omo`. |
## Components Vendored
- `rules` (TypeScript) - injects `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` into context via `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact`.
- `comment-checker` (TypeScript) - runs `@code-yeongyu/comment-checker` after `apply_patch` / `edit` / `write` tool use.
- `lsp` (TypeScript + LSP MCP) - exposes LSP diagnostics, navigation, symbols, rename via MCP + post-edit hooks.
- `ultrawork` (TypeScript) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive; bundled agent TOML files are installed into `CODEX_HOME/agents`.
- `ulw-loop` (TypeScript) - durable multi-goal orchestration backed by `.omo/ulw-loop/` evidence audit.
- `start-work-continuation` (TypeScript) - `Stop` / `SubagentStop` continuation hook for `.omo/boulder.json` start-work plans.
- `telemetry` (TypeScript) - anonymous daily active telemetry hook.
## Install
End users invoke through the omodex CLI. This package is the **Light edition** of omo — install it directly with:
```bash
bunx omo install --platform=codex
# or via the shortcut alias (same compiled CLI, defaults --platform=codex):
bunx lazycodex install
# or the longer package names:
bunx oh-my-opencode install --platform=codex
bunx oh-my-openagent install --platform=codex
```
To install **both** the Ultimate edition (OpenCode plugin) and the Light edition (this package) at once, use `--platform=both`.
The installer copies the built plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/`, writes stable agent TOML links through `~/.codex/.tmp/marketplaces/sisyphuslabs/plugins/omo/`, enables `omo@sisyphuslabs` in `~/.codex/config.toml`, and registers the `sisyphuslabs` marketplace from the local built cache. `lazycodex` is the repo/npm/bin alias; the marketplace identity remains `sisyphuslabs`.
To install both editions in one command, use `--platform=both`.
## Telemetry
Anonymous telemetry uses the same PostHog project as oh-my-openagent but emits the distinct event `omo_codex_daily_active`. The event is sent at most once per UTC day per machine from two sources:
| Source | Reason | Trigger |
|--------|--------|---------|
| `install` | `install_completed` | `bunx omo install --platform=codex` or `--platform=both` finishes (handled by `src/cli/install-codex/install-codex.ts`) |
| `plugin` | `session_start` | Codex plugin `SessionStart` hook fires (handled by `plugin/components/telemetry/`) |
Both sources share the same SHA256-hashed installation identifier (`sha256("omo-codex:" + hostname)`), suppress PostHog person profiles, and write the daily dedup state to `~/.local/share/omo-codex/posthog-activity.json`.
Opt out with:
```bash
# Codex-only
export OMO_CODEX_DISABLE_POSTHOG=1
export OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0
# Globally (also disables oh-my-openagent telemetry)
export OMO_DISABLE_POSTHOG=1
export OMO_SEND_ANONYMOUS_TELEMETRY=0
```
The identity constants and opt-out behavior are pinned across both sources by `src/telemetry/cross-package-equivalence.test.ts`.
See `/Users/yeongyu/local-workspaces/omodex/docs/legal/privacy-policy.md` for the full disclosure.
## Component Sources
The bundled component implementations come from the Sisyphus Labs Codex plugin family:
- [code-yeongyu/codex-rules](https://github.com/code-yeongyu/codex-rules)
- [code-yeongyu/codex-comment-checker](https://github.com/code-yeongyu/codex-comment-checker)
- [code-yeongyu/codex-lsp](https://github.com/code-yeongyu/codex-lsp)
- [code-yeongyu/codex-ultrawork](https://github.com/code-yeongyu/codex-ultrawork)
- [code-yeongyu/codex-ulw-loop](https://github.com/code-yeongyu/codex-ulw-loop)
- [code-yeongyu/codex-start-work-continuation](https://github.com/code-yeongyu/codex-start-work-continuation)
+1
View File
@@ -0,0 +1 @@
export * from "./src/index";
+17
View File
@@ -0,0 +1,17 @@
{
"name": "sisyphuslabs",
"interface": {
"displayName": "Sisyphus Labs"
},
"plugins": [
{
"name": "omo",
"source": "./plugins/omo",
"category": "Developer Tools",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@oh-my-opencode/omo-codex",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Codex harness adapter for oh-my-openagent. Vendored Codex plugin namespace (omo) + TypeScript installer + telemetry.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
},
"./telemetry": "./src/telemetry/index.ts",
"./marketplace.json": "./marketplace.json"
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/**/*.test.ts",
"build:plugin": "bun run --cwd plugin build",
"sync:skills": "node plugin/scripts/sync-skills.mjs"
},
"dependencies": {
"@oh-my-opencode/utils": "workspace:*",
"posthog-node": "^5.34.3"
},
"devDependencies": {
"bun-types": "1.3.14"
}
}
@@ -0,0 +1,35 @@
{
"name": "omo",
"version": "0.1.0",
"description": "One Codex plugin namespace for Yeongyu's local Codex components.",
"author": {
"name": "Yeongyu Kim",
"email": "yeongyu@users.noreply.github.com",
"url": "https://github.com/code-yeongyu"
},
"homepage": "https://github.com/sisyphuslabs/omo",
"repository": "https://github.com/sisyphuslabs/omo",
"license": "MIT",
"keywords": ["codex", "codex-plugin", "omo", "hooks", "mcp", "skills"],
"skills": "./skills/",
"hooks": "./hooks/hooks.json",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "OMO",
"shortDescription": "Unified local Codex components",
"longDescription": "OMO exposes the local Codex Rules, Comment Checker, LSP, Ultrawork, and ulw-loop components as one plugin namespace while keeping each component isolated under components/ for maintenance.",
"developerName": "Yeongyu Kim",
"category": "Developer Tools",
"capabilities": ["Hooks", "MCP Tools", "Code Intelligence", "Workflow", "Context Injection"],
"websiteURL": "https://github.com/sisyphuslabs/omo",
"privacyPolicyURL": "https://github.com/sisyphuslabs/omo#privacy",
"termsOfServiceURL": "https://github.com/sisyphuslabs/omo#license",
"defaultPrompt": [
"Use OMO LSP diagnostics on this workspace.",
"Show which OMO rules matched this file.",
"ulw: run this change with evidence."
],
"brandColor": "#7C3AED",
"screenshots": []
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"ast_grep": {
"command": "node",
"args": ["../../ast-grep-mcp/dist/cli.js", "mcp"],
"cwd": "."
},
"lsp": {
"command": "node",
"args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
"cwd": "."
}
}
}
+13
View File
@@ -0,0 +1,13 @@
# omo
`omo` is the single local Codex plugin namespace for Yeongyu's Codex components.
Internally each component remains isolated under `components/`:
- `components/comment-checker`
- `components/rules`
- `components/lsp`
- `components/ultrawork`
- `components/ulw-loop`
The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and the LSP MCP server.
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1,12 @@
* @code-yeongyu
.github/workflows/* @code-yeongyu
.github/dependabot.yml @code-yeongyu
package.json @code-yeongyu
package-lock.json @code-yeongyu
LICENSE @code-yeongyu
NOTICE @code-yeongyu
README.md @code-yeongyu
CHANGELOG.md @code-yeongyu
.codex-plugin/plugin.json @code-yeongyu
hooks/hooks.json @code-yeongyu
@@ -0,0 +1,40 @@
name: Bug Report
description: Report broken Codex hook, MCP, or comment-checking behavior
labels: [bug]
body:
- type: markdown
attributes:
value: |
Include the Codex tool payload, hook output, and plugin version needed to reproduce.
- type: textarea
id: what
attributes:
label: What happened?
description: Include exact output/errors.
validations:
required: true
- type: textarea
id: payload
attributes:
label: Tool payload
description: Paste the minimal PostToolUse or MCP payload that reproduces the issue.
render: json
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: input
id: version
attributes:
label: codex-comment-checker version
placeholder: 0.1.0
validations:
required: false
@@ -0,0 +1,27 @@
name: Feature Request
description: Propose a Codex comment-checker hook or MCP improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow is blocked or awkward today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-comment-checker do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What else could solve this?
validations:
required: false
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
dev-dependencies:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -0,0 +1,19 @@
## Summary
<!-- Brief description, 1-3 bullets -->
-
## Verification
- [ ] `npm run check` (typecheck + biome + build)
- [ ] `npm test` (unit tests)
- [ ] `npm pack --dry-run` (release sanity)
- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use`
## Codex plugin impact
- [ ] `.codex-plugin/plugin.json` remains valid
- [ ] `hooks/hooks.json` still uses stable Codex hook JSON
- [ ] No MCP server or MCP tool is exposed
- [ ] CHANGELOG entry added for user-facing changes
@@ -0,0 +1,47 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,51 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
.env
.env.*
coverage/
.vitest/
@@ -0,0 +1,35 @@
# Repository Conventions
Conventions for human contributors and AI agents working on this repository.
## Style
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
- TypeScript strict mode. No `any`, no `unknown` casts where avoidable, no `@ts-ignore`, no `@ts-expect-error`, no enums.
- ESM modules with `.js` suffix in runtime import paths.
- Tabs for indentation. Double quotes for strings.
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
## Commands
- `npm install` - install dependencies.
- `npm test` - run vitest once.
- `npm run typecheck` - strict TypeScript check.
- `npm run check` - type check, biome, and build.
- `npm pack --dry-run` - release package smoke test.
- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test the Codex hook.
## Constraints
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
- Keep Codex `PostToolUse` hook behavior covered by tests.
- Keep `apply_patch` extraction covered by tests.
- `apply_patch` must support Codex `tool_input.command`, raw patch text, and OMO-compatible metadata.
- Hook output must use the stable Codex hook JSON contract.
- Do not expose an MCP server or MCP tool from this plugin.
## Don'ts
- No `git add -A` or `git add .`. Stage only the files you changed.
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
- Do not couple this package back to pi, omo, or senpi internal source paths.
@@ -0,0 +1,33 @@
# Changelog
## Unreleased
### Added
- Restore `write`, `edit`, `multi_edit`, and `multiedit` PostToolUse coverage alongside `apply_patch`.
- Forward Codex `transcript_path` into native comment-checker hook input when available.
- Add package smoke coverage for portable hook entrypoints.
### Changed
- Treat the native checker binary as an optional dependency for unsupported platforms.
- Cap child process stdout/stderr captured from the native checker.
- Run CI on Windows in addition to Ubuntu and macOS.
## [0.1.1] - 2026-05-15
### Changed
- Limit automatic comment checking to successful `apply_patch` hook events.
- Remove the `comment_check` MCP tool and MCP server configuration.
- Update plugin metadata, docs, and contributor guidance to describe hook-only behavior.
## [0.1.0] - 2026-05-15
### Added
- Initial `codex-comment-checker` Codex plugin.
- `PostToolUse` hook for `apply_patch`, `write`, `edit`, and `multiedit` style tool calls.
- Blocking hook feedback when `comment-checker` reports warnings.
- `comment_check` MCP tool for explicit write/edit/multiedit checks.
- Codex plugin manifest, local MCP config, bundled skill, and GitHub repository metadata.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,6 @@
codex-comment-checker
This package ports the pi-comment-checker hook into a Codex plugin repository.
The plugin targets Codex plugin manifests and plugin-bundled lifecycle hooks.
The checker engine is provided by @code-yeongyu/comment-checker.
@@ -0,0 +1,87 @@
# codex-comment-checker
[![ci](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Codex plugin that runs [`@code-yeongyu/comment-checker`](https://github.com/code-yeongyu/go-claude-code-comment-checker) after successful edit-like `PostToolUse` hook calls.
## Behavior
| Case | Result |
|------|--------|
| `apply_patch` succeeds | parses `tool_input.command` and checks added/updated files |
| `write`, `edit`, `multi_edit`, or `multiedit` succeeds | maps the Codex payload to the native checker hook input |
| non-edit tool succeeds | ignored |
| checker exits `2` | returns Codex `PostToolUse` blocking feedback so the model fixes or explains the warning |
| checker binary missing or unavailable on the current platform | emits no hook output |
| checker exits unexpectedly | leaves hook output unchanged |
Deletes are ignored because they cannot introduce new comments.
## Codex Plugin
The plugin ships:
- `.codex-plugin/plugin.json` for Codex plugin discovery.
- `hooks/hooks.json` for the `PostToolUse` hook.
- `skills/comment-checker/SKILL.md` with usage guidance.
The hook command is:
```bash
node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
```
No MCP server or `comment_check` tool is exposed.
## Local Development
```bash
npm install
npm test
npm run typecheck
npm run check
npm pack --dry-run
```
Smoke-test the hook:
```bash
node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json
```
## Local Codex Installation
```bash
bunx lazycodex install
```
The installer builds and copies the plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/0.1.0`, registers the `sisyphuslabs` marketplace from the `lazycodex` Git repository, installs runtime dependencies there, and enables:
```toml
[features]
plugins = true
plugin_hooks = true
[plugins."omo@sisyphuslabs"]
enabled = true
```
## Branch Rules and Releases
- `main` is protected by `.github/branch-ruleset.json`.
- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows.
- Releases are GitHub Releases tagged as `v<semver>`.
- Publishing runs from the `publish` workflow after a GitHub Release is published.
## Privacy
This plugin runs locally. It sends hook input to the optional local `comment-checker` binary when available and does not call a network service by itself.
## License
[MIT](LICENSE).
## Related
- [pi-comment-checker](https://github.com/code-yeongyu/pi-comment-checker) - source extension this Codex plugin ports.
- [comment-checker](https://github.com/code-yeongyu/go-claude-code-comment-checker) - native checker binary.
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
"timeout": 30,
"statusMessage": "LazyCodex(0.1.1): Checking Comments"
}
]
}
]
}
}
@@ -0,0 +1,57 @@
{
"name": "@code-yeongyu/codex-comment-checker",
"version": "0.1.1",
"description": "Codex plugin that runs comment-checker after edit-like PostToolUse hooks.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/codex-comment-checker",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/codex-comment-checker.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/codex-comment-checker/issues"
},
"keywords": [
"codex",
"codex-plugin",
"comment-checker",
"hooks",
"typescript"
],
"bin": {
"omo-comment-checker": "./dist/cli.js"
},
"files": [
"dist",
"hooks",
"skills",
".codex-plugin",
"LICENSE",
"NOTICE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"check": "tsc --noEmit && biome check . && npm run build"
},
"optionalDependencies": {
"@code-yeongyu/comment-checker": "^0.8.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,16 @@
---
name: comment-checker
description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook.
---
# Codex Comment Checker
The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls.
When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on.
## Scope
- No MCP tool is exposed.
- Non-edit tools are ignored by this plugin.
- Missing checker binaries emit no hook output so normal Codex work can continue.
@@ -0,0 +1,12 @@
#!/usr/bin/env node
import { runCodexHookCli } from "./codex-hook.js";
const [command, subcommand] = process.argv.slice(2);
if (command === "hook" && subcommand === "post-tool-use") {
await runCodexHookCli();
} else {
process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n");
process.exitCode = 2;
}
@@ -0,0 +1,205 @@
import { readFileSync } from "node:fs";
import { stdin as processStdin, stdout as processStdout } from "node:process";
import {
type CommentCheckRequest,
extractCommentCheckRequests,
isRecord,
type ToolResultContent,
type ToolResultLike,
toHookInput,
} from "./core.js";
import { type CommentCheckerRunner, runCommentChecker } from "./runner.js";
export type CodexPostToolUseInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "PostToolUse";
model: string;
permission_mode: string;
tool_name: string;
tool_input: Record<string, unknown>;
tool_response: unknown;
tool_use_id: string;
};
export type CodexHookOptions = {
run?: CommentCheckerRunner;
};
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
const CONTEXT_PRESSURE_MARKERS = [
"context compacted",
"context_length_exceeded",
"skill descriptions were shortened",
"context_too_large",
"codex ran out of room in the model's context window",
"your input exceeds the context window",
"long threads and multiple compactions",
] as const;
export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] {
return extractCommentCheckRequests(toToolResultLike(input));
}
export async function runCommentCheckerPostToolUse(
input: CodexPostToolUseInput,
options: CodexHookOptions = {},
): Promise<string> {
const requests = extractCodexCommentCheckRequests(input);
if (requests.length === 0) return "";
const runner = options.run ?? runCommentChecker;
const warnings: Array<{ filePath: string; message: string }> = [];
for (const request of requests) {
const context = {
sessionId: input.session_id,
cwd: input.cwd,
...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }),
};
const result = await runner(toHookInput(request, context));
if (result.status === "missing" || result.status === "pass") continue;
if (result.status === "error") continue;
const message = normalizeHookText(result.message);
if (message.length > 0) {
warnings.push({ filePath: request.filePath, message });
}
}
if (warnings.length === 0) return "";
return JSON.stringify({
decision: "block",
reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)),
});
}
export async function runCodexHookCli(): Promise<void> {
const input = await readStdin();
if (input.trim().length === 0) return;
const parsed = parseCodexPostToolUseInput(input);
if (!parsed) return;
const output = await runCommentCheckerPostToolUse(parsed);
if (output.length > 0) {
processStdout.write(output);
processStdout.write("\n");
}
}
export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return undefined;
}
return isCodexPostToolUseInput(parsed) ? parsed : undefined;
}
function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike {
return {
toolName: input.tool_name,
input: normalizeToolInput(input.tool_name, input.tool_input),
content: normalizeToolResponse(input.tool_response),
isError: isErrorResponse(input.tool_response),
details: isRecord(input.tool_response) ? input.tool_response : undefined,
};
}
function normalizeToolInput(toolName: string, toolInput: Record<string, unknown>): Record<string, unknown> {
if (toolName === "apply_patch" && typeof toolInput["command"] === "string") {
return {
...toolInput,
input: toolInput["command"],
patch: toolInput["command"],
};
}
return toolInput;
}
function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] {
if (typeof toolResponse === "string") {
return [{ type: "text", text: toolResponse }];
}
if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") {
return [{ type: "text", text: toolResponse["text"] }];
}
return [];
}
function isErrorResponse(toolResponse: unknown): boolean {
return isRecord(toolResponse) && toolResponse["is_error"] === true;
}
function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string {
return warnings
.map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`)
.join("\n\n");
}
function normalizeHookText(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
}
function hookFeedbackLimit(transcriptPath: string | null): number {
return isContextPressureTranscript(transcriptPath)
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
}
function isContextPressureTranscript(transcriptPath: string | null): boolean {
if (transcriptPath === null) return false;
try {
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
} catch (error) {
if (error instanceof Error) return false;
throw error;
}
}
function hasContextPressureMarker(text: string): boolean {
const normalizedText = text.toLowerCase();
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
}
function limitHookText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`;
if (marker.length >= maxChars) return marker.slice(0, maxChars);
const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, "");
return `${head}${marker}`;
}
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
return (
isRecord(value) &&
value["hook_event_name"] === "PostToolUse" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["tool_name"] === "string" &&
isRecord(value["tool_input"]) &&
typeof value["tool_use_id"] === "string"
);
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
processStdin.setEncoding("utf-8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", reject);
processStdin.once("end", () => {
resolve(data);
});
});
}
@@ -0,0 +1,361 @@
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image";
data: string;
mimeType: string;
};
export type CheckerToolName = "Write" | "Edit" | "MultiEdit";
export type CheckerEdit = {
old_string: string;
new_string: string;
};
export type CheckerToolInput = {
file_path: string;
content?: string;
old_string?: string;
new_string?: string;
edits?: CheckerEdit[];
};
export type CommentCheckRequest = {
sourceToolName: string;
toolName: CheckerToolName;
filePath: string;
toolInput: CheckerToolInput;
};
export type CommentCheckerHookInput = {
session_id: string;
tool_name: CheckerToolName;
transcript_path: string;
cwd: string;
hook_event_name: "PostToolUse";
tool_input: CheckerToolInput;
};
export type ToolResultContent = TextContent | ImageContent;
export type ToolResultLike = {
toolName: string;
input: Record<string, unknown>;
content?: ToolResultContent[];
isError?: boolean;
details?: unknown;
};
type ApplyPatchAccumulator = {
operation: "add" | "delete" | "update";
filePath: string;
movePath?: string;
oldLines: string[];
newLines: string[];
};
type ApplyPatchFileMetadata = {
filePath: string;
movePath?: string;
before: string;
after: string;
type?: string;
};
export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] {
if (event.isError) return [];
if (isToolFailureOutput(getContentText(event.content))) return [];
const toolName = event.toolName.toLowerCase();
if (toolName === "write") return extractWriteRequest(event);
if (toolName === "edit") return extractEditRequest(event);
if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event);
if (toolName === "apply_patch") return extractApplyPatchRequests(event);
return [];
}
export function toHookInput(
request: CommentCheckRequest,
context: {
sessionId: string;
cwd: string;
transcriptPath?: string;
},
): CommentCheckerHookInput {
return {
session_id: context.sessionId,
tool_name: request.toolName,
transcript_path: context.transcriptPath ?? "",
cwd: context.cwd,
hook_event_name: "PostToolUse",
tool_input: request.toolInput,
};
}
export function isToolFailureOutput(text: string): boolean {
const lower = text.trim().toLowerCase();
return (
lower.startsWith("error") ||
lower.includes("error:") ||
lower.includes("failed to") ||
lower.includes("could not")
);
}
function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const content = getString(event.input, ["content"]);
if (!filePath || content === undefined) return [];
return [
{
sourceToolName: event.toolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content,
},
},
];
}
function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const oldString = getString(event.input, ["oldString", "old_string"]);
const newString = getString(event.input, ["newString", "new_string"]);
if (!filePath || oldString === undefined || newString === undefined) return [];
const toolInput: CheckerToolInput = { file_path: filePath };
toolInput.old_string = oldString;
toolInput.new_string = newString;
return [
{
sourceToolName: event.toolName,
toolName: "Edit",
filePath,
toolInput,
},
];
}
function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const edits = getEdits(event.input["edits"]);
if (!filePath || edits.length === 0) return [];
return [
{
sourceToolName: event.toolName,
toolName: "MultiEdit",
filePath,
toolInput: {
file_path: filePath,
edits,
},
},
];
}
function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] {
const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName);
if (metadataRequests.length > 0) return metadataRequests;
const patch = getString(event.input, ["input", "patch", "command"]);
if (!patch) return [];
return parseApplyPatchRequests(patch, event.toolName);
}
function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] {
const metadataFiles = getApplyPatchMetadataFiles(details);
if (metadataFiles.length === 0) return [];
const requests: CommentCheckRequest[] = [];
for (const file of metadataFiles) {
if (file.type === "delete") continue;
const filePath = file.movePath ?? file.filePath;
if (file.before.length === 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content: file.after,
},
});
continue;
}
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: file.before,
new_string: file.after,
},
});
}
return requests;
}
function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] {
if (!isRecord(details)) return [];
const direct = readApplyPatchMetadataFiles(details["files"]);
if (direct.length > 0) return direct;
const resultDetails = details["result"];
const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : [];
if (result.length > 0) return result;
const metadataDetails = details["metadata"];
const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : [];
return metadata;
}
function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] {
if (!Array.isArray(value)) return [];
const files: ApplyPatchFileMetadata[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const filePath = getString(item, ["filePath", "file_path", "path"]);
const movePath = getString(item, ["movePath", "move_path"]);
const before = getString(item, ["before", "old", "oldString", "old_string"]);
const after = getString(item, ["after", "new", "newString", "new_string"]);
const type = getString(item, ["type", "operation"]);
if (!filePath || before === undefined || after === undefined) continue;
files.push({
filePath,
before,
after,
...(movePath === undefined ? {} : { movePath }),
...(type === undefined ? {} : { type }),
});
}
return files;
}
export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] {
const requests: CommentCheckRequest[] = [];
let current: ApplyPatchAccumulator | undefined;
const flush = (): void => {
if (!current) return;
if (current.operation === "add") {
const content = joinPatchLines(current.newLines);
if (content.length > 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath: current.filePath,
toolInput: {
file_path: current.filePath,
content,
},
});
}
}
if (current.operation === "update") {
const newString = joinPatchLines(current.newLines);
if (newString.length > 0) {
const filePath = current.movePath ?? current.filePath;
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: joinPatchLines(current.oldLines),
new_string: newString,
},
});
}
}
current = undefined;
};
for (const line of patch.split(/\r?\n/)) {
if (line === "*** Begin Patch" || line === "*** End Patch") continue;
if (line.startsWith("*** Add File: ")) {
flush();
current = makeAccumulator("add", line.slice("*** Add File: ".length).trim());
continue;
}
if (line.startsWith("*** Update File: ")) {
flush();
current = makeAccumulator("update", line.slice("*** Update File: ".length).trim());
continue;
}
if (line.startsWith("*** Delete File: ")) {
flush();
current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim());
continue;
}
if (line.startsWith("*** Move to: ")) {
if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim();
continue;
}
if (!current) continue;
if (line.startsWith("@@")) continue;
if (current.operation === "add") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
continue;
}
if (current.operation === "update") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
if (line.startsWith("-")) current.oldLines.push(line.slice(1));
}
}
flush();
return requests;
}
function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator {
return {
operation,
filePath,
oldLines: [],
newLines: [],
};
}
function getEdits(value: unknown): CheckerEdit[] {
if (!Array.isArray(value)) return [];
const edits: CheckerEdit[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const oldString = getString(item, ["oldString", "old_string"]);
const newString = getString(item, ["newString", "new_string"]);
if (oldString === undefined || newString === undefined) continue;
edits.push({
old_string: oldString,
new_string: newString,
});
}
return edits;
}
function getContentText(content: ToolResultContent[] | undefined): string {
if (!content) return "";
return content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
function getString(input: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = input[key];
if (typeof value === "string") return value;
}
return undefined;
}
function joinPatchLines(lines: string[]): string {
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,195 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import type { CommentCheckerHookInput } from "./core.js";
export type ProcessResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024;
export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise<ProcessResult>;
export type RunCommentCheckerOptions = {
binaryPath?: string;
customPrompt?: string;
resolveBinary?: () => string | undefined;
executor?: ProcessExecutor;
};
export type CommentCheckerRunResult = {
status: "pass" | "warning" | "error" | "missing";
message: string;
binaryPath?: string;
exitCode?: number | null;
stdout?: string;
stderr?: string;
};
export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise<CommentCheckerRunResult>;
export async function runCommentChecker(
input: CommentCheckerHookInput,
options: RunCommentCheckerOptions = {},
): Promise<CommentCheckerRunResult> {
const binaryPath =
options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary());
if (!binaryPath) {
return {
status: "missing",
message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.",
};
}
const args = ["check"];
if (options.customPrompt) {
args.push("--prompt", options.customPrompt);
}
const executor = options.executor ?? spawnProcess;
const result = await executor(binaryPath, args, JSON.stringify(input));
const message = result.stderr || result.stdout;
if (result.exitCode === 0) {
return {
status: "pass",
message: "",
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
if (result.exitCode === 2) {
return {
status: "warning",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
return {
status: "error",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
export function resolveCommentCheckerBinary(): string | undefined {
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker";
const fromPackageApi = resolvePackageApiBinary();
if (fromPackageApi) return fromPackageApi;
const fromPackage = resolvePackageBinary(binaryName);
if (fromPackage) return fromPackage;
return undefined;
}
function resolvePackageApiBinary(): string | undefined {
try {
const require = createRequire(import.meta.url);
const packageExports: unknown = require("@code-yeongyu/comment-checker");
if (!isCommentCheckerPackage(packageExports)) return undefined;
const binaryPath = packageExports.getBinaryPath();
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function resolvePackageBinary(binaryName: string): string | undefined {
try {
const require = createRequire(import.meta.url);
const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json");
const binaryPath = join(dirname(packagePath), "bin", binaryName);
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } {
return isRecord(value) && typeof value["getBinaryPath"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
interface OutputAccumulator {
text: string;
bytes: number;
truncated: boolean;
}
function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void {
if (output.truncated) return;
const remainingBytes = maxOutputBytes - output.bytes;
const chunkBytes = Buffer.byteLength(chunk, "utf8");
if (chunkBytes <= remainingBytes) {
output.text += chunk;
output.bytes += chunkBytes;
return;
}
if (remainingBytes > 0) {
output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8");
output.bytes += remainingBytes;
}
output.truncated = true;
}
function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string {
if (!output.truncated) return output.text;
return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`;
}
export function spawnProcess(
command: string,
args: string[],
stdin: string,
maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES,
): Promise<ProcessResult> {
return new Promise((resolve) => {
const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
const proc = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
});
const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false };
const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false };
proc.stdout.setEncoding("utf-8");
proc.stderr.setEncoding("utf-8");
proc.stdout.on("data", (chunk: string) => {
appendOutput(stdout, chunk, outputByteLimit);
});
proc.stderr.on("data", (chunk: string) => {
appendOutput(stderr, chunk, outputByteLimit);
});
proc.once("error", (error) => {
appendOutput(stderr, error.message, outputByteLimit);
resolve({
exitCode: null,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.once("close", (exitCode) => {
resolve({
exitCode,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.stdin.end(stdin);
});
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { type CodexPostToolUseInput, runCommentCheckerPostToolUse } from "../src/codex-hook.ts";
function postToolUseInput(): CodexPostToolUseInput {
return {
session_id: "thread-1",
turn_id: "turn-1",
transcript_path: null,
cwd: "/repo",
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "never",
tool_name: "apply_patch",
tool_input: {
command: [
"*** Begin Patch",
"*** Update File: src/example.ts",
"@@",
"-const value = 1;",
"+// explains value",
"+const value = 2;",
"*** End Patch",
].join("\n"),
},
tool_response: "Success. Updated files.",
tool_use_id: "call-1",
};
}
describe("comment-checker hook newline rendering", () => {
it("#given checker warning with CRLF and bare CR #when hook runs #then returns normalized blocking feedback JSON", async () => {
// given
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "warning",
message: "\r\nfirst warning line\r\n indented detail\rthird warning line\r\n",
}),
});
// when
const parsed: unknown = JSON.parse(output);
// then
expect(parsed).toEqual({
decision: "block",
reason:
"comment-checker found issues in src/example.ts:\nfirst warning line\n indented detail\nthird warning line",
});
expect(output).not.toContain("\r");
});
});
@@ -0,0 +1,368 @@
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
type CodexPostToolUseInput,
extractCodexCommentCheckRequests,
runCommentCheckerPostToolUse,
} from "../src/codex-hook.ts";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function runHookCli(input: string): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", "post-tool-use"], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
function postToolUseInput(overrides: Partial<CodexPostToolUseInput> = {}): CodexPostToolUseInput {
return {
session_id: "thread-1",
turn_id: "turn-1",
transcript_path: null,
cwd: "/repo",
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "never",
tool_name: "apply_patch",
tool_input: {
command: [
"*** Begin Patch",
"*** Update File: src/example.ts",
"@@",
"-const value = 1;",
"+// explains value",
"+const value = 2;",
"*** End Patch",
].join("\n"),
},
tool_response: "Success. Updated files.",
tool_use_id: "call-1",
...overrides,
};
}
describe("extractCodexCommentCheckRequests", () => {
it("#given codex apply_patch command #when extracting #then returns edit request for changed file", () => {
const requests = extractCodexCommentCheckRequests(postToolUseInput());
expect(requests).toEqual([
{
sourceToolName: "apply_patch",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given unsupported post tool event #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "read",
tool_input: { file_path: "src/example.ts", content: "// hi\nconst value = 1;\n" },
}),
);
expect(requests).toEqual([]);
});
it("#given codex write payload #when extracting #then returns write request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "write",
toolName: "Write",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
},
]);
});
it("#given codex edit payload #when extracting #then returns edit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
newString: "// explains value\nconst value = 2;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "edit",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given one-sided codex edit payload #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
},
}),
);
expect(requests).toEqual([]);
});
it("#given codex multi_edit payload #when extracting #then returns multiedit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "multi_edit",
tool_input: {
filePath: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ oldString: "const b = 1;\n", newString: "// explains b\nconst b = 2;\n" },
],
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "multi_edit",
toolName: "MultiEdit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ old_string: "const b = 1;\n", new_string: "// explains b\nconst b = 2;\n" },
],
},
},
]);
});
});
describe("runCommentCheckerPostToolUse", () => {
it("#given checker warning #when hook runs #then returns blocking feedback JSON", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "warning",
message: "comment warning: explain less",
}),
});
expect(JSON.parse(output)).toEqual({
decision: "block",
reason: "comment-checker found issues in src/example.ts:\ncomment warning: explain less",
});
});
it("#given missing checker binary #when hook runs #then emits no hook output", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "missing",
message: "not installed",
}),
});
expect(output).toBe("");
});
it("#given transcript path #when hook runs #then forwards it to checker input", async () => {
let transcriptPath = "";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: "/tmp/codex-comment-checker-transcript.jsonl",
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("/tmp/codex-comment-checker-transcript.jsonl");
});
it("#given null transcript path #when hook runs #then forwards empty string fallback", async () => {
let transcriptPath = "unset";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: null,
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("");
});
it("#given Codex canonical context-window transcript and long checker warning #when hook blocks #then it caps feedback", async () => {
const root = mkdtempSync(path.join(tmpdir(), "codex-comment-checker-context-pressure-"));
tempDirs.push(root);
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
[
"context_length_exceeded",
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
"",
].join("\n"),
);
const output = await runCommentCheckerPostToolUse(postToolUseInput({ transcript_path: transcriptPath }), {
run: async () => ({
status: "warning",
message: `comment warning: explain less\n${"x".repeat(10_000)}`,
}),
});
const parsed: unknown = JSON.parse(output);
if (!isBlockingOutput(parsed)) throw new TypeError("Expected blocking output");
expect(parsed.reason.length).toBeLessThanOrEqual(1200);
expect(parsed.reason).toContain("comment-checker found issues in src/example.ts");
expect(parsed.reason).toContain("[Truncated hook output");
});
});
describe("runCodexHookCli", () => {
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = '"break;"\n';
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-string transcript path #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ ...postToolUseInput(), transcript_path: 42 })}\n`;
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
interface BlockingOutput {
readonly decision: "block";
readonly reason: string;
}
function isBlockingOutput(value: unknown): value is BlockingOutput {
return isRecord(value) && value["decision"] === "block" && typeof value["reason"] === "string";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,15 @@
{
"session_id": "00000000-0000-0000-0000-000000000000",
"turn_id": "00000000-0000-0000-0000-000000000001",
"transcript_path": "/tmp/codex-comment-checker-transcript.jsonl",
"cwd": ".",
"hook_event_name": "PostToolUse",
"model": "gpt-5.5",
"permission_mode": "default",
"tool_name": "apply_patch",
"tool_input": {
"command": "*** Begin Patch\n*** Add File: src/example.ts\n+export const meaning = 42;\n*** End Patch\n"
},
"tool_response": "Success. Updated files.",
"tool_use_id": "toolu_000000000000000000000000"
}
@@ -0,0 +1,93 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies?: Record<string, unknown>;
readonly optionalDependencies: Record<string, string>;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).not.toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.optionalDependencies).toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.bin["omo-comment-checker"]).toBe("./dist/cli.js");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
isStringRecord(value["optionalDependencies"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,66 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
MAX_PROCESS_OUTPUT_BYTES,
resolveCommentCheckerBinary,
runCommentChecker,
spawnProcess,
} from "../src/runner.js";
describe("spawnProcess", () => {
it("#given noisy checker process #when output exceeds cap #then stderr is bounded", async () => {
// given
const maxOutputBytes = 16;
// when
const result = await spawnProcess(
process.execPath,
["-e", "process.stderr.write('x'.repeat(40)); process.exit(2);"],
"",
maxOutputBytes,
);
// then
expect(MAX_PROCESS_OUTPUT_BYTES).toBeGreaterThan(maxOutputBytes);
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe(`${"x".repeat(maxOutputBytes)}\n[stderr truncated after 16 bytes]`);
});
});
describe("resolveCommentCheckerBinary", () => {
it("#given installed checker package #when resolving binary #then returns existing checker binary", () => {
// given / when
const binaryPath = resolveCommentCheckerBinary();
// then
expect(binaryPath).toBeDefined();
expect(binaryPath ?? "").toContain("comment-checker");
expect(existsSync(binaryPath ?? "")).toBe(true);
});
});
describe("runCommentChecker", () => {
it("#given missing checker binary #when runner starts #then returns missing result", async () => {
// given / when
const result = await runCommentChecker(
{
session_id: "session-1",
tool_name: "Write",
transcript_path: "",
cwd: "/repo",
hook_event_name: "PostToolUse",
tool_input: {
file_path: "src/example.ts",
content: "const value = 1;\n",
},
},
{
resolveBinary: () => undefined,
},
);
// then
expect(result.status).toBe("missing");
});
});
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"useDefineForClassFields": false,
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
pool: "threads",
},
});
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1 @@
* @code-yeongyu
@@ -0,0 +1,26 @@
name: Bug report
description: Report a reproducible codex-lsp bug.
title: "[bug]: "
labels: ["bug"]
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Exact steps, config, command output, and affected language server.
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: 0.1.0
validations:
required: true
@@ -0,0 +1,19 @@
name: Feature request
description: Propose a focused codex-lsp improvement.
title: "[feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow should improve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-lsp do?
validations:
required: true
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
@@ -0,0 +1,11 @@
## Summary
-
## Validation
-
## Notes
-
@@ -0,0 +1,56 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Bootstrap lsp-tools-mcp submodule
shell: bash
run: |
cd packages/lsp-tools-mcp
npm ci
npm run build
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,60 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Bootstrap lsp-tools-mcp submodule
shell: bash
run: |
cd packages/lsp-tools-mcp
npm ci
npm run build
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,6 @@
node_modules/
*.log
.env
.DS_Store
coverage/
.vitest/
@@ -0,0 +1,9 @@
{
"mcpServers": {
"lsp": {
"command": "node",
"args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"],
"cwd": "."
}
}
}
@@ -0,0 +1,25 @@
# Repository Conventions
Conventions for humans and agents working on this repository.
## Style
- TypeScript strict mode. No `any`, `@ts-ignore`, `@ts-expect-error`, or enums.
- ESM modules with `.js` suffix in import paths.
- Tabs for indentation. Double quotes for strings.
- Runtime is Node only.
- Tests use vitest and should exercise Codex hook/MCP behavior before implementation changes.
## Commands
- `npm install` installs dependencies.
- `npm test` runs the test suite once.
- `npm run typecheck` runs strict TypeScript checking.
- `npm run check` runs typecheck, Biome, and build.
## LSP Constraints
- LSP server processes are owned by `LspManager`.
- Tool execution acquires clients through `withLspClient(...)` unless it only reports static status.
- `lsp.rename` mutates files by applying workspace edits; keep it sequential at the MCP caller level.
- Do not add pi-coding-agent or omo source dependencies. This package is standalone.
@@ -0,0 +1,25 @@
# Changelog
## Unreleased
- Reuse the repository-level `packages/lsp-tools-mcp` package instead of carrying a second copy under `components/lsp/packages`.
## 0.2.0
- Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp).
- codex-lsp now consumes that runtime as a git submodule at `packages/lsp-tools-mcp`.
- Kept the Codex-specific PostToolUse hook in this package and routed MCP serving through the upstream CLI.
- Extract LSP runtime to `lsp-tools-mcp` upstream and consume it via git submodule at `packages/lsp-tools-mcp`.
- Renamed the MCP server namespace to `lsp` and exposed shorter tool names such as `lsp.diagnostics`.
- Use portable Codex hook interpolation and add package smoke coverage for hook/MCP entrypoints.
- Spawn language servers without shell mode; Windows `.cmd` and `.bat` shims are routed through `cmd.exe` with explicit arguments.
- Cap directory diagnostics file traversal and run CI on Windows in addition to Ubuntu and macOS.
- Replace the external JSON-RPC runtime dependency with an internal LSP framing layer so clean Codex plugin installs run without `node_modules`.
## 0.1.0
- Ported the standalone LSP client, server resolution, diagnostics aggregation, and workspace edit runtime from `pi-lsp-client`.
- Added Codex `PostToolUse` diagnostics for edit-style tools.
- Added MCP tools for status, diagnostics, definitions, references, symbols, prepare rename, and rename.
- Added Codex plugin metadata, skill docs, CI, and release automation.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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