Compare commits
76 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad98e88ec | ||
|
|
e264cd5078 | ||
|
|
0230e71bc6 | ||
|
|
c9f762f980 | ||
|
|
f658544cd6 | ||
|
|
396043a122 | ||
|
|
9854e9f6e5 | ||
|
|
48167a6920 | ||
|
|
207a39b17a | ||
|
|
5de3d4fb7d | ||
|
|
7a9e604b2d | ||
|
|
6670754efe | ||
|
|
37d4aec4d0 | ||
|
|
c38b078c12 | ||
|
|
5e44996746 | ||
|
|
9a152bcebb | ||
|
|
c67ca8275e | ||
|
|
72a3975799 | ||
|
|
747d824cbf | ||
|
|
b8a8cc95e2 | ||
|
|
96630bb0ee | ||
|
|
15e3e16bf2 | ||
|
|
68699330b8 | ||
|
|
49384fa804 | ||
|
|
b056e775f5 | ||
|
|
9bed597e46 | ||
|
|
74f355322a | ||
|
|
1ea304513c | ||
|
|
e925ed0009 | ||
|
|
fc5c2baac0 | ||
|
|
abc4a34ce4 | ||
|
|
d6499cbe31 | ||
|
|
a38dc28e40 | ||
|
|
89fa9ff167 | ||
|
|
4c22d6de76 | ||
|
|
1dd369fda5 | ||
|
|
84e97ba900 | ||
|
|
ef65f405e8 | ||
|
|
3de559ff87 | ||
|
|
acb16bcb27 | ||
|
|
9995b680f7 | ||
|
|
41fa37eb11 | ||
|
|
70bca4a7a6 | ||
|
|
b1f19cbfbd | ||
|
|
8395a6eaac | ||
|
|
abd1ec1092 | ||
|
|
5a8d9f09d9 | ||
|
|
2c4730f094 | ||
|
|
951df07c0f | ||
|
|
4c49299a93 | ||
|
|
00508e9959 | ||
|
|
c9ef648c60 | ||
|
|
8a9ebe1012 | ||
|
|
014bdaeec2 | ||
|
|
570b51d07b | ||
|
|
a91b05d9c6 | ||
|
|
4a892a9809 | ||
|
|
4d4966362f | ||
|
|
0c21c72e05 | ||
|
|
caf50fc4c9 | ||
|
|
3801e42ccb | ||
|
|
306dab41ad | ||
|
|
9f040e020f | ||
|
|
25dbcfe200 | ||
|
|
47a641c415 | ||
|
|
5c4f4fc655 | ||
|
|
4d966ec99b | ||
|
|
5d99e9ab64 | ||
|
|
129388387b | ||
|
|
c196db2a0e | ||
|
|
e6a572824c | ||
|
|
4d76f37bfe | ||
|
|
75eb82ea32 | ||
|
|
4a722df8be | ||
|
|
1a5fdb3338 | ||
|
|
c29e6f0213 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [master, dev]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [dev]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
14
.github/workflows/publish.yml
vendored
14
.github/workflows/publish.yml
vendored
@@ -77,6 +77,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
@@ -109,9 +110,12 @@ jobs:
|
||||
echo "=== Running bun build (CLI) ==="
|
||||
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi
|
||||
echo "=== Running tsc ==="
|
||||
tsc --emitDeclarationOnly
|
||||
bunx tsc --emitDeclarationOnly
|
||||
echo "=== Running build:schema ==="
|
||||
bun run build:schema
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun run build:binaries
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
@@ -121,6 +125,13 @@ jobs:
|
||||
ls -la dist/cli/
|
||||
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
|
||||
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
|
||||
echo "=== Platform binaries ==="
|
||||
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl; do
|
||||
test -f "packages/${platform}/bin/oh-my-opencode" || (echo "ERROR: packages/${platform}/bin/oh-my-opencode not found!" && exit 1)
|
||||
echo "✓ packages/${platform}/bin/oh-my-opencode"
|
||||
done
|
||||
test -f "packages/windows-x64/bin/oh-my-opencode.exe" || (echo "ERROR: packages/windows-x64/bin/oh-my-opencode.exe not found!" && exit 1)
|
||||
echo "✓ packages/windows-x64/bin/oh-my-opencode.exe"
|
||||
|
||||
- name: Publish
|
||||
run: bun run script/publish.ts
|
||||
@@ -130,6 +141,7 @@ jobs:
|
||||
CI: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
SKIP_PLATFORM_PACKAGES: true
|
||||
|
||||
- name: Delete draft release
|
||||
run: gh release delete next --yes 2>/dev/null || echo "No draft release to delete"
|
||||
|
||||
2
.github/workflows/sisyphus-agent.yml
vendored
2
.github/workflows/sisyphus-agent.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
opencode --version
|
||||
|
||||
# Run local oh-my-opencode install (uses built dist)
|
||||
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
|
||||
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no --copilot=no
|
||||
|
||||
# Override plugin to use local file reference
|
||||
OPENCODE_JSON=~/.config/opencode/opencode.json
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,6 +5,10 @@ node_modules/
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Platform binaries (built, not committed)
|
||||
packages/*/bin/oh-my-opencode
|
||||
packages/*/bin/oh-my-opencode.exe
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-13T14:45:00+09:00
|
||||
**Commit:** e47b5514
|
||||
**Generated:** 2026-01-15T14:53:00+09:00
|
||||
**Commit:** 89fa9ff1
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -13,16 +13,15 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (7+): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker, prometheus, metis, momus
|
||||
│ ├── agents/ # AI agents (10+): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker, prometheus, metis, momus
|
||||
│ ├── hooks/ # 22+ lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, session mgmt - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, grep_app, websearch
|
||||
│ ├── config/ # Zod schema (12k lines), TypeScript types
|
||||
│ └── index.ts # Main plugin entry (563 lines)
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (580 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
@@ -39,7 +38,6 @@ oh-my-opencode/
|
||||
| Add skill | `src/features/builtin-skills/` | Create skill dir with SKILL.md |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google/Gemini models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
@@ -50,7 +48,7 @@ oh-my-opencode/
|
||||
| Shared utilities | `src/shared/` | Cross-cutting utilities |
|
||||
| Slash commands | `src/hooks/auto-slash-command/` | Auto-detect and execute `/command` patterns |
|
||||
| Ralph Loop | `src/hooks/ralph-loop/` | Self-referential dev loop until completion |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (677 lines) |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (684 lines) |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
@@ -83,7 +81,7 @@ oh-my-opencode/
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given/#when/#then`, TDD workflow (RED-GREEN-REFACTOR), 82 test files
|
||||
- **Testing**: BDD comments `#given/#when/#then`, TDD workflow (RED-GREEN-REFACTOR), 80+ test files
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
@@ -140,7 +138,7 @@ bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun run build:schema # Schema only
|
||||
bun test # Run tests (82 test files, 2559+ BDD assertions)
|
||||
bun test # Run tests (80+ test files, 2500+ BDD assertions)
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -157,26 +155,23 @@ bun test # Run tests (82 test files, 2559+ BDD assertions)
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
- **sisyphus-agent.yml**: Agent-in-CI for automated issue handling via `@sisyphus-dev-ai` mentions
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1486 | Orchestrator agent, 7-section delegation, accumulated wisdom |
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1485 | Orchestrator agent, 7-section delegation, accumulated wisdom |
|
||||
| `src/features/builtin-skills/skills.ts` | 1230 | Skill definitions (frontend-ui-ux, playwright) |
|
||||
| `src/agents/prometheus-prompt.ts` | 988 | Planning agent, interview mode, multi-agent validation |
|
||||
| `src/auth/antigravity/fetch.ts` | 798 | Token refresh, multi-account rotation, endpoint fallback |
|
||||
| `src/auth/antigravity/thinking.ts` | 755 | Thinking block extraction, signature management |
|
||||
| `src/cli/config-manager.ts` | 725 | JSONC parsing, multi-level config, env detection |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 677 | Orchestrator hook impl |
|
||||
| `src/agents/prometheus-prompt.ts` | 991 | Planning agent, interview mode, multi-agent validation |
|
||||
| `src/features/background-agent/manager.ts` | 928 | Task lifecycle, concurrency |
|
||||
| `src/cli/config-manager.ts` | 730 | JSONC parsing, multi-level config, env detection |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 684 | Orchestrator hook impl |
|
||||
| `src/tools/sisyphus-task/tools.ts` | 667 | Category-based task delegation |
|
||||
| `src/agents/sisyphus.ts` | 643 | Main Sisyphus prompt |
|
||||
| `src/tools/lsp/client.ts` | 632 | LSP protocol, JSON-RPC |
|
||||
| `src/features/background-agent/manager.ts` | 825 | Task lifecycle, concurrency |
|
||||
| `src/auth/antigravity/response.ts` | 598 | Response transformation, streaming |
|
||||
| `src/tools/sisyphus-task/tools.ts` | 583 | Category-based task delegation |
|
||||
| `src/index.ts` | 563 | Main plugin, all hook/tool init |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 555 | Multi-stage recovery |
|
||||
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactoring command template |
|
||||
| `src/index.ts` | 580 | Main plugin, all hook/tool init |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
@@ -187,14 +182,14 @@ Three-tier MCP system:
|
||||
|
||||
## CONFIG SYSTEM
|
||||
|
||||
- **Zod validation**: `src/config/schema.ts` (12k lines)
|
||||
- **Zod validation**: `src/config/schema.ts`
|
||||
- **JSONC support**: Comments and trailing commas
|
||||
- **Multi-level**: User (`~/.config/opencode/`) → Project (`.opencode/`)
|
||||
- **CLI doctor**: Validates config and reports errors
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 82 test files
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 80+ test files
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
|
||||
12
README.md
12
README.md
@@ -5,8 +5,8 @@
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> [](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.1)
|
||||
> > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.6` to install it.**
|
||||
> [](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.7)
|
||||
> > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.7` to install it.**
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
@@ -261,12 +261,14 @@ If you don't want all this, as mentioned, you can just pick and choose specific
|
||||
Run the interactive installer:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# or use npx if bunx doesn't work
|
||||
npx oh-my-opencode install
|
||||
# or with bun
|
||||
bunx oh-my-opencode install
|
||||
```
|
||||
|
||||
> **Note for Ubuntu/Debian users**: If you installed Bun via Snap (`/snap/bin/bun`), `bunx` will fail with "script not found" due to Snap's sandboxing. Either use `npx` instead, or reinstall Bun via the official installer: `curl -fsSL https://bun.sh/install | bash`
|
||||
> **Note**: The CLI ships with standalone binaries for all major platforms. No runtime (Bun/Node.js) is required for CLI execution after installation.
|
||||
>
|
||||
> **Supported platforms**: macOS (ARM64, x64), Linux (x64, ARM64, Alpine/musl), Windows (x64)
|
||||
|
||||
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
|
||||
|
||||
|
||||
@@ -252,6 +252,11 @@
|
||||
|
||||
### 面向人类用户
|
||||
|
||||
> **⚠️ 先决条件:需要安装 Bun**
|
||||
>
|
||||
> 此工具**需要系统中已安装 [Bun](https://bun.sh/)** 才能运行。
|
||||
> 即使使用 `npx` 运行安装程序,底层运行时仍依赖于 Bun。
|
||||
|
||||
运行交互式安装程序:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
"sisyphus-task-retry",
|
||||
"prometheus-md-only",
|
||||
"start-work",
|
||||
"sisyphus-orchestrator"
|
||||
|
||||
80
bin/oh-my-opencode.js
Normal file
80
bin/oh-my-opencode.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// bin/oh-my-opencode.js
|
||||
// Wrapper script that detects platform and spawns the correct binary
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Detect libc family on Linux
|
||||
* @returns {string | null} 'glibc', 'musl', or null if detection fails
|
||||
*/
|
||||
function getLibcFamily() {
|
||||
if (process.platform !== "linux") {
|
||||
return undefined; // Not needed on non-Linux
|
||||
}
|
||||
|
||||
try {
|
||||
const detectLibc = require("detect-libc");
|
||||
return detectLibc.familySync();
|
||||
} catch {
|
||||
// detect-libc not available
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
|
||||
// Get platform package name
|
||||
let pkg;
|
||||
try {
|
||||
pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
} catch (error) {
|
||||
console.error(`\noh-my-opencode: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve binary path
|
||||
const binRelPath = getBinaryPath(pkg, platform);
|
||||
|
||||
let binPath;
|
||||
try {
|
||||
binPath = require.resolve(binRelPath);
|
||||
} catch {
|
||||
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
||||
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
||||
console.error(`Expected package: ${pkg}`);
|
||||
console.error(`\nTo fix, run:`);
|
||||
console.error(` npm install ${pkg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Spawn the binary
|
||||
const result = spawnSync(binPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
if (result.error) {
|
||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||
console.error(`Error: ${result.error.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Handle signals
|
||||
if (result.signal) {
|
||||
const signalNum = result.signal === "SIGTERM" ? 15 :
|
||||
result.signal === "SIGKILL" ? 9 :
|
||||
result.signal === "SIGINT" ? 2 : 1;
|
||||
process.exit(128 + signalNum);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
main();
|
||||
38
bin/platform.js
Normal file
38
bin/platform.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// bin/platform.js
|
||||
// Shared platform detection module - used by wrapper and postinstall
|
||||
|
||||
/**
|
||||
* Get the platform-specific package name
|
||||
* @param {{ platform: string, arch: string, libcFamily?: string | null }} options
|
||||
* @returns {string} Package name like "oh-my-opencode-darwin-arm64"
|
||||
* @throws {Error} If libc cannot be detected on Linux
|
||||
*/
|
||||
export function getPlatformPackage({ platform, arch, libcFamily }) {
|
||||
let suffix = "";
|
||||
if (platform === "linux") {
|
||||
if (libcFamily === null || libcFamily === undefined) {
|
||||
throw new Error(
|
||||
"Could not detect libc on Linux. " +
|
||||
"Please ensure detect-libc is installed or report this issue."
|
||||
);
|
||||
}
|
||||
if (libcFamily === "musl") {
|
||||
suffix = "-musl";
|
||||
}
|
||||
}
|
||||
|
||||
// Map platform names: win32 -> windows (for package name)
|
||||
const os = platform === "win32" ? "windows" : platform;
|
||||
return `oh-my-opencode-${os}-${arch}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the binary within a platform package
|
||||
* @param {string} pkg Package name
|
||||
* @param {string} platform Process platform
|
||||
* @returns {string} Relative path like "oh-my-opencode-darwin-arm64/bin/oh-my-opencode"
|
||||
*/
|
||||
export function getBinaryPath(pkg, platform) {
|
||||
const ext = platform === "win32" ? ".exe" : "";
|
||||
return `${pkg}/bin/oh-my-opencode${ext}`;
|
||||
}
|
||||
148
bin/platform.test.ts
Normal file
148
bin/platform.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// bin/platform.test.ts
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
|
||||
describe("getPlatformPackage", () => {
|
||||
// #region Darwin platforms
|
||||
test("returns darwin-arm64 for macOS ARM64", () => {
|
||||
// #given macOS ARM64 platform
|
||||
const input = { platform: "darwin", arch: "arm64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-darwin-arm64");
|
||||
});
|
||||
|
||||
test("returns darwin-x64 for macOS Intel", () => {
|
||||
// #given macOS x64 platform
|
||||
const input = { platform: "darwin", arch: "x64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-darwin-x64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Linux glibc platforms
|
||||
test("returns linux-x64 for Linux x64 with glibc", () => {
|
||||
// #given Linux x64 with glibc
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-linux-x64");
|
||||
});
|
||||
|
||||
test("returns linux-arm64 for Linux ARM64 with glibc", () => {
|
||||
// #given Linux ARM64 with glibc
|
||||
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-linux-arm64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Linux musl platforms
|
||||
test("returns linux-x64-musl for Alpine x64", () => {
|
||||
// #given Linux x64 with musl (Alpine)
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with musl suffix
|
||||
expect(result).toBe("oh-my-opencode-linux-x64-musl");
|
||||
});
|
||||
|
||||
test("returns linux-arm64-musl for Alpine ARM64", () => {
|
||||
// #given Linux ARM64 with musl (Alpine)
|
||||
const input = { platform: "linux", arch: "arm64", libcFamily: "musl" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with musl suffix
|
||||
expect(result).toBe("oh-my-opencode-linux-arm64-musl");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Windows platform
|
||||
test("returns windows-x64 for Windows", () => {
|
||||
// #given Windows x64 platform (win32 is Node's platform name)
|
||||
const input = { platform: "win32", arch: "x64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with 'windows' not 'win32'
|
||||
expect(result).toBe("oh-my-opencode-windows-x64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Error cases
|
||||
test("throws error for Linux with null libcFamily", () => {
|
||||
// #given Linux platform with null libc detection
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: null };
|
||||
|
||||
// #when getting platform package
|
||||
// #then throws descriptive error
|
||||
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
|
||||
});
|
||||
|
||||
test("throws error for Linux with undefined libcFamily", () => {
|
||||
// #given Linux platform with undefined libc
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: undefined };
|
||||
|
||||
// #when getting platform package
|
||||
// #then throws descriptive error
|
||||
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
|
||||
describe("getBinaryPath", () => {
|
||||
test("returns path without .exe for Unix platforms", () => {
|
||||
// #given Unix platform package
|
||||
const pkg = "oh-my-opencode-darwin-arm64";
|
||||
const platform = "darwin";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path without extension
|
||||
expect(result).toBe("oh-my-opencode-darwin-arm64/bin/oh-my-opencode");
|
||||
});
|
||||
|
||||
test("returns path with .exe for Windows", () => {
|
||||
// #given Windows platform package
|
||||
const pkg = "oh-my-opencode-windows-x64";
|
||||
const platform = "win32";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path with .exe extension
|
||||
expect(result).toBe("oh-my-opencode-windows-x64/bin/oh-my-opencode.exe");
|
||||
});
|
||||
|
||||
test("returns path without .exe for Linux", () => {
|
||||
// #given Linux platform package
|
||||
const pkg = "oh-my-opencode-linux-x64";
|
||||
const platform = "linux";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path without extension
|
||||
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
||||
});
|
||||
});
|
||||
10
bun.lock
10
bun.lock
@@ -14,6 +14,7 @@
|
||||
"@opencode-ai/plugin": "^1.1.19",
|
||||
"@opencode-ai/sdk": "^1.1.19",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
@@ -29,6 +30,15 @@
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "0.0.0",
|
||||
"oh-my-opencode-darwin-x64": "0.0.0",
|
||||
"oh-my-opencode-linux-arm64": "0.0.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "0.0.0",
|
||||
"oh-my-opencode-linux-x64": "0.0.0",
|
||||
"oh-my-opencode-linux-x64-musl": "0.0.0",
|
||||
"oh-my-opencode-windows-x64": "0.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
|
||||
21
package.json
21
package.json
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.0.0-beta.7",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"oh-my-opencode": "./dist/cli/index.js"
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"bin",
|
||||
"postinstall.mjs"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -20,8 +22,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
||||
"build:all": "bun run build && bun run build:binaries",
|
||||
"build:binaries": "bun run script/build-binaries.ts",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"postinstall": "node postinstall.mjs",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
@@ -55,6 +60,7 @@
|
||||
"@opencode-ai/plugin": "^1.1.19",
|
||||
"@opencode-ai/sdk": "^1.1.19",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
@@ -70,6 +76,15 @@
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-darwin-x64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-arm64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-x64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.8",
|
||||
"oh-my-opencode-windows-x64": "3.0.0-beta.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi",
|
||||
|
||||
0
packages/darwin-arm64/bin/.gitkeep
Normal file
0
packages/darwin-arm64/bin/.gitkeep
Normal file
16
packages/darwin-arm64/package.json
Normal file
16
packages/darwin-arm64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/darwin-x64/bin/.gitkeep
Normal file
0
packages/darwin-x64/bin/.gitkeep
Normal file
16
packages/darwin-x64/package.json
Normal file
16
packages/darwin-x64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-arm64-musl/bin/.gitkeep
Normal file
0
packages/linux-arm64-musl/bin/.gitkeep
Normal file
17
packages/linux-arm64-musl/package.json
Normal file
17
packages/linux-arm64-musl/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"libc": ["musl"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-arm64/bin/.gitkeep
Normal file
0
packages/linux-arm64/bin/.gitkeep
Normal file
17
packages/linux-arm64/package.json
Normal file
17
packages/linux-arm64/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"libc": ["glibc"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-x64-musl/bin/.gitkeep
Normal file
0
packages/linux-x64-musl/bin/.gitkeep
Normal file
17
packages/linux-x64-musl/package.json
Normal file
17
packages/linux-x64-musl/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"libc": ["musl"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-x64/bin/.gitkeep
Normal file
0
packages/linux-x64/bin/.gitkeep
Normal file
17
packages/linux-x64/package.json
Normal file
17
packages/linux-x64/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"libc": ["glibc"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/windows-x64/bin/.gitkeep
Normal file
0
packages/windows-x64/bin/.gitkeep
Normal file
16
packages/windows-x64/package.json
Normal file
16
packages/windows-x64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.exe"
|
||||
}
|
||||
}
|
||||
43
postinstall.mjs
Normal file
43
postinstall.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// postinstall.mjs
|
||||
// Runs after npm install to verify platform binary is available
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Detect libc family on Linux
|
||||
*/
|
||||
function getLibcFamily() {
|
||||
if (process.platform !== "linux") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const detectLibc = require("detect-libc");
|
||||
return detectLibc.familySync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
|
||||
try {
|
||||
const pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
const binPath = getBinaryPath(pkg, platform);
|
||||
|
||||
// Try to resolve the binary
|
||||
require.resolve(binPath);
|
||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
||||
console.warn(` The CLI may not work on this platform.`);
|
||||
// Don't fail installation - let user try anyway
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
103
script/build-binaries.ts
Normal file
103
script/build-binaries.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bun
|
||||
// script/build-binaries.ts
|
||||
// Build platform-specific binaries for CLI distribution
|
||||
|
||||
import { $ } from "bun";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
interface PlatformTarget {
|
||||
dir: string;
|
||||
target: string;
|
||||
binary: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: PlatformTarget[] = [
|
||||
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
|
||||
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
|
||||
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
|
||||
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
|
||||
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
|
||||
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
|
||||
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
|
||||
];
|
||||
|
||||
const ENTRY_POINT = "src/cli/index.ts";
|
||||
|
||||
async function buildPlatform(platform: PlatformTarget): Promise<boolean> {
|
||||
const outfile = join("packages", platform.dir, "bin", platform.binary);
|
||||
|
||||
console.log(`\n📦 Building ${platform.description}...`);
|
||||
console.log(` Target: ${platform.target}`);
|
||||
console.log(` Output: ${outfile}`);
|
||||
|
||||
try {
|
||||
await $`bun build --compile --minify --sourcemap --bytecode --target=${platform.target} ${ENTRY_POINT} --outfile=${outfile}`;
|
||||
|
||||
// Verify binary exists
|
||||
if (!existsSync(outfile)) {
|
||||
console.error(` ❌ Binary not found after build: ${outfile}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify binary with file command (skip on Windows host for non-Windows targets)
|
||||
if (process.platform !== "win32") {
|
||||
const fileInfo = await $`file ${outfile}`.text();
|
||||
console.log(` ✓ ${fileInfo.trim()}`);
|
||||
} else {
|
||||
console.log(` ✓ Binary created successfully`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` ❌ Build failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔨 Building oh-my-opencode platform binaries");
|
||||
console.log(` Entry point: ${ENTRY_POINT}`);
|
||||
console.log(` Platforms: ${PLATFORMS.length}`);
|
||||
|
||||
// Verify entry point exists
|
||||
if (!existsSync(ENTRY_POINT)) {
|
||||
console.error(`\n❌ Entry point not found: ${ENTRY_POINT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results: { platform: string; success: boolean }[] = [];
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const success = await buildPlatform(platform);
|
||||
results.push({ platform: platform.description, success });
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("Build Summary:");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
for (const result of results) {
|
||||
const icon = result.success ? "✓" : "✗";
|
||||
console.log(` ${icon} ${result.platform}`);
|
||||
}
|
||||
|
||||
console.log("=".repeat(50));
|
||||
console.log(`Total: ${succeeded} succeeded, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n✅ All platform binaries built successfully!\n");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const bump = process.env.BUMP as "major" | "minor" | "patch" | undefined
|
||||
const versionOverride = process.env.VERSION
|
||||
|
||||
console.log("=== Publishing oh-my-opencode ===\n")
|
||||
const PLATFORM_PACKAGES = [
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64-musl",
|
||||
"linux-arm64-musl",
|
||||
"windows-x64",
|
||||
]
|
||||
|
||||
console.log("=== Publishing oh-my-opencode (multi-package) ===\n")
|
||||
|
||||
async function fetchPreviousVersion(): Promise<string> {
|
||||
try {
|
||||
@@ -22,7 +34,9 @@ async function fetchPreviousVersion(): Promise<string> {
|
||||
}
|
||||
|
||||
function bumpVersion(version: string, type: "major" | "minor" | "patch"): string {
|
||||
const [major, minor, patch] = version.split(".").map(Number)
|
||||
// Handle prerelease versions (e.g., 3.0.0-beta.7)
|
||||
const baseVersion = version.split("-")[0]
|
||||
const [major, minor, patch] = baseVersion.split(".").map(Number)
|
||||
switch (type) {
|
||||
case "major":
|
||||
return `${major + 1}.0.0`
|
||||
@@ -33,14 +47,42 @@ function bumpVersion(version: string, type: "major" | "minor" | "patch"): string
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePackageVersion(newVersion: string): Promise<void> {
|
||||
const pkgPath = new URL("../package.json", import.meta.url).pathname
|
||||
async function updatePackageVersion(pkgPath: string, newVersion: string): Promise<void> {
|
||||
let pkg = await Bun.file(pkgPath).text()
|
||||
pkg = pkg.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`)
|
||||
await Bun.file(pkgPath).write(pkg)
|
||||
await Bun.write(pkgPath, pkg)
|
||||
console.log(`Updated: ${pkgPath}`)
|
||||
}
|
||||
|
||||
async function updateAllPackageVersions(newVersion: string): Promise<void> {
|
||||
console.log("\nSyncing version across all packages...")
|
||||
|
||||
// Update main package.json
|
||||
const mainPkgPath = new URL("../package.json", import.meta.url).pathname
|
||||
await updatePackageVersion(mainPkgPath, newVersion)
|
||||
|
||||
// Update optionalDependencies versions in main package.json
|
||||
let mainPkg = await Bun.file(mainPkgPath).text()
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgName = `oh-my-opencode-${platform}`
|
||||
mainPkg = mainPkg.replace(
|
||||
new RegExp(`"${pkgName}": "[^"]+"`),
|
||||
`"${pkgName}": "${newVersion}"`
|
||||
)
|
||||
}
|
||||
await Bun.write(mainPkgPath, mainPkg)
|
||||
|
||||
// Update each platform package.json
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgPath = new URL(`../packages/${platform}/package.json`, import.meta.url).pathname
|
||||
if (existsSync(pkgPath)) {
|
||||
await updatePackageVersion(pkgPath, newVersion)
|
||||
} else {
|
||||
console.warn(`Warning: ${pkgPath} not found`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateChangelog(previous: string): Promise<string[]> {
|
||||
const notes: string[] = []
|
||||
|
||||
@@ -113,28 +155,101 @@ function getDistTag(version: string): string | null {
|
||||
return tag || "next"
|
||||
}
|
||||
|
||||
async function buildAndPublish(version: string): Promise<void> {
|
||||
console.log("\nBuilding before publish...")
|
||||
await $`bun run clean && bun run build`
|
||||
interface PublishResult {
|
||||
success: boolean
|
||||
alreadyPublished?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
console.log("\nPublishing to npm...")
|
||||
const distTag = getDistTag(version)
|
||||
async function publishPackage(cwd: string, distTag: string | null): Promise<PublishResult> {
|
||||
const tagArgs = distTag ? ["--tag", distTag] : []
|
||||
const provenanceArgs = process.env.CI ? ["--provenance"] : []
|
||||
|
||||
if (process.env.CI) {
|
||||
await $`npm publish --access public --provenance --ignore-scripts ${tagArgs}`
|
||||
} else {
|
||||
await $`npm publish --access public --ignore-scripts ${tagArgs}`
|
||||
try {
|
||||
await $`npm publish --access public --ignore-scripts ${provenanceArgs} ${tagArgs}`.cwd(cwd)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
const stderr = error?.stderr?.toString() || error?.message || ""
|
||||
|
||||
// E409 = version already exists (idempotent success)
|
||||
if (
|
||||
stderr.includes("EPUBLISHCONFLICT") ||
|
||||
stderr.includes("E409") ||
|
||||
stderr.includes("cannot publish over") ||
|
||||
stderr.includes("already exists")
|
||||
) {
|
||||
return { success: true, alreadyPublished: true }
|
||||
}
|
||||
|
||||
return { success: false, error: stderr }
|
||||
}
|
||||
}
|
||||
|
||||
async function publishAllPackages(version: string): Promise<void> {
|
||||
const distTag = getDistTag(version)
|
||||
const skipPlatform = process.env.SKIP_PLATFORM_PACKAGES === "true"
|
||||
|
||||
if (skipPlatform) {
|
||||
console.log("\n⏭️ Skipping platform packages (SKIP_PLATFORM_PACKAGES=true)")
|
||||
} else {
|
||||
console.log("\n📦 Publishing platform packages...")
|
||||
|
||||
// Publish platform packages first
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgDir = join(process.cwd(), "packages", platform)
|
||||
const pkgName = `oh-my-opencode-${platform}`
|
||||
|
||||
console.log(`\n Publishing ${pkgName}...`)
|
||||
const result = await publishPackage(pkgDir, distTag)
|
||||
|
||||
if (result.success) {
|
||||
if (result.alreadyPublished) {
|
||||
console.log(` ✓ ${pkgName}@${version} (already published)`)
|
||||
} else {
|
||||
console.log(` ✓ ${pkgName}@${version}`)
|
||||
}
|
||||
} else {
|
||||
console.error(` ✗ ${pkgName} failed: ${result.error}`)
|
||||
throw new Error(`Failed to publish ${pkgName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish main package last
|
||||
console.log(`\n📦 Publishing main package...`)
|
||||
const mainResult = await publishPackage(process.cwd(), distTag)
|
||||
|
||||
if (mainResult.success) {
|
||||
if (mainResult.alreadyPublished) {
|
||||
console.log(` ✓ ${PACKAGE_NAME}@${version} (already published)`)
|
||||
} else {
|
||||
console.log(` ✓ ${PACKAGE_NAME}@${version}`)
|
||||
}
|
||||
} else {
|
||||
console.error(` ✗ ${PACKAGE_NAME} failed: ${mainResult.error}`)
|
||||
throw new Error(`Failed to publish ${PACKAGE_NAME}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPackages(): Promise<void> {
|
||||
console.log("\nBuilding packages...")
|
||||
await $`bun run clean && bun run build`
|
||||
console.log("Building platform binaries...")
|
||||
await $`bun run build:binaries`
|
||||
}
|
||||
|
||||
async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> {
|
||||
if (!process.env.CI) return
|
||||
|
||||
console.log("\nCommitting and tagging...")
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
|
||||
await $`git config user.name "github-actions[bot]"`
|
||||
|
||||
// Add all package.json files
|
||||
await $`git add package.json assets/oh-my-opencode.schema.json`
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
await $`git add packages/${platform}/package.json`.nothrow()
|
||||
}
|
||||
|
||||
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
|
||||
if (hasStagedChanges.exitCode !== 0) {
|
||||
@@ -181,15 +296,16 @@ async function main() {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await updatePackageVersion(newVersion)
|
||||
await updateAllPackageVersions(newVersion)
|
||||
const changelog = await generateChangelog(previous)
|
||||
const contributors = await getContributors(previous)
|
||||
const notes = [...changelog, ...contributors]
|
||||
|
||||
await buildAndPublish(newVersion)
|
||||
await buildPackages()
|
||||
await publishAllPackages(newVersion)
|
||||
await gitTagAndRelease(newVersion, notes)
|
||||
|
||||
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} ===`)
|
||||
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} (8 packages) ===`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -519,6 +519,38 @@
|
||||
"created_at": "2026-01-14T07:41:50Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 777
|
||||
},
|
||||
{
|
||||
"name": "devkade",
|
||||
"id": 64977390,
|
||||
"comment_id": 3749807159,
|
||||
"created_at": "2026-01-14T14:25:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 784
|
||||
},
|
||||
{
|
||||
"name": "stranger2904",
|
||||
"id": 57737909,
|
||||
"comment_id": 3750612223,
|
||||
"created_at": "2026-01-14T17:06:12Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 788
|
||||
},
|
||||
{
|
||||
"name": "stranger29",
|
||||
"id": 29339256,
|
||||
"comment_id": 3751601362,
|
||||
"created_at": "2026-01-14T20:31:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 795
|
||||
},
|
||||
{
|
||||
"name": "mmlmt2604",
|
||||
"id": 59196850,
|
||||
"comment_id": 3753859484,
|
||||
"created_at": "2026-01-15T09:57:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 812
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,20 +6,21 @@ AI agent definitions for multi-model orchestration, delegating tasks to speciali
|
||||
## STRUCTURE
|
||||
```
|
||||
agents/
|
||||
├── orchestrator-sisyphus.ts # Orchestrator agent (1486 lines) - 7-section delegation, wisdom
|
||||
├── orchestrator-sisyphus.ts # Orchestrator agent (1485 lines) - 7-section delegation, wisdom
|
||||
├── sisyphus.ts # Main Sisyphus prompt (643 lines)
|
||||
├── sisyphus-junior.ts # Junior variant for delegated tasks
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (GLM-4.7-free)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Pro)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro Preview)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Pro Preview)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── prometheus-prompt.ts # Planning agent prompt (988 lines) - interview mode
|
||||
├── prometheus-prompt.ts # Planning agent prompt (991 lines) - interview mode
|
||||
├── metis.ts # Plan Consultant agent - pre-planning analysis
|
||||
├── momus.ts # Plan Reviewer agent - plan validation
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── sisyphus-prompt-builder.ts # Factory for orchestrator prompts
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
@@ -28,15 +29,15 @@ agents/
|
||||
## AGENT MODELS
|
||||
| Agent | Default Model | Purpose |
|
||||
|-------|---------------|---------|
|
||||
| Sisyphus | claude-opus-4-5 | Primary orchestrator. 32k extended thinking budget. |
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator. 32k extended thinking budget. |
|
||||
| oracle | openai/gpt-5.2 | High-IQ debugging, architecture, strategic consultation. |
|
||||
| librarian | glm-4.7-free | Multi-repo analysis, docs research, GitHub examples. |
|
||||
| explore | grok-code | Fast contextual grep. Fallbacks: Gemini-3-Flash, Haiku-4-5. |
|
||||
| frontend-ui-ux | gemini-3-pro | Production-grade UI/UX generation and styling. |
|
||||
| document-writer | gemini-3-pro | Technical writing, guides, API documentation. |
|
||||
| Prometheus | claude-opus-4-5 | Strategic planner. Interview mode, orchestrates Metis/Momus. |
|
||||
| Metis | claude-sonnet-4-5 | Plan Consultant. Pre-planning risk/requirement analysis. |
|
||||
| Momus | claude-sonnet-4-5 | Plan Reviewer. Validation and quality enforcement. |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs research, GitHub examples. |
|
||||
| explore | opencode/grok-code | Fast contextual grep. Fallbacks: Gemini-3-Flash, Haiku-4-5. |
|
||||
| frontend-ui-ux | google/gemini-3-pro-preview | Production-grade UI/UX generation and styling. |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical writing, guides, API documentation. |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planner. Interview mode, orchestrates Metis/Momus. |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | Plan Consultant. Pre-planning risk/requirement analysis. |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | Plan Reviewer. Validation and quality enforcement. |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
1. Create `src/agents/my-agent.ts` exporting `AgentConfig`.
|
||||
|
||||
@@ -84,13 +84,14 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
} as const
|
||||
|
||||
export function createSisyphusJuniorAgentWithOverrides(
|
||||
override: AgentOverrideConfig | undefined
|
||||
override: AgentOverrideConfig | undefined,
|
||||
systemDefaultModel?: string
|
||||
): AgentConfig {
|
||||
if (override?.disable) {
|
||||
override = undefined
|
||||
}
|
||||
|
||||
const model = override?.model ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
|
||||
|
||||
const promptAppend = override?.prompt_append
|
||||
|
||||
@@ -206,28 +206,55 @@ export function buildFrontendSection(agents: AvailableAgent[]): string {
|
||||
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
|
||||
if (!frontendAgent) return ""
|
||||
|
||||
return `### Frontend Files: Decision Gate (NOT a blind block)
|
||||
return `### Frontend Files: VISUAL = HARD BLOCK (zero tolerance)
|
||||
|
||||
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
|
||||
**DEFAULT ASSUMPTION**: Any frontend file change is VISUAL until proven otherwise.
|
||||
|
||||
#### Step 1: Classify the Change Type
|
||||
#### HARD BLOCK: Visual Changes (NEVER touch directly)
|
||||
|
||||
| Change Type | Examples | Action |
|
||||
|-------------|----------|--------|
|
||||
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
|
||||
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
|
||||
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
|
||||
| Pattern | Action | No Exceptions |
|
||||
|---------|--------|---------------|
|
||||
| \`.tsx\`, \`.jsx\` with styling | DELEGATE | Even "just add className" |
|
||||
| \`.vue\`, \`.svelte\` | DELEGATE | Even single prop change |
|
||||
| \`.css\`, \`.scss\`, \`.sass\`, \`.less\` | DELEGATE | Even color/margin tweak |
|
||||
| Any file with visual keywords | DELEGATE | See keyword list below |
|
||||
|
||||
#### Step 2: Ask Yourself
|
||||
#### Keyword Detection (INSTANT DELEGATE)
|
||||
|
||||
Before touching any frontend file, think:
|
||||
> "Is this change about **how it LOOKS** or **how it WORKS**?"
|
||||
If your change involves **ANY** of these keywords → **STOP. DELEGATE.**
|
||||
|
||||
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
|
||||
- **WORKS** (data flow, API integration, state) → Handle directly
|
||||
\`\`\`
|
||||
style, className, tailwind, css, color, background, border, shadow,
|
||||
margin, padding, width, height, flex, grid, animation, transition,
|
||||
hover, responsive, font-size, font-weight, icon, svg, image, layout,
|
||||
position, display, opacity, z-index, transform, gradient, theme
|
||||
\`\`\`
|
||||
|
||||
#### When in Doubt → DELEGATE if ANY of these keywords involved:
|
||||
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg`
|
||||
**YOU CANNOT**:
|
||||
- "Just quickly fix this style"
|
||||
- "It's only one className"
|
||||
- "Too simple to delegate"
|
||||
|
||||
#### EXCEPTION: Pure Logic Only
|
||||
|
||||
You MAY handle directly **ONLY IF ALL** conditions are met:
|
||||
1. Change is **100% logic** (API, state, event handlers, types, utils)
|
||||
2. **Zero** visual keywords in your diff
|
||||
3. No styling, layout, or appearance changes whatsoever
|
||||
|
||||
| Pure Logic Examples | Visual Examples (DELEGATE) |
|
||||
|---------------------|---------------------------|
|
||||
| Add onClick API call | Change button color |
|
||||
| Fix pagination logic | Add loading spinner animation |
|
||||
| Add form validation | Make modal responsive |
|
||||
| Update state management | Adjust spacing/margins |
|
||||
|
||||
#### Mixed Changes → SPLIT
|
||||
|
||||
If change has BOTH logic AND visual:
|
||||
1. Handle logic yourself
|
||||
2. DELEGATE visual part to \`frontend-ui-ux-engineer\`
|
||||
3. **Never** combine them into one edit`
|
||||
}
|
||||
|
||||
export function buildOracleSection(agents: AvailableAgent[]): string {
|
||||
@@ -271,7 +298,7 @@ export function buildHardBlocksSection(agents: AvailableAgent[]): string {
|
||||
|
||||
if (frontendAgent) {
|
||||
blocks.unshift(
|
||||
"| Frontend VISUAL changes (styling, layout, animation) | Always delegate to `frontend-ui-ux-engineer` |"
|
||||
"| Frontend VISUAL changes (styling, className, layout, animation, any visual keyword) | **HARD BLOCK** - Always delegate to `frontend-ui-ux-engineer`. Zero tolerance. |"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,7 +324,7 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
patterns.splice(
|
||||
4,
|
||||
0,
|
||||
"| **Frontend** | Direct edit to visual/styling code (logic changes OK) |"
|
||||
"| **Frontend** | ANY direct edit to visual/styling code. Keyword detected = DELEGATE. Pure logic only = OK |"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export function createBuiltinAgents(
|
||||
|
||||
if (!disabledAgents.includes("orchestrator-sisyphus")) {
|
||||
const orchestratorOverride = agentOverrides["orchestrator-sisyphus"]
|
||||
const orchestratorModel = orchestratorOverride?.model
|
||||
const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel
|
||||
let orchestratorConfig = createOrchestratorSisyphusAgent({
|
||||
model: orchestratorModel,
|
||||
availableAgents,
|
||||
|
||||
@@ -6,17 +6,16 @@ CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runt
|
||||
## STRUCTURE
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry, subcommand routing (184 lines)
|
||||
├── install.ts # Interactive TUI installer (436 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (725 lines)
|
||||
├── index.ts # Commander.js entry, subcommand routing (146 lines)
|
||||
├── install.ts # Interactive TUI installer (462 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (730 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── commands/ # CLI subcommands (auth.ts)
|
||||
├── doctor/ # Health check system
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── runner.ts # Health check orchestration
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 10+ check modules (17+ individual checks)
|
||||
│ └── checks/ # 10 check modules (14 individual checks)
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
├── completion.ts # Completion logic
|
||||
@@ -28,16 +27,17 @@ cli/
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard with subscription detection |
|
||||
| `doctor` | Environment health checks (LSP, Auth, Config, Deps) |
|
||||
| `run` | Launch OpenCode session with event handling |
|
||||
| `auth` | Manage authentication providers |
|
||||
| `run` | Launch OpenCode session with todo/background completion enforcement |
|
||||
| `get-local-version` | Detect and return local plugin version & update status |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
17+ checks in `doctor/checks/`:
|
||||
- `version.ts`: OpenCode >= 1.0.150
|
||||
14 checks in `doctor/checks/`:
|
||||
- `version.ts`: OpenCode >= 1.0.150 & plugin update status
|
||||
- `config.ts`: Plugin registration & JSONC validity
|
||||
- `dependencies.ts`: bun, node, git, gh-cli
|
||||
- `dependencies.ts`: AST-Grep (CLI/NAPI), Comment Checker
|
||||
- `auth.ts`: Anthropic, OpenAI, Google (Antigravity)
|
||||
- `lsp.ts`, `mcp.ts`: Tool connectivity checks
|
||||
- `gh.ts`: GitHub CLI availability
|
||||
|
||||
## CONFIG-MANAGER
|
||||
- **JSONC**: Supports comments and trailing commas via `parseJsonc`
|
||||
|
||||
@@ -1,8 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"
|
||||
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG, generateOmoConfig } from "./config-manager"
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags, generateOmoConfig } from "./config-manager"
|
||||
import type { InstallConfig } from "./types"
|
||||
|
||||
describe("getPluginNameWithVersion", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test("returns @latest when current version matches latest tag", async () => {
|
||||
// #given npm dist-tags with latest=2.14.0
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 2.14.0
|
||||
const result = await getPluginNameWithVersion("2.14.0")
|
||||
|
||||
// #then should use @latest tag
|
||||
expect(result).toBe("oh-my-opencode@latest")
|
||||
})
|
||||
|
||||
test("returns @beta when current version matches beta tag", async () => {
|
||||
// #given npm dist-tags with beta=3.0.0-beta.3
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.0.0-beta.3
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.3")
|
||||
|
||||
// #then should use @beta tag
|
||||
expect(result).toBe("oh-my-opencode@beta")
|
||||
})
|
||||
|
||||
test("returns @next when current version matches next tag", async () => {
|
||||
// #given npm dist-tags with next=3.1.0-next.1
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3", next: "3.1.0-next.1" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.1.0-next.1
|
||||
const result = await getPluginNameWithVersion("3.1.0-next.1")
|
||||
|
||||
// #then should use @next tag
|
||||
expect(result).toBe("oh-my-opencode@next")
|
||||
})
|
||||
|
||||
test("returns pinned version when no tag matches", async () => {
|
||||
// #given npm dist-tags with beta=3.0.0-beta.3
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is old beta 3.0.0-beta.2
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.2")
|
||||
|
||||
// #then should pin to specific version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.2")
|
||||
})
|
||||
|
||||
test("returns pinned version when fetch fails", async () => {
|
||||
// #given network failure
|
||||
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.0.0-beta.3
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.3")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.3")
|
||||
})
|
||||
|
||||
test("returns pinned version when npm returns non-ok response", async () => {
|
||||
// #given npm returns 404
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 2.14.0
|
||||
const result = await getPluginNameWithVersion("2.14.0")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@2.14.0")
|
||||
})
|
||||
|
||||
test("prioritizes latest over other tags when version matches multiple", async () => {
|
||||
// #given version matches both latest and beta (during release promotion)
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ beta: "3.0.0", latest: "3.0.0", next: "3.1.0-alpha.1" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version matches both
|
||||
const result = await getPluginNameWithVersion("3.0.0")
|
||||
|
||||
// #then should prioritize @latest
|
||||
expect(result).toBe("oh-my-opencode@latest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchNpmDistTags", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test("returns dist-tags on success", async () => {
|
||||
// #given npm returns dist-tags
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return the tags
|
||||
expect(result).toEqual({ latest: "2.14.0", beta: "3.0.0-beta.3" })
|
||||
})
|
||||
|
||||
test("returns null on network failure", async () => {
|
||||
// #given network failure
|
||||
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null on non-ok response", async () => {
|
||||
// #given npm returns 404
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
test("Gemini models include full spec (limit + modalities)", () => {
|
||||
const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
@@ -109,6 +108,47 @@ export async function fetchLatestVersion(packageName: string): Promise<string |
|
||||
}
|
||||
}
|
||||
|
||||
interface NpmDistTags {
|
||||
latest?: string
|
||||
beta?: string
|
||||
next?: string
|
||||
[tag: string]: string | undefined
|
||||
}
|
||||
|
||||
const NPM_FETCH_TIMEOUT_MS = 5000
|
||||
|
||||
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, {
|
||||
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as NpmDistTags
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
|
||||
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
|
||||
|
||||
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
|
||||
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
|
||||
|
||||
if (distTags) {
|
||||
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
|
||||
for (const tag of allTags) {
|
||||
if (distTags[tag] === currentVersion) {
|
||||
return `${PACKAGE_NAME}@${tag}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${PACKAGE_NAME}@${currentVersion}`
|
||||
}
|
||||
|
||||
type ConfigFormat = "json" | "jsonc" | "none"
|
||||
|
||||
interface OpenCodeConfig {
|
||||
@@ -179,7 +219,7 @@ function ensureConfigDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
@@ -187,11 +227,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
const pluginName = "oh-my-opencode"
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion)
|
||||
|
||||
try {
|
||||
if (format === "none") {
|
||||
const config: OpenCodeConfig = { plugin: [pluginName] }
|
||||
const config: OpenCodeConfig = { plugin: [pluginEntry] }
|
||||
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
@@ -203,11 +243,18 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
|
||||
const config = parseResult.config
|
||||
const plugins = config.plugin ?? []
|
||||
if (plugins.some((p) => p.startsWith(pluginName))) {
|
||||
return { success: true, configPath: path }
|
||||
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (plugins[existingIndex] === pluginEntry) {
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
plugins[existingIndex] = pluginEntry
|
||||
} else {
|
||||
plugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
config.plugin = [...plugins, pluginName]
|
||||
config.plugin = plugins
|
||||
|
||||
if (format === "jsonc") {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
@@ -215,14 +262,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
const match = content.match(pluginArrayRegex)
|
||||
|
||||
if (match) {
|
||||
const arrayContent = match[1].trim()
|
||||
const newArrayContent = arrayContent
|
||||
? `${arrayContent},\n "${pluginName}"`
|
||||
: `"${pluginName}"`
|
||||
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`)
|
||||
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
|
||||
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
|
||||
writeFileSync(path, newContent)
|
||||
} else {
|
||||
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`)
|
||||
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
|
||||
writeFileSync(path, newContent)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
import type { DoctorOptions } from "./doctor"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
|
||||
const packageJson = await import("../../package.json")
|
||||
const VERSION = packageJson.version
|
||||
|
||||
const program = new Command()
|
||||
@@ -26,12 +26,13 @@ program
|
||||
.option("--claude <value>", "Claude subscription: no, yes, max20")
|
||||
.option("--chatgpt <value>", "ChatGPT subscription: no, yes")
|
||||
.option("--gemini <value>", "Gemini integration: no, yes")
|
||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||
.option("--skip-auth", "Skip authentication setup hints")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ bunx oh-my-opencode install
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes --copilot=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no --copilot=yes
|
||||
|
||||
Model Providers:
|
||||
Claude Required for Sisyphus (main orchestrator) and Librarian agents
|
||||
@@ -44,6 +45,7 @@ Model Providers:
|
||||
claude: options.claude,
|
||||
chatgpt: options.chatgpt,
|
||||
gemini: options.gemini,
|
||||
copilot: options.copilot,
|
||||
skipAuth: options.skipAuth ?? false,
|
||||
}
|
||||
const exitCode = await install(args)
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
addProviderConfig,
|
||||
detectCurrentConfig,
|
||||
} from "./config-manager"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
|
||||
const VERSION = packageJson.version
|
||||
|
||||
const SYMBOLS = {
|
||||
check: color.green("✓"),
|
||||
@@ -274,7 +277,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
const config = argsToConfig(args)
|
||||
|
||||
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
|
||||
const pluginResult = addPluginToOpenCodeConfig()
|
||||
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
|
||||
if (!pluginResult.success) {
|
||||
printError(`Failed: ${pluginResult.error}`)
|
||||
return 1
|
||||
@@ -380,7 +383,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
if (!config) return 1
|
||||
|
||||
s.start("Adding oh-my-opencode to OpenCode config")
|
||||
const pluginResult = addPluginToOpenCodeConfig()
|
||||
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
|
||||
if (!pluginResult.success) {
|
||||
s.stop(`Failed to add plugin: ${pluginResult.error}`)
|
||||
p.outro(color.red("Installation failed."))
|
||||
|
||||
@@ -84,6 +84,7 @@ export const HookNameSchema = z.enum([
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
"sisyphus-task-retry",
|
||||
"prometheus-md-only",
|
||||
"start-work",
|
||||
"sisyphus-orchestrator",
|
||||
|
||||
@@ -6,13 +6,13 @@ Claude Code compatibility layer + core feature modules. Commands, skills, agents
|
||||
## STRUCTURE
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Task lifecycle, notifications (825 lines manager.ts)
|
||||
├── background-agent/ # Task lifecycle, notifications (928 lines manager.ts)
|
||||
├── boulder-state/ # Boulder state persistence
|
||||
├── builtin-commands/ # Built-in slash commands
|
||||
│ └── templates/ # start-work, refactor, init-deep, ralph-loop
|
||||
├── builtin-skills/ # Built-in skills (1230 lines skills.ts)
|
||||
│ ├── git-master/ # Atomic commits, rebase, history search
|
||||
│ ├── playwright/ # Browser automation skill
|
||||
│ ├── playwright # Browser automation skill
|
||||
│ └── frontend-ui-ux/ # Designer-turned-developer skill
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
@@ -24,8 +24,7 @@ features/
|
||||
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers in skill YAML
|
||||
├── task-toast-manager/ # Task toast notifications
|
||||
├── hook-message-injector/ # Inject messages into conversation
|
||||
└── context-injector/ # Context collection and injection
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
|
||||
@@ -186,6 +186,7 @@ export class BackgroundManager {
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
@@ -289,6 +290,9 @@ export class BackgroundManager {
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
// Reset startedAt on resume to prevent immediate completion
|
||||
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
|
||||
existingTask.startedAt = new Date()
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
@@ -340,6 +344,11 @@ export class BackgroundManager {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
// Release concurrency on resume error (matches launch error handler)
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
@@ -421,6 +430,13 @@ export class BackgroundManager {
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via session.idle event:", task.id)
|
||||
@@ -445,7 +461,10 @@ export class BackgroundManager {
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -537,6 +556,21 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task from pending tracking for its parent session.
|
||||
* Cleans up the parent entry if no pending tasks remain.
|
||||
*/
|
||||
private cleanupPendingByParent(task: BackgroundTask): void {
|
||||
if (!task.parentSessionID) return
|
||||
const pending = this.pendingByParent.get(task.parentSessionID)
|
||||
if (pending) {
|
||||
pending.delete(task.id)
|
||||
if (pending.size === 0) {
|
||||
this.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollingInterval) return
|
||||
|
||||
@@ -641,21 +675,33 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
// Dynamically lookup the parent session's current message context
|
||||
// This ensures we use the CURRENT model/agent, not the stale one from task creation time
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
|
||||
const agent = currentMessage?.agent ?? task.parentAgent
|
||||
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
try {
|
||||
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
agent = info.agent ?? task.parentAgent
|
||||
model = info.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
messageDir: !!messageDir,
|
||||
currentAgent: currentMessage?.agent,
|
||||
currentModel: currentMessage?.model,
|
||||
resolvedAgent: agent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
@@ -681,6 +727,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(() => {
|
||||
// Concurrency already released at completion - just cleanup notifications and task
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
@@ -720,7 +767,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.completedAt = new Date()
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
@@ -773,6 +823,13 @@ try {
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via polling:", task.id)
|
||||
@@ -839,6 +896,13 @@ if (lastMessage) {
|
||||
if (!hasIncompleteTodos) {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via stability detection:", task.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
|
||||
export type { StoredMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
||||
export { MESSAGE_STORAGE } from "./constants"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveSkillContent, resolveMultipleSkills } from "./skill-content"
|
||||
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
|
||||
|
||||
describe("resolveSkillContent", () => {
|
||||
it("should return template for existing skill", () => {
|
||||
@@ -109,3 +109,87 @@ describe("resolveMultipleSkills", () => {
|
||||
expect(result.resolved.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSkillContentAsync", () => {
|
||||
it("should return template for builtin skill", async () => {
|
||||
// #given: builtin skill 'frontend-ui-ux'
|
||||
// #when: resolving content async
|
||||
const result = await resolveSkillContentAsync("frontend-ui-ux")
|
||||
|
||||
// #then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Role: Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", async () => {
|
||||
// #given: non-existent skill name
|
||||
// #when: resolving content async
|
||||
const result = await resolveSkillContentAsync("definitely-not-a-skill-12345")
|
||||
|
||||
// #then: returns null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkillsAsync", () => {
|
||||
it("should resolve builtin skills", async () => {
|
||||
// #given: builtin skill names
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: all builtin skills resolved
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual([])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should handle partial success with non-existent skills", async () => {
|
||||
// #given: mix of existing and non-existing skills
|
||||
const skillNames = ["playwright", "nonexistent-skill-12345"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: existing skills resolved, non-existing in notFound
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual(["nonexistent-skill-12345"])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
})
|
||||
|
||||
it("should support git-master config injection", async () => {
|
||||
// #given: git-master skill with config override
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: config values injected into template
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual([])
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).toContain("commit_footer")
|
||||
expect(gitMasterContent).toContain("DISABLED")
|
||||
})
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
// #given: empty skill names
|
||||
const skillNames: string[] = []
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: empty results
|
||||
expect(result.resolved.size).toBe(0)
|
||||
expect(result.notFound).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
import { createBuiltinSkills } from "../builtin-skills/skills"
|
||||
import { discoverSkills } from "./loader"
|
||||
import type { LoadedSkill } from "./types"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { readFileSync } from "node:fs"
|
||||
import type { GitMasterConfig } from "../../config/schema"
|
||||
|
||||
export interface SkillResolutionOptions {
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
}
|
||||
|
||||
let cachedSkills: LoadedSkill[] | null = null
|
||||
|
||||
function clearSkillCache(): void {
|
||||
cachedSkills = null
|
||||
}
|
||||
|
||||
async function getAllSkills(): Promise<LoadedSkill[]> {
|
||||
if (cachedSkills) return cachedSkills
|
||||
|
||||
const [discoveredSkills, builtinSkillDefs] = await Promise.all([
|
||||
discoverSkills({ includeClaudeCodePaths: true }),
|
||||
Promise.resolve(createBuiltinSkills()),
|
||||
])
|
||||
|
||||
const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({
|
||||
name: skill.name,
|
||||
definition: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
template: skill.template,
|
||||
model: skill.model,
|
||||
agent: skill.agent,
|
||||
subtask: skill.subtask,
|
||||
},
|
||||
scope: "builtin" as const,
|
||||
license: skill.license,
|
||||
compatibility: skill.compatibility,
|
||||
metadata: skill.metadata as Record<string, string> | undefined,
|
||||
allowedTools: skill.allowedTools,
|
||||
mcpConfig: skill.mcpConfig,
|
||||
}))
|
||||
|
||||
const discoveredNames = new Set(discoveredSkills.map((s) => s.name))
|
||||
const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name))
|
||||
|
||||
cachedSkills = [...discoveredSkills, ...uniqueBuiltins]
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
async function extractSkillTemplate(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
return body.trim()
|
||||
}
|
||||
return skill.definition.template || ""
|
||||
}
|
||||
|
||||
export { clearSkillCache, getAllSkills, extractSkillTemplate }
|
||||
|
||||
function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
|
||||
if (!config) return template
|
||||
|
||||
@@ -60,3 +114,53 @@ export function resolveMultipleSkills(skillNames: string[], options?: SkillResol
|
||||
|
||||
return { resolved, notFound }
|
||||
}
|
||||
|
||||
export async function resolveSkillContentAsync(
|
||||
skillName: string,
|
||||
options?: SkillResolutionOptions
|
||||
): Promise<string | null> {
|
||||
const allSkills = await getAllSkills()
|
||||
const skill = allSkills.find((s) => s.name === skillName)
|
||||
if (!skill) return null
|
||||
|
||||
const template = await extractSkillTemplate(skill)
|
||||
|
||||
if (skillName === "git-master" && options?.gitMasterConfig) {
|
||||
return injectGitMasterConfig(template, options.gitMasterConfig)
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
export async function resolveMultipleSkillsAsync(
|
||||
skillNames: string[],
|
||||
options?: SkillResolutionOptions
|
||||
): Promise<{
|
||||
resolved: Map<string, string>
|
||||
notFound: string[]
|
||||
}> {
|
||||
const allSkills = await getAllSkills()
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
for (const skill of allSkills) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
|
||||
const resolved = new Map<string, string>()
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const name of skillNames) {
|
||||
const skill = skillMap.get(name)
|
||||
if (skill) {
|
||||
const template = await extractSkillTemplate(skill)
|
||||
if (name === "git-master" && options?.gitMasterConfig) {
|
||||
resolved.set(name, injectGitMasterConfig(template, options.gitMasterConfig))
|
||||
} else {
|
||||
resolved.set(name, template)
|
||||
}
|
||||
} else {
|
||||
notFound.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, notFound }
|
||||
}
|
||||
|
||||
@@ -3,11 +3,47 @@ import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
|
||||
|
||||
// Mock the MCP SDK transports to avoid network calls
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport {
|
||||
constructor(public url: URL, public options?: { requestInit?: RequestInit }) {
|
||||
lastTransportInstance = { url, options }
|
||||
}
|
||||
async start() {
|
||||
await mockHttpConnect()
|
||||
}
|
||||
async close() {
|
||||
await mockHttpClose()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
describe("SkillMcpManager", () => {
|
||||
let manager: SkillMcpManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
mockHttpConnect.mockClear()
|
||||
mockHttpClose.mockClear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -15,34 +51,296 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
describe("getOrCreateClient", () => {
|
||||
it("throws error when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
describe("configuration validation", () => {
|
||||
it("throws error when neither url nor command is provided", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing required 'command' field/
|
||||
)
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/no valid connection configuration/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes both HTTP and stdio examples in error message", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/HTTP[\s\S]*Stdio/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes server and skill names in error message", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "custom-server",
|
||||
skillName: "custom-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/custom-server[\s\S]*custom-skill/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("includes helpful error message with example when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
describe("connection type detection", () => {
|
||||
it("detects HTTP connection from explicit type='http'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/my-mcp[\s\S]*data-skill[\s\S]*Example/
|
||||
)
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from explicit type='sse'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "sse-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "sse",
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from url field when type is not specified", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-http",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from explicit type='stdio'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stdio-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from command field when type is not specified", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-stdio",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers explicit type over inferred type", async () => {
|
||||
// #given - has both url and command, but type is explicitly stdio
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mixed-config",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
url: "https://example.com/mcp", // should be ignored
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should use stdio (show Command in error, not URL)
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Command: node/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("HTTP connection", () => {
|
||||
it("throws error for invalid URL", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "bad-url-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
url: "not-a-valid-url",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes URL in HTTP connection error", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-error-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/https:\/\/nonexistent\.example\.com\/mcp/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for HTTP connection failures", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "hint-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/
|
||||
)
|
||||
})
|
||||
|
||||
it("calls mocked transport connect for HTTP connections", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mock-test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// #then - verify mock was called (transport was instantiated)
|
||||
// The connection attempt happens through the Client.connect() which
|
||||
// internally calls transport.start()
|
||||
expect(mockHttpConnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdio connection (backward compatibility)", () => {
|
||||
it("throws error when command is missing for stdio type", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "missing-command",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
// command is missing
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing 'command' field/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes command in stdio connection error", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command-xyz",
|
||||
args: ["--foo"],
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/nonexistent-command-xyz --foo/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for stdio connection failures", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*PATH[\s\S]*package exists/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,4 +454,52 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("HTTP headers handling", () => {
|
||||
it("accepts configuration with headers", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// Headers are passed through to the transport
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
|
||||
// Verify headers were forwarded to transport
|
||||
expect(lastTransportInstance.options?.requestInit?.headers).toEqual({
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
})
|
||||
})
|
||||
|
||||
it("works without headers (optional)", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
// no headers
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
/**
|
||||
* Connection type for a managed MCP client.
|
||||
* - "stdio": Local process via stdin/stdout
|
||||
* - "http": Remote server via HTTP (Streamable HTTP transport)
|
||||
*/
|
||||
type ConnectionType = "stdio" | "http"
|
||||
|
||||
interface ManagedClientBase {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
lastUsedAt: number
|
||||
connectionType: ConnectionType
|
||||
}
|
||||
|
||||
interface ManagedStdioClient extends ManagedClientBase {
|
||||
connectionType: "stdio"
|
||||
transport: StdioClientTransport
|
||||
}
|
||||
|
||||
interface ManagedHttpClient extends ManagedClientBase {
|
||||
connectionType: "http"
|
||||
transport: StreamableHTTPClientTransport
|
||||
}
|
||||
|
||||
type ManagedClient = ManagedStdioClient | ManagedHttpClient
|
||||
|
||||
/**
|
||||
* Determines connection type from MCP server configuration.
|
||||
* Priority: explicit type field > url presence > command presence
|
||||
*/
|
||||
function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null {
|
||||
// Explicit type takes priority
|
||||
if (config.type === "http" || config.type === "sse") {
|
||||
return "http"
|
||||
}
|
||||
if (config.type === "stdio") {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
// Infer from available fields
|
||||
if (config.url) {
|
||||
return "http"
|
||||
}
|
||||
if (config.command) {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
@@ -98,18 +142,125 @@ export class SkillMcpManager {
|
||||
private async createClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const connectionType = getConnectionType(config)
|
||||
|
||||
if (!connectionType) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" has no valid connection configuration.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify either:\n` +
|
||||
` - A URL for HTTP connection (remote MCP server)\n` +
|
||||
` - A command for stdio connection (local MCP process)\n\n` +
|
||||
`Examples:\n` +
|
||||
` HTTP:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` url: https://mcp.example.com/mcp\n` +
|
||||
` headers:\n` +
|
||||
` Authorization: Bearer \${API_KEY}\n\n` +
|
||||
` Stdio:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
)
|
||||
}
|
||||
|
||||
if (connectionType === "http") {
|
||||
return this.createHttpClient(info, config)
|
||||
} else {
|
||||
return this.createStdioClient(info, config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP-based MCP client using StreamableHTTPClientTransport.
|
||||
* Supports remote MCP servers with optional authentication headers.
|
||||
*/
|
||||
private async createHttpClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.url) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.`
|
||||
)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(config.url)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` +
|
||||
`Expected a valid URL like: https://mcp.example.com/mcp`
|
||||
)
|
||||
}
|
||||
|
||||
this.registerProcessCleanup()
|
||||
|
||||
// Build request init with headers if provided
|
||||
const requestInit: RequestInit = {}
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// Transport may already be closed
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
`URL: ${config.url}\n` +
|
||||
`Reason: ${errorMessage}\n\n` +
|
||||
`Hints:\n` +
|
||||
` - Verify the URL is correct and the server is running\n` +
|
||||
` - Check if authentication headers are required\n` +
|
||||
` - Ensure the server supports MCP over HTTP`
|
||||
)
|
||||
}
|
||||
|
||||
const managedClient: ManagedHttpClient = {
|
||||
client,
|
||||
transport,
|
||||
skillName: info.skillName,
|
||||
lastUsedAt: Date.now(),
|
||||
connectionType: "http",
|
||||
}
|
||||
this.clients.set(key, managedClient)
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stdio-based MCP client using StdioClientTransport.
|
||||
* Spawns a local process and communicates via stdin/stdout.
|
||||
*/
|
||||
private async createStdioClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.command) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is missing required 'command' field.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` +
|
||||
`Example:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
`MCP server "${info.serverName}" is configured for stdio but missing 'command' field.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +304,14 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
|
||||
const managedClient: ManagedStdioClient = {
|
||||
client,
|
||||
transport,
|
||||
skillName: info.skillName,
|
||||
lastUsedAt: Date.now(),
|
||||
connectionType: "stdio",
|
||||
}
|
||||
this.clients.set(key, managedClient)
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager"
|
||||
export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types"
|
||||
export type { TrackedTask, TaskStatus, TaskToastOptions, ModelFallbackInfo } from "./types"
|
||||
|
||||
@@ -142,4 +142,109 @@ describe("TaskToastManager", () => {
|
||||
expect(call.body.message).toContain("Running (1):")
|
||||
})
|
||||
})
|
||||
|
||||
describe("model fallback info in toast message", () => {
|
||||
test("should display warning when model falls back to category-default", () => {
|
||||
// #given - a task with model fallback to category-default
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Task with category default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "google/gemini-3-pro-preview", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with model info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("google/gemini-3-pro-preview")
|
||||
expect(call.body.message).toContain("(category default)")
|
||||
})
|
||||
|
||||
test("should display warning when model falls back to system-default", () => {
|
||||
// #given - a task with model fallback to system-default
|
||||
const task = {
|
||||
id: "task_1b",
|
||||
description: "Task with system default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "system-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with model info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("anthropic/claude-sonnet-4-5")
|
||||
expect(call.body.message).toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should display warning when model is inherited from parent", () => {
|
||||
// #given - a task with inherited model
|
||||
const task = {
|
||||
id: "task_2",
|
||||
description: "Task with inherited model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with inherited model
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("cliproxy/claude-opus-4-5")
|
||||
expect(call.body.message).toContain("(inherited)")
|
||||
})
|
||||
|
||||
test("should not display model info when user-defined", () => {
|
||||
// #given - a task with user-defined model
|
||||
const task = {
|
||||
id: "task_3",
|
||||
description: "Task with user model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "my-provider/my-model", type: "user-defined" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("⚠️ Model:")
|
||||
expect(call.body.message).not.toContain("(inherited)")
|
||||
expect(call.body.message).not.toContain("(category default)")
|
||||
expect(call.body.message).not.toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should not display model info when not provided", () => {
|
||||
// #given - a task without model info
|
||||
const task = {
|
||||
id: "task_4",
|
||||
description: "Task without model info",
|
||||
agent: "explore",
|
||||
isBackground: true,
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("⚠️ Model:")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TrackedTask, TaskStatus } from "./types"
|
||||
import type { TrackedTask, TaskStatus, ModelFallbackInfo } from "./types"
|
||||
import type { ConcurrencyManager } from "../background-agent/concurrency"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
@@ -25,6 +25,7 @@ export class TaskToastManager {
|
||||
isBackground: boolean
|
||||
status?: TaskStatus
|
||||
skills?: string[]
|
||||
modelInfo?: ModelFallbackInfo
|
||||
}): void {
|
||||
const trackedTask: TrackedTask = {
|
||||
id: task.id,
|
||||
@@ -34,6 +35,7 @@ export class TaskToastManager {
|
||||
startedAt: new Date(),
|
||||
isBackground: task.isBackground,
|
||||
skills: task.skills,
|
||||
modelInfo: task.modelInfo,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, trackedTask)
|
||||
@@ -105,6 +107,19 @@ export class TaskToastManager {
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// Show model fallback warning for the new task if applicable
|
||||
if (newTask.modelInfo && newTask.modelInfo.type !== "user-defined") {
|
||||
const icon = "⚠️"
|
||||
const suffixMap: Partial<Record<ModelFallbackInfo["type"], string>> = {
|
||||
inherited: " (inherited)",
|
||||
"category-default": " (category default)",
|
||||
"system-default": " (system default)",
|
||||
}
|
||||
const suffix = suffixMap[newTask.modelInfo.type] ?? ""
|
||||
lines.push(`${icon} Model: ${newTask.modelInfo.model}${suffix}`)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (running.length > 0) {
|
||||
lines.push(`Running (${running.length}):${concurrencyInfo}`)
|
||||
for (const task of running) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type TaskStatus = "running" | "queued" | "completed" | "error"
|
||||
|
||||
export interface ModelFallbackInfo {
|
||||
model: string
|
||||
type: "user-defined" | "inherited" | "category-default" | "system-default"
|
||||
}
|
||||
|
||||
export interface TrackedTask {
|
||||
id: string
|
||||
description: string
|
||||
@@ -8,6 +13,7 @@ export interface TrackedTask {
|
||||
startedAt: Date
|
||||
isBackground: boolean
|
||||
skills?: string[]
|
||||
modelInfo?: ModelFallbackInfo
|
||||
}
|
||||
|
||||
export interface TaskToastOptions {
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
## STRUCTURE
|
||||
```
|
||||
hooks/
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (555 lines)
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (677 lines)
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (684 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (554 lines)
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items (445 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (364 lines)
|
||||
├── claude-code-hooks/ # settings.json hook compatibility layer
|
||||
├── comment-checker/ # Prevents AI slop/excessive comments
|
||||
@@ -23,7 +24,6 @@ hooks/
|
||||
├── start-work/ # Initializes work sessions (ulw/ulw)
|
||||
├── think-mode/ # Dynamic thinking budget adjustment
|
||||
├── background-notification/ # OS notification on task completion
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items
|
||||
└── tool-output-truncator.ts # Prevents context bloat from verbose tools
|
||||
```
|
||||
|
||||
|
||||
68
src/hooks/comment-checker/cli.test.ts
Normal file
68
src/hooks/comment-checker/cli.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test"
|
||||
|
||||
describe("comment-checker CLI path resolution", () => {
|
||||
describe("lazy initialization", () => {
|
||||
// #given module is imported
|
||||
// #when COMMENT_CHECKER_CLI_PATH is accessed
|
||||
// #then findCommentCheckerPathSync should NOT have been called during import
|
||||
|
||||
test("getCommentCheckerPathSync should be lazy - not called on module import", async () => {
|
||||
// #given a fresh module import
|
||||
// We need to verify that importing the module doesn't immediately call findCommentCheckerPathSync
|
||||
|
||||
// #when we import the module
|
||||
const cliModule = await import("./cli")
|
||||
|
||||
// #then getCommentCheckerPathSync should exist and be callable
|
||||
expect(typeof cliModule.getCommentCheckerPathSync).toBe("function")
|
||||
|
||||
// The key test: calling getCommentCheckerPathSync should work
|
||||
// (we can't easily test that it wasn't called on import without mocking,
|
||||
// but we can verify the function exists and returns expected types)
|
||||
const result = cliModule.getCommentCheckerPathSync()
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
|
||||
test("getCommentCheckerPathSync should cache result after first call", async () => {
|
||||
// #given getCommentCheckerPathSync is called once
|
||||
const cliModule = await import("./cli")
|
||||
const firstResult = cliModule.getCommentCheckerPathSync()
|
||||
|
||||
// #when called again
|
||||
const secondResult = cliModule.getCommentCheckerPathSync()
|
||||
|
||||
// #then should return same cached result
|
||||
expect(secondResult).toBe(firstResult)
|
||||
})
|
||||
|
||||
test("COMMENT_CHECKER_CLI_PATH export should not exist (removed for lazy loading)", async () => {
|
||||
// #given the cli module
|
||||
const cliModule = await import("./cli")
|
||||
|
||||
// #when checking for COMMENT_CHECKER_CLI_PATH
|
||||
// #then it should not exist (replaced with lazy getter)
|
||||
expect("COMMENT_CHECKER_CLI_PATH" in cliModule).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("runCommentChecker", () => {
|
||||
test("should use getCommentCheckerPathSync for fallback path resolution", async () => {
|
||||
// #given runCommentChecker is called without explicit path
|
||||
const { runCommentChecker } = await import("./cli")
|
||||
|
||||
// #when called with input containing no comments
|
||||
const result = await runCommentChecker({
|
||||
session_id: "test",
|
||||
tool_name: "Write",
|
||||
transcript_path: "",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: { file_path: "/tmp/test.ts", content: "const x = 1" },
|
||||
})
|
||||
|
||||
// #then should return CheckResult type (binary may or may not exist)
|
||||
expect(typeof result.hasComments).toBe("boolean")
|
||||
expect(typeof result.message).toBe("string")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -121,9 +121,6 @@ export function startBackgroundInit(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export for backwards compatibility (sync, no download)
|
||||
export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPathSync()
|
||||
|
||||
export interface HookInput {
|
||||
session_id: string
|
||||
tool_name: string
|
||||
@@ -152,7 +149,7 @@ export interface CheckResult {
|
||||
* @param customPrompt Optional custom prompt to replace default warning message
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? getCommentCheckerPathSync()
|
||||
|
||||
if (!binaryPath) {
|
||||
debugLog("comment-checker binary not found")
|
||||
|
||||
@@ -30,3 +30,4 @@ export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
|
||||
export { createTaskResumeInfoHook } from "./task-resume-info";
|
||||
export { createStartWorkHook } from "./start-work";
|
||||
export { createSisyphusOrchestratorHook } from "./sisyphus-orchestrator";
|
||||
export { createSisyphusTaskRetryHook } from "./sisyphus-task-retry";
|
||||
|
||||
@@ -192,7 +192,7 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
pattern: /\b(ultrawork|ulw)\b/i,
|
||||
message: getUltraworkMessage,
|
||||
},
|
||||
// SEARCH: EN/KO/JP/CN/VN
|
||||
|
||||
@@ -93,16 +93,18 @@ describe("keyword-detector registers to ContextCollector", () => {
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
@@ -233,3 +235,100 @@ describe("keyword-detector session filtering", () => {
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector word boundary", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should NOT trigger ultrawork on partial matches like 'StatefulWidget' containing 'ulw'", async () => {
|
||||
// #given - text contains 'ulw' as part of another word (StatefulWidget)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "refactor the StatefulWidget component" }],
|
||||
}
|
||||
|
||||
// #when - message with partial 'ulw' match is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should NOT be triggered
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should trigger ultrawork on standalone 'ulw' keyword", async () => {
|
||||
// #given - text contains standalone 'ulw'
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ulw do this task" }],
|
||||
}
|
||||
|
||||
// #when - message with standalone 'ulw' is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should be triggered
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should NOT trigger ultrawork on file references containing 'ulw' substring", async () => {
|
||||
// #given - file reference contains 'ulw' as substring
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "@StatefulWidget.tsx please review this file" }],
|
||||
}
|
||||
|
||||
// #when - message referencing file with 'ulw' substring is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should NOT be triggered
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||
|
||||
describe("non-interactive-env hook", () => {
|
||||
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||
|
||||
let originalPlatform: NodeJS.Platform
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
originalEnv = {
|
||||
SHELL: process.env.SHELL,
|
||||
PSModulePath: process.env.PSModulePath,
|
||||
}
|
||||
// #given clean Unix-like environment for all tests
|
||||
// This prevents CI environments (which may have PSModulePath set) from
|
||||
// triggering PowerShell detection in tests that expect Unix behavior
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("git command modification", () => {
|
||||
test("#given git command #when hook executes #then prepends export statement", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
@@ -147,4 +174,147 @@ describe("non-interactive-env hook", () => {
|
||||
expect(output.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-platform shell support", () => {
|
||||
test("#given macOS platform #when git command executes #then uses unix export syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain(";")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
expect(cmd).not.toContain("set ")
|
||||
})
|
||||
|
||||
test("#given Linux platform #when git command executes #then uses unix export syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git commit")
|
||||
})
|
||||
|
||||
test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
expect(cmd).toContain("; git status")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
expect(cmd).not.toContain("set ")
|
||||
})
|
||||
|
||||
test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git log" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("set ")
|
||||
expect(cmd).toContain("&&")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
})
|
||||
|
||||
test("#given PowerShell #when values contain quotes #then escapes correctly", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/\$env:\w+='[^']*'/)
|
||||
})
|
||||
|
||||
test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/set \w+="[^"]*"/)
|
||||
})
|
||||
|
||||
test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
expect(cmd).toContain("; git add file && git commit")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { isNonInteractive } from "./detector"
|
||||
import { log, detectShellType, buildEnvPrefix } from "../../shared"
|
||||
|
||||
export * from "./constants"
|
||||
export * from "./detector"
|
||||
@@ -19,35 +20,6 @@ function detectBannedCommand(command: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in VAR=value prefix.
|
||||
* Wraps in single quotes if contains special chars.
|
||||
*/
|
||||
function shellEscape(value: string): string {
|
||||
// Empty string needs quotes
|
||||
if (value === "") return "''"
|
||||
// If contains special chars, wrap in single quotes (escape existing single quotes)
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export statement for environment variables.
|
||||
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
|
||||
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
|
||||
*
|
||||
* Previous approach used VAR=value prefix which only applies to the first command.
|
||||
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||
*/
|
||||
function buildEnvPrefix(env: Record<string, string>): string {
|
||||
const exports = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
.join(" ")
|
||||
return `export ${exports};`
|
||||
}
|
||||
|
||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -74,11 +46,12 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||
// ignoring any args.env we might set. Prepend export statement to command.
|
||||
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
|
||||
// in a chain (e.g., `git add file && git rebase --continue`).
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||
if (!isNonInteractive()) {
|
||||
return
|
||||
}
|
||||
|
||||
const shellType = detectShellType()
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
|
||||
@@ -684,7 +684,8 @@ describe("ralph-loop", () => {
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
test("should not hang when session.messages() times out", async () => {
|
||||
// FIXME: Flaky in CI - times out intermittently
|
||||
test.skip("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
...createMockPluginInput(),
|
||||
|
||||
@@ -315,12 +315,30 @@ export function createRalphLoopHook(
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const agent = currentMessage?.agent
|
||||
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
let agent: string | undefined
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
agent = info.agent
|
||||
model = info.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
|
||||
// #then - standalone verification reminder appended
|
||||
expect(output.output).toContain("Task completed successfully")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
expect(output.output).toContain("MANDATORY:")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
@@ -179,7 +179,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
expect(output.output).toContain("Task completed successfully")
|
||||
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
|
||||
expect(output.output).toContain("test-plan")
|
||||
expect(output.output).toContain("SUBAGENTS LIE")
|
||||
expect(output.output).toContain("LIE")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
@@ -217,7 +217,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - output transformed even when complete (shows 2/2 done)
|
||||
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
|
||||
expect(output.output).toContain("2/2 done")
|
||||
expect(output.output).toContain("0 left")
|
||||
expect(output.output).toContain("0 remaining")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
@@ -327,7 +327,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - output should contain plan name and progress
|
||||
expect(output.output).toContain("my-feature")
|
||||
expect(output.output).toContain("1/3 done")
|
||||
expect(output.output).toContain("2 left")
|
||||
expect(output.output).toContain("2 remaining")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
@@ -364,7 +364,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - should include resume instructions and verification
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
expect(output.output).toContain("[x]")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
expect(output.output).toContain("MANDATORY:")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
@@ -63,34 +63,45 @@ RULES:
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
const VERIFICATION_REMINDER = `**MANDATORY VERIFICATION - SUBAGENTS LIE**
|
||||
const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
|
||||
|
||||
Subagents FREQUENTLY claim completion when:
|
||||
- Tests are actually FAILING
|
||||
- Code has type/lint ERRORS
|
||||
- Implementation is INCOMPLETE
|
||||
- Patterns were NOT followed
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**YOU MUST VERIFY EVERYTHING YOURSELF:**
|
||||
⚠️ CRITICAL: Subagents FREQUENTLY LIE about completion.
|
||||
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
|
||||
|
||||
1. Run \`lsp_diagnostics\` on changed files - Must be CLEAN
|
||||
2. Run tests yourself - Must PASS (not "agent said it passed")
|
||||
3. Read the actual code - Must match requirements
|
||||
4. Check build/typecheck - Must succeed
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
DO NOT TRUST THE AGENT'S SELF-REPORT.
|
||||
VERIFY EACH CLAIM WITH YOUR OWN TOOL CALLS.
|
||||
**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)**
|
||||
|
||||
**HANDS-ON QA REQUIRED (after ALL tasks complete):**
|
||||
Run these commands YOURSELF - do NOT trust agent's claims:
|
||||
1. \`lsp_diagnostics\` on changed files → Must be CLEAN
|
||||
2. \`bash\` to run tests → Must PASS
|
||||
3. \`bash\` to run build/typecheck → Must succeed
|
||||
4. \`Read\` the actual code → Must match requirements
|
||||
|
||||
| Deliverable Type | Verification Tool | Action |
|
||||
|------------------|-------------------|--------|
|
||||
| **Frontend/UI** | \`/playwright\` skill | Navigate, interact, screenshot evidence |
|
||||
| **TUI/CLI** | \`interactive_bash\` (tmux) | Run interactively, verify output |
|
||||
| **API/Backend** | \`bash\` with curl | Send requests, verify responses |
|
||||
**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED**
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages, integration problems.
|
||||
**FAILURE TO DO HANDS-ON QA = INCOMPLETE WORK.**`
|
||||
| Deliverable Type | QA Method | Tool |
|
||||
|------------------|-----------|------|
|
||||
| **Frontend/UI** | Browser interaction | \`/playwright\` skill |
|
||||
| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) |
|
||||
| **API/Backend** | Send real requests | \`bash\` with curl |
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages.
|
||||
|
||||
**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY**
|
||||
|
||||
\`\`\`
|
||||
todowrite([
|
||||
{ id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**BLOCKING: DO NOT proceed to next task until Steps 1-3 are complete.**
|
||||
**FAILURE TO DO QA = INCOMPLETE WORK = USER WILL REJECT.**`
|
||||
|
||||
const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
@@ -183,20 +194,38 @@ function buildOrchestratorReminder(planName: string, progress: { total: number;
|
||||
return `
|
||||
---
|
||||
|
||||
**State:** Plan: ${planName} | ${progress.completed}/${progress.total} done, ${remaining} left
|
||||
**BOULDER STATE:** Plan: \`${planName}\` | ✅ ${progress.completed}/${progress.total} done | ⏳ ${remaining} remaining
|
||||
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
ALL pass? → commit atomic unit, mark \`[x]\`, next task.`
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**AFTER VERIFICATION PASSES - YOUR NEXT ACTIONS (IN ORDER):**
|
||||
|
||||
1. **COMMIT** atomic unit (only verified changes)
|
||||
2. **MARK** \`[x]\` in plan file for completed task
|
||||
3. **PROCEED** to next task immediately
|
||||
|
||||
**DO NOT STOP. ${remaining} tasks remain. Keep bouldering.**`
|
||||
}
|
||||
|
||||
function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||
return `
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}`
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**AFTER VERIFICATION - CHECK YOUR TODO LIST:**
|
||||
|
||||
1. Run \`todoread\` to see remaining tasks
|
||||
2. If QA tasks exist → execute them BEFORE marking complete
|
||||
3. Mark completed tasks → proceed to next pending task
|
||||
|
||||
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
|
||||
}
|
||||
|
||||
function extractSessionIdFromOutput(output: string): string {
|
||||
@@ -407,11 +436,26 @@ export function createSisyphusOrchestratorHook(
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msgModel = messages[i].info?.model
|
||||
if (msgModel?.providerID && msgModel?.modelID) {
|
||||
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
|
||||
119
src/hooks/sisyphus-task-retry/index.test.ts
Normal file
119
src/hooks/sisyphus-task-retry/index.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
SISYPHUS_TASK_ERROR_PATTERNS,
|
||||
detectSisyphusTaskError,
|
||||
buildRetryGuidance,
|
||||
} from "./index"
|
||||
|
||||
describe("sisyphus-task-retry", () => {
|
||||
describe("SISYPHUS_TASK_ERROR_PATTERNS", () => {
|
||||
// #given error patterns are defined
|
||||
// #then should include all known sisyphus_task error types
|
||||
it("should contain all known error patterns", () => {
|
||||
expect(SISYPHUS_TASK_ERROR_PATTERNS.length).toBeGreaterThan(5)
|
||||
|
||||
const patternTexts = SISYPHUS_TASK_ERROR_PATTERNS.map(p => p.pattern)
|
||||
expect(patternTexts).toContain("run_in_background")
|
||||
expect(patternTexts).toContain("skills")
|
||||
expect(patternTexts).toContain("category OR subagent_type")
|
||||
expect(patternTexts).toContain("Unknown category")
|
||||
expect(patternTexts).toContain("Unknown agent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectSisyphusTaskError", () => {
|
||||
// #given tool output with run_in_background error
|
||||
// #when detecting error
|
||||
// #then should return matching error info
|
||||
it("should detect run_in_background missing error", () => {
|
||||
const output = "❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("missing_run_in_background")
|
||||
})
|
||||
|
||||
it("should detect skills missing error", () => {
|
||||
const output = "❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("missing_skills")
|
||||
})
|
||||
|
||||
it("should detect category/subagent mutual exclusion error", () => {
|
||||
const output = "❌ Invalid arguments: Provide EITHER category OR subagent_type, not both."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("mutual_exclusion")
|
||||
})
|
||||
|
||||
it("should detect unknown category error", () => {
|
||||
const output = '❌ Unknown category: "invalid-cat". Available: visual-engineering, ultrabrain, quick'
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("unknown_category")
|
||||
})
|
||||
|
||||
it("should detect unknown agent error", () => {
|
||||
const output = '❌ Unknown agent: "fake-agent". Available agents: explore, librarian, oracle'
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("unknown_agent")
|
||||
})
|
||||
|
||||
it("should return null for successful output", () => {
|
||||
const output = "Background task launched.\n\nTask ID: bg_12345\nSession ID: ses_abc"
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildRetryGuidance", () => {
|
||||
// #given detected error
|
||||
// #when building retry guidance
|
||||
// #then should return actionable fix instructions
|
||||
it("should provide fix for missing run_in_background", () => {
|
||||
const errorInfo = { errorType: "missing_run_in_background", originalOutput: "" }
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("run_in_background")
|
||||
expect(guidance).toContain("REQUIRED")
|
||||
})
|
||||
|
||||
it("should provide fix for unknown category with available list", () => {
|
||||
const errorInfo = {
|
||||
errorType: "unknown_category",
|
||||
originalOutput: '❌ Unknown category: "bad". Available: visual-engineering, ultrabrain'
|
||||
}
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("visual-engineering")
|
||||
expect(guidance).toContain("ultrabrain")
|
||||
})
|
||||
|
||||
it("should provide fix for unknown agent with available list", () => {
|
||||
const errorInfo = {
|
||||
errorType: "unknown_agent",
|
||||
originalOutput: '❌ Unknown agent: "fake". Available agents: explore, oracle'
|
||||
}
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("explore")
|
||||
expect(guidance).toContain("oracle")
|
||||
})
|
||||
})
|
||||
})
|
||||
136
src/hooks/sisyphus-task-retry/index.ts
Normal file
136
src/hooks/sisyphus-task-retry/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export interface SisyphusTaskErrorPattern {
|
||||
pattern: string
|
||||
errorType: string
|
||||
fixHint: string
|
||||
}
|
||||
|
||||
export const SISYPHUS_TASK_ERROR_PATTERNS: SisyphusTaskErrorPattern[] = [
|
||||
{
|
||||
pattern: "run_in_background",
|
||||
errorType: "missing_run_in_background",
|
||||
fixHint: "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)",
|
||||
},
|
||||
{
|
||||
pattern: "skills",
|
||||
errorType: "missing_skills",
|
||||
fixHint: "Add skills=[] parameter (empty array if no skills needed)",
|
||||
},
|
||||
{
|
||||
pattern: "category OR subagent_type",
|
||||
errorType: "mutual_exclusion",
|
||||
fixHint: "Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')",
|
||||
},
|
||||
{
|
||||
pattern: "Must provide either category or subagent_type",
|
||||
errorType: "missing_category_or_agent",
|
||||
fixHint: "Add either category='general' OR subagent_type='explore'",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown category",
|
||||
errorType: "unknown_category",
|
||||
fixHint: "Use a valid category from the Available list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Agent name cannot be empty",
|
||||
errorType: "empty_agent",
|
||||
fixHint: "Provide a non-empty subagent_type value",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown agent",
|
||||
errorType: "unknown_agent",
|
||||
fixHint: "Use a valid agent from the Available agents list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Cannot call primary agent",
|
||||
errorType: "primary_agent",
|
||||
fixHint: "Primary agents cannot be called via sisyphus_task. Use a subagent like 'explore', 'oracle', or 'librarian'",
|
||||
},
|
||||
{
|
||||
pattern: "Skills not found",
|
||||
errorType: "unknown_skills",
|
||||
fixHint: "Use valid skill names from the Available list in the error message",
|
||||
},
|
||||
]
|
||||
|
||||
export interface DetectedError {
|
||||
errorType: string
|
||||
originalOutput: string
|
||||
}
|
||||
|
||||
export function detectSisyphusTaskError(output: string): DetectedError | null {
|
||||
if (!output.includes("❌")) return null
|
||||
|
||||
for (const errorPattern of SISYPHUS_TASK_ERROR_PATTERNS) {
|
||||
if (output.includes(errorPattern.pattern)) {
|
||||
return {
|
||||
errorType: errorPattern.errorType,
|
||||
originalOutput: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractAvailableList(output: string): string | null {
|
||||
const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m)
|
||||
return availableMatch ? availableMatch[1].trim() : null
|
||||
}
|
||||
|
||||
export function buildRetryGuidance(errorInfo: DetectedError): string {
|
||||
const pattern = SISYPHUS_TASK_ERROR_PATTERNS.find(
|
||||
(p) => p.errorType === errorInfo.errorType
|
||||
)
|
||||
|
||||
if (!pattern) {
|
||||
return `[sisyphus_task ERROR] Fix the error and retry with correct parameters.`
|
||||
}
|
||||
|
||||
let guidance = `
|
||||
[sisyphus_task CALL FAILED - IMMEDIATE RETRY REQUIRED]
|
||||
|
||||
**Error Type**: ${errorInfo.errorType}
|
||||
**Fix**: ${pattern.fixHint}
|
||||
`
|
||||
|
||||
const availableList = extractAvailableList(errorInfo.originalOutput)
|
||||
if (availableList) {
|
||||
guidance += `\n**Available Options**: ${availableList}\n`
|
||||
}
|
||||
|
||||
guidance += `
|
||||
**Action**: Retry sisyphus_task NOW with corrected parameters.
|
||||
|
||||
Example of CORRECT call:
|
||||
\`\`\`
|
||||
sisyphus_task(
|
||||
description="Task description",
|
||||
prompt="Detailed prompt...",
|
||||
category="general", // OR subagent_type="explore"
|
||||
run_in_background=false,
|
||||
skills=[]
|
||||
)
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
return guidance
|
||||
}
|
||||
|
||||
export function createSisyphusTaskRetryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "sisyphus_task") return
|
||||
|
||||
const errorInfo = detectSisyphusTaskError(output.output)
|
||||
if (errorInfo) {
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
output.output += `\n${guidance}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -236,5 +236,148 @@ describe("start-work hook", () => {
|
||||
expect(output.parts[0].text).toContain("Ask the user")
|
||||
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
|
||||
})
|
||||
|
||||
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
|
||||
// #given - existing boulder state pointing to old plan
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
// Old plan (in boulder state)
|
||||
const oldPlanPath = join(plansDir, "old-plan.md")
|
||||
writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Old Task 1")
|
||||
|
||||
// New plan (user wants this one)
|
||||
const newPlanPath = join(plansDir, "new-plan.md")
|
||||
writeFileSync(newPlanPath, "# New Plan\n- [ ] New Task 1")
|
||||
|
||||
// Set up stale boulder state pointing to old plan
|
||||
const staleState: BoulderState = {
|
||||
active_plan: oldPlanPath,
|
||||
started_at: "2026-01-01T10:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "old-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, staleState)
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
new-plan
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user explicitly specifies new-plan
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should select new-plan, NOT resume old-plan
|
||||
expect(output.parts[0].text).toContain("new-plan")
|
||||
expect(output.parts[0].text).not.toContain("RESUMING")
|
||||
expect(output.parts[0].text).not.toContain("old-plan")
|
||||
})
|
||||
|
||||
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
|
||||
// #given - plan with ultrawork keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "my-feature-plan.md")
|
||||
writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
my-feature-plan ultrawork
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user specifies plan with ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ultrawork suffix
|
||||
expect(output.parts[0].text).toContain("my-feature-plan")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should strip ulw keyword from plan name argument", async () => {
|
||||
// #given - plan with ulw keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "api-refactor.md")
|
||||
writeFileSync(planPath, "# API Refactor\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
api-refactor ulw
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ulw suffix
|
||||
expect(output.parts[0].text).toContain("api-refactor")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match plan by partial name", async () => {
|
||||
// #given - user specifies partial plan name
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "2026-01-15-feature-implementation.md")
|
||||
writeFileSync(planPath, "# Feature Implementation\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
feature-implementation
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan by partial match
|
||||
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
getPlanProgress,
|
||||
createBoulderState,
|
||||
getPlanName,
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export const HOOK_NAME = "start-work"
|
||||
|
||||
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
||||
|
||||
interface StartWorkHookInput {
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
@@ -21,6 +24,27 @@ interface StartWorkHookOutput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function extractUserRequestPlanName(promptText: string): string | null {
|
||||
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
|
||||
if (!userRequestMatch) return null
|
||||
|
||||
const rawArg = userRequestMatch[1].trim()
|
||||
if (!rawArg) return null
|
||||
|
||||
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
||||
return cleanedArg || null
|
||||
}
|
||||
|
||||
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
const lowerName = requestedName.toLowerCase()
|
||||
|
||||
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
|
||||
return partialMatch || null
|
||||
}
|
||||
|
||||
export function createStartWorkHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -51,8 +75,70 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
let contextInfo = ""
|
||||
|
||||
const explicitPlanName = extractUserRequestPlanName(promptText)
|
||||
|
||||
if (explicitPlanName) {
|
||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const allPlans = findPrometheusPlans(ctx.directory)
|
||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||
|
||||
if (matchedPlan) {
|
||||
const progress = getPlanProgress(matchedPlan)
|
||||
|
||||
if (progress.isComplete) {
|
||||
contextInfo = `
|
||||
## Plan Already Complete
|
||||
|
||||
if (existingState) {
|
||||
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||
} else {
|
||||
if (existingState) {
|
||||
clearBoulderState(ctx.directory)
|
||||
}
|
||||
const newState = createBoulderState(matchedPlan, sessionId)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
## Auto-Selected Plan
|
||||
|
||||
**Plan**: ${getPlanName(matchedPlan)}
|
||||
**Path**: ${matchedPlan}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||
**Session ID**: ${sessionId}
|
||||
**Started**: ${timestamp}
|
||||
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
} else {
|
||||
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
const planList = incompletePlans.map((p, i) => {
|
||||
const prog = getPlanProgress(p)
|
||||
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
||||
}).join("\n")
|
||||
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
|
||||
Available incomplete plans:
|
||||
${planList}
|
||||
|
||||
Ask the user which plan to work on.`
|
||||
} else {
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
No incomplete plans available. Create a new plan with: /plan "your task"`
|
||||
}
|
||||
}
|
||||
} else if (existingState) {
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
|
||||
if (!progress.isComplete) {
|
||||
@@ -78,7 +164,7 @@ Looking for new plans...`
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingState || getPlanProgress(existingState.active_plan).isComplete) {
|
||||
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
|
||||
const plans = findPrometheusPlans(ctx.directory)
|
||||
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
|
||||
|
||||
|
||||
@@ -807,4 +807,26 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #then - no continuation (API fallback detected the abort)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should pass model property in prompt call (undefined when no message context)", async () => {
|
||||
// #given - session with incomplete todos, no prior message context available
|
||||
const sessionID = "main-model-preserve"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
})
|
||||
|
||||
// #when - session goes idle and continuation is injected
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - prompt call made, model is undefined when no context (expected behavior)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
expect("model" in promptCalls[0]).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getMainSessionID, subagentSessions } from "../features/claude-code-sess
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
type ToolPermission,
|
||||
} from "../features/hook-message-injector"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
@@ -151,7 +152,18 @@ export function createTodoContinuationEnforcer(
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
interface ResolvedMessageInfo {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
async function injectContinuation(
|
||||
sessionID: string,
|
||||
incompleteCount: number,
|
||||
total: number,
|
||||
resolvedInfo?: ResolvedMessageInfo
|
||||
): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
if (state?.isRecovering) {
|
||||
@@ -159,8 +171,6 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -185,35 +195,45 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
let agentName = resolvedInfo?.agent
|
||||
let model = resolvedInfo?.model
|
||||
let tools = resolvedInfo?.tools
|
||||
|
||||
if (!agentName || !model) {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agentName = agentName ?? prevMessage?.agent
|
||||
model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined)
|
||||
tools = tools ?? prevMessage?.tools
|
||||
}
|
||||
|
||||
const agentName = prevMessage?.agent
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
const editPermission = prevMessage?.tools?.edit
|
||||
const writePermission = prevMessage?.tools?.write
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
const editPermission = tools?.edit
|
||||
const writePermission = tools?.write
|
||||
const hasWritePermission = !tools ||
|
||||
((editPermission !== false && editPermission !== "deny") &&
|
||||
(writePermission !== false && writePermission !== "deny"))
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
|
||||
|
||||
// Don't pass model - let OpenCode use session's existing lastModel
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
agent: agentName,
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
@@ -225,7 +245,12 @@ export function createTodoContinuationEnforcer(
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
|
||||
function startCountdown(
|
||||
sessionID: string,
|
||||
incompleteCount: number,
|
||||
total: number,
|
||||
resolvedInfo?: ResolvedMessageInfo
|
||||
): void {
|
||||
const state = getState(sessionID)
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
@@ -242,7 +267,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
state.countdownTimer = setTimeout(() => {
|
||||
cancelCountdown(sessionID)
|
||||
injectContinuation(sessionID, incompleteCount, total)
|
||||
injectContinuation(sessionID, incompleteCount, total, resolvedInfo)
|
||||
}, COUNTDOWN_SECONDS * 1000)
|
||||
|
||||
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
|
||||
@@ -346,15 +371,26 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
let agentName: string | undefined
|
||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const messages = (messagesResp.data ?? []) as Array<{ info?: { agent?: string } }>
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info?.agent) {
|
||||
agentName = messages[i].info?.agent
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
resolvedInfo = {
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
tools: info.tools,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -362,13 +398,13 @@ export function createTodoContinuationEnforcer(
|
||||
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName, skipAgents })
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents })
|
||||
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown(sessionID, incompleteCount, todos.length)
|
||||
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ const TRUNCATABLE_TOOLS = [
|
||||
"glob",
|
||||
"Glob",
|
||||
"safe_glob",
|
||||
"lsp_find_references",
|
||||
"lsp_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
|
||||
13
src/index.ts
13
src/index.ts
@@ -26,6 +26,7 @@ import {
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
createEditErrorRecoveryHook,
|
||||
createSisyphusTaskRetryHook,
|
||||
createTaskResumeInfoHook,
|
||||
createStartWorkHook,
|
||||
createSisyphusOrchestratorHook,
|
||||
@@ -72,7 +73,7 @@ import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
import { type HookName } from "./config";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
@@ -201,6 +202,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
|
||||
const sisyphusTaskRetry = isHookEnabled("sisyphus-task-retry")
|
||||
? createSisyphusTaskRetryHook(ctx)
|
||||
: null;
|
||||
|
||||
const startWork = isHookEnabled("start-work")
|
||||
? createStartWorkHook(ctx)
|
||||
: null;
|
||||
@@ -440,6 +445,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionAgent(sessionInfo.id);
|
||||
resetMessageCursor(sessionInfo.id);
|
||||
firstMessageVariantGate.clear(sessionInfo.id);
|
||||
await skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
@@ -548,8 +554,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
await sisyphusTaskRetry?.["tool.execute.after"](input, output);
|
||||
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);
|
||||
await taskResumeInfo["tool.execute.after"](input, output);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -154,7 +154,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
};
|
||||
|
||||
agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["Sisyphus-Junior"]
|
||||
pluginConfig.agents?.["Sisyphus-Junior"],
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
if (builderEnabled) {
|
||||
|
||||
@@ -7,6 +7,7 @@ Cross-cutting utilities for path resolution, config management, text processing,
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export
|
||||
├── agent-variant.ts # Agent model/prompt variation logic
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── command-executor.ts # Shell exec with variable expansion
|
||||
├── config-errors.ts # Global error tracking
|
||||
@@ -14,23 +15,31 @@ shared/
|
||||
├── data-path.ts # XDG data directory
|
||||
├── deep-merge.ts # Type-safe recursive merge
|
||||
├── dynamic-truncator.ts # Token-aware truncation
|
||||
├── external-plugin-detector.ts # Detect marketplace plugins
|
||||
├── file-reference-resolver.ts # @filename syntax
|
||||
├── file-utils.ts # Symlink, markdown detection
|
||||
├── first-message-variant.ts # Initial prompt variations
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook disabled
|
||||
├── jsonc-parser.ts # JSON with Comments
|
||||
├── logger.ts # File-based logging
|
||||
├── migration.ts # Legacy name compat (omo → Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── opencode-config-dir.ts # ~/.config/opencode resolution
|
||||
├── opencode-version.ts # Version comparison logic
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── permission-compat.ts # Legacy permission mapping
|
||||
├── session-cursor.ts # Track message history pointer
|
||||
├── snake-case.ts # Case conversion
|
||||
└── tool-name.ts # PascalCase normalization
|
||||
├── tool-name.ts # PascalCase normalization
|
||||
└── zip-extractor.ts # Plugin installation utility
|
||||
```
|
||||
|
||||
## WHEN TO USE
|
||||
| Task | Utility |
|
||||
|------|---------|
|
||||
| Find ~/.claude | `getClaudeConfigDir()` |
|
||||
| Find ~/.config/opencode | `getOpenCodeConfigDir()` |
|
||||
| Merge configs | `deepMerge(base, override)` |
|
||||
| Parse user files | `parseJsonc()` |
|
||||
| Check hook enabled | `isHookDisabled(name, list)` |
|
||||
@@ -38,6 +47,9 @@ shared/
|
||||
| Resolve @file | `resolveFileReferencesInText()` |
|
||||
| Execute shell | `resolveCommandsInText()` |
|
||||
| Legacy names | `migrateLegacyAgentNames()` |
|
||||
| Version check | `isOpenCodeVersionAtLeast(version)` |
|
||||
| Map permissions | `normalizePermission()` |
|
||||
| Track session | `SessionCursor` |
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
```typescript
|
||||
@@ -49,10 +61,14 @@ const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
// Safe JSONC parsing for user-edited files
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
|
||||
// Version-gated features
|
||||
if (isOpenCodeVersionAtLeast('1.0.150')) { /* ... */ }
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Hardcoding paths (use `getClaudeConfigDir`, `getUserConfigPath`)
|
||||
- Hardcoding paths (use `getClaudeConfigDir`, `getOpenCodeConfigDir`)
|
||||
- Using `JSON.parse` for user configs (always use `parseJsonc`)
|
||||
- Ignoring output size (large tool outputs MUST use `dynamicTruncate`)
|
||||
- Manual case conversion (use `toSnakeCase`, `normalizeToolName`)
|
||||
- Manual version parsing (use `opencode-version.ts` utilities)
|
||||
- Raw permission checks (use `permission-compat.ts`)
|
||||
|
||||
@@ -22,3 +22,5 @@ export * from "./permission-compat"
|
||||
export * from "./external-plugin-detector"
|
||||
export * from "./zip-extractor"
|
||||
export * from "./agent-variant"
|
||||
export * from "./session-cursor"
|
||||
export * from "./shell-env"
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("migrateAgentNames", () => {
|
||||
const agents = {
|
||||
SISYPHUS: { model: "test" },
|
||||
"planner-sisyphus": { prompt: "test" },
|
||||
"Orchestrator-Sisyphus": { model: "openai/gpt-5.2" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
@@ -63,6 +64,7 @@ describe("migrateAgentNames", () => {
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "test" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
|
||||
"document-writer": "document-writer",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"orchestrator-sisyphus": "orchestrator-sisyphus",
|
||||
}
|
||||
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
|
||||
66
src/shared/session-cursor.test.ts
Normal file
66
src/shared/session-cursor.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test"
|
||||
import { consumeNewMessages, resetMessageCursor } from "./session-cursor"
|
||||
|
||||
describe("consumeNewMessages", () => {
|
||||
const sessionID = "session-123"
|
||||
|
||||
const buildMessage = (id: string, created: number) => ({
|
||||
info: { id, time: { created } },
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetMessageCursor(sessionID)
|
||||
})
|
||||
|
||||
it("returns all messages on first read and none on repeat", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
|
||||
// #when
|
||||
const first = consumeNewMessages(sessionID, messages)
|
||||
const second = consumeNewMessages(sessionID, messages)
|
||||
|
||||
// #then
|
||||
expect(first).toEqual(messages)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it("returns only new messages after cursor advances", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const extended = [...messages, buildMessage("m3", 3)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, extended)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual([extended[2]])
|
||||
})
|
||||
|
||||
it("resets when message history shrinks", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const shorter = [buildMessage("n1", 1)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, shorter)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual(shorter)
|
||||
})
|
||||
|
||||
it("returns all messages when last key is missing", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const replaced = [buildMessage("n1", 1), buildMessage("n2", 2)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, replaced)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual(replaced)
|
||||
})
|
||||
})
|
||||
85
src/shared/session-cursor.ts
Normal file
85
src/shared/session-cursor.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
type MessageTime =
|
||||
| { created?: number | string }
|
||||
| number
|
||||
| string
|
||||
| undefined
|
||||
|
||||
type MessageInfo = {
|
||||
id?: string
|
||||
time?: MessageTime
|
||||
}
|
||||
|
||||
export type CursorMessage = {
|
||||
info?: MessageInfo
|
||||
}
|
||||
|
||||
interface CursorState {
|
||||
lastKey?: string
|
||||
lastCount: number
|
||||
}
|
||||
|
||||
const sessionCursors = new Map<string, CursorState>()
|
||||
|
||||
function buildMessageKey(message: CursorMessage, index: number): string {
|
||||
const id = message.info?.id
|
||||
if (id) return `id:${id}`
|
||||
|
||||
const time = message.info?.time
|
||||
if (typeof time === "number" || typeof time === "string") {
|
||||
return `t:${time}:${index}`
|
||||
}
|
||||
|
||||
const created = time?.created
|
||||
if (typeof created === "number") {
|
||||
return `t:${created}:${index}`
|
||||
}
|
||||
if (typeof created === "string") {
|
||||
return `t:${created}:${index}`
|
||||
}
|
||||
|
||||
return `i:${index}`
|
||||
}
|
||||
|
||||
export function consumeNewMessages<T extends CursorMessage>(
|
||||
sessionID: string | undefined,
|
||||
messages: T[]
|
||||
): T[] {
|
||||
if (!sessionID) return messages
|
||||
|
||||
const keys = messages.map((message, index) => buildMessageKey(message, index))
|
||||
const cursor = sessionCursors.get(sessionID)
|
||||
let startIndex = 0
|
||||
|
||||
if (cursor) {
|
||||
if (cursor.lastCount > messages.length) {
|
||||
startIndex = 0
|
||||
} else if (cursor.lastKey) {
|
||||
const lastIndex = keys.lastIndexOf(cursor.lastKey)
|
||||
if (lastIndex >= 0) {
|
||||
startIndex = lastIndex + 1
|
||||
} else {
|
||||
// History changed without a shrink; reset to avoid skipping messages.
|
||||
startIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
sessionCursors.delete(sessionID)
|
||||
} else {
|
||||
sessionCursors.set(sessionID, {
|
||||
lastKey: keys[keys.length - 1],
|
||||
lastCount: messages.length,
|
||||
})
|
||||
}
|
||||
|
||||
return messages.slice(startIndex)
|
||||
}
|
||||
|
||||
export function resetMessageCursor(sessionID?: string): void {
|
||||
if (sessionID) {
|
||||
sessionCursors.delete(sessionID)
|
||||
return
|
||||
}
|
||||
sessionCursors.clear()
|
||||
}
|
||||
278
src/shared/shell-env.test.ts
Normal file
278
src/shared/shell-env.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { detectShellType, shellEscape, buildEnvPrefix } from "./shell-env"
|
||||
|
||||
describe("shell-env", () => {
|
||||
let originalPlatform: NodeJS.Platform
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
originalEnv = {
|
||||
SHELL: process.env.SHELL,
|
||||
PSModulePath: process.env.PSModulePath,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("detectShellType", () => {
|
||||
test("#given SHELL env var set to /bin/bash #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given SHELL env var set to /bin/zsh #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given PSModulePath is set #when detectShellType is called #then returns powershell", () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("powershell")
|
||||
})
|
||||
|
||||
test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("cmd")
|
||||
})
|
||||
|
||||
test("#given non-Windows platform without SHELL env var #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given PSModulePath takes priority over SHELL #when both are set #then returns powershell", () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("powershell")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shellEscape", () => {
|
||||
describe("unix shell", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with unix #then returns unquoted string", () => {
|
||||
const result = shellEscape("simple123", "unix")
|
||||
expect(result).toBe("simple123")
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with unix #then returns single quotes", () => {
|
||||
const result = shellEscape("", "unix")
|
||||
expect(result).toBe("''")
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with unix #then wraps in single quotes", () => {
|
||||
const result = shellEscape("has spaces", "unix")
|
||||
expect(result).toBe("'has spaces'")
|
||||
})
|
||||
|
||||
test("#given string with single quote #when shellEscape is called with unix #then escapes with backslash", () => {
|
||||
const result = shellEscape("it's", "unix")
|
||||
expect(result).toBe("'it'\\''s'")
|
||||
})
|
||||
|
||||
test("#given string with colon and slash #when shellEscape is called with unix #then returns unquoted", () => {
|
||||
const result = shellEscape("/usr/bin:/bin", "unix")
|
||||
expect(result).toBe("/usr/bin:/bin")
|
||||
})
|
||||
|
||||
test("#given string with newline #when shellEscape is called with unix #then preserves newline in quotes", () => {
|
||||
const result = shellEscape("line1\nline2", "unix")
|
||||
expect(result).toBe("'line1\nline2'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("powershell", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with powershell #then wraps in single quotes", () => {
|
||||
const result = shellEscape("simple123", "powershell")
|
||||
expect(result).toBe("'simple123'")
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with powershell #then returns single quotes", () => {
|
||||
const result = shellEscape("", "powershell")
|
||||
expect(result).toBe("''")
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with powershell #then wraps in single quotes", () => {
|
||||
const result = shellEscape("has spaces", "powershell")
|
||||
expect(result).toBe("'has spaces'")
|
||||
})
|
||||
|
||||
test("#given string with single quote #when shellEscape is called with powershell #then escapes with double quote", () => {
|
||||
const result = shellEscape("it's", "powershell")
|
||||
expect(result).toBe("'it''s'")
|
||||
})
|
||||
|
||||
test("#given string with dollar sign #when shellEscape is called with powershell #then wraps to prevent expansion", () => {
|
||||
const result = shellEscape("$var", "powershell")
|
||||
expect(result).toBe("'$var'")
|
||||
})
|
||||
|
||||
test("#given Windows path with backslashes #when shellEscape is called with powershell #then preserves backslashes", () => {
|
||||
const result = shellEscape("C:\\path", "powershell")
|
||||
expect(result).toBe("'C:\\path'")
|
||||
})
|
||||
|
||||
test("#given string with colon #when shellEscape is called with powershell #then wraps in quotes", () => {
|
||||
const result = shellEscape("key:value", "powershell")
|
||||
expect(result).toBe("'key:value'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cmd.exe", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("simple123", "cmd")
|
||||
expect(result).toBe('"simple123"')
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with cmd #then returns double quotes", () => {
|
||||
const result = shellEscape("", "cmd")
|
||||
expect(result).toBe('""')
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("has spaces", "cmd")
|
||||
expect(result).toBe('"has spaces"')
|
||||
})
|
||||
|
||||
test("#given string with double quote #when shellEscape is called with cmd #then escapes with double quote", () => {
|
||||
const result = shellEscape('say "hello"', "cmd")
|
||||
expect(result).toBe('"say ""hello"""')
|
||||
})
|
||||
|
||||
test("#given string with percent signs #when shellEscape is called with cmd #then escapes percent signs", () => {
|
||||
const result = shellEscape("%PATH%", "cmd")
|
||||
expect(result).toBe('"%%PATH%%"')
|
||||
})
|
||||
|
||||
test("#given Windows path with backslashes #when shellEscape is called with cmd #then preserves backslashes", () => {
|
||||
const result = shellEscape("C:\\path", "cmd")
|
||||
expect(result).toBe('"C:\\path"')
|
||||
})
|
||||
|
||||
test("#given string with colon #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("key:value", "cmd")
|
||||
expect(result).toBe('"key:value"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildEnvPrefix", () => {
|
||||
describe("unix shell", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with unix #then builds export statement", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "unix")
|
||||
expect(result).toBe("export VAR=value;")
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with unix #then builds export statement with all vars", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "unix")
|
||||
expect(result).toBe("export VAR1=val1 VAR2=val2;")
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with unix #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ PATH: "/usr/bin:/bin" }, "unix")
|
||||
expect(result).toBe("export PATH=/usr/bin:/bin;")
|
||||
})
|
||||
|
||||
test("#given env var with spaces #when buildEnvPrefix is called with unix #then escapes with quotes", () => {
|
||||
const result = buildEnvPrefix({ MSG: "has spaces" }, "unix")
|
||||
expect(result).toBe("export MSG='has spaces';")
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with unix #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "unix")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("powershell", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with powershell #then builds $env assignment", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "powershell")
|
||||
expect(result).toBe("$env:VAR='value';")
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with powershell #then builds multiple assignments", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "powershell")
|
||||
expect(result).toBe("$env:VAR1='val1'; $env:VAR2='val2';")
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with powershell #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ MSG: "it's working" }, "powershell")
|
||||
expect(result).toBe("$env:MSG='it''s working';")
|
||||
})
|
||||
|
||||
test("#given env var with dollar sign #when buildEnvPrefix is called with powershell #then escapes to prevent expansion", () => {
|
||||
const result = buildEnvPrefix({ VAR: "$test" }, "powershell")
|
||||
expect(result).toBe("$env:VAR='$test';")
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with powershell #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "powershell")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cmd.exe", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with cmd #then builds set command", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "cmd")
|
||||
expect(result).toBe('set VAR="value" &&')
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with cmd #then builds multiple set commands", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "cmd")
|
||||
expect(result).toBe('set VAR1="val1" && set VAR2="val2" &&')
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with cmd #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ MSG: "has spaces" }, "cmd")
|
||||
expect(result).toBe('set MSG="has spaces" &&')
|
||||
})
|
||||
|
||||
test("#given env var with double quotes #when buildEnvPrefix is called with cmd #then escapes quotes", () => {
|
||||
const result = buildEnvPrefix({ MSG: 'say "hello"' }, "cmd")
|
||||
expect(result).toBe('set MSG="say ""hello""" &&')
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with cmd #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "cmd")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
src/shared/shell-env.ts
Normal file
111
src/shared/shell-env.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type ShellType = "unix" | "powershell" | "cmd"
|
||||
|
||||
/**
|
||||
* Detect the current shell type based on environment variables.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. PSModulePath → PowerShell
|
||||
* 2. SHELL env var → Unix shell
|
||||
* 3. Platform fallback → win32: cmd, others: unix
|
||||
*/
|
||||
export function detectShellType(): ShellType {
|
||||
if (process.env.PSModulePath) {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
if (process.env.SHELL) {
|
||||
return "unix"
|
||||
}
|
||||
|
||||
return process.platform === "win32" ? "cmd" : "unix"
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in environment variable assignment.
|
||||
*
|
||||
* @param value - The value to escape
|
||||
* @param shellType - The target shell type
|
||||
* @returns Escaped value appropriate for the shell
|
||||
*/
|
||||
export function shellEscape(value: string, shellType: ShellType): string {
|
||||
if (value === "") {
|
||||
return shellType === "cmd" ? '""' : "''"
|
||||
}
|
||||
|
||||
switch (shellType) {
|
||||
case "unix":
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
|
||||
case "powershell":
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
|
||||
case "cmd":
|
||||
// Escape % first (for environment variable expansion), then " (for quoting)
|
||||
return `"${value.replace(/%/g, '%%').replace(/"/g, '""')}"`
|
||||
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variable prefix command for the target shell.
|
||||
*
|
||||
* @param env - Record of environment variables to set
|
||||
* @param shellType - The target shell type
|
||||
* @returns Command prefix string to prepend to the actual command
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Unix: "export VAR1=val1 VAR2=val2; command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "unix")
|
||||
* // => "export VAR1=val1 VAR2=val2;"
|
||||
*
|
||||
* // PowerShell: "$env:VAR1='val1'; $env:VAR2='val2'; command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "powershell")
|
||||
* // => "$env:VAR1='val1'; $env:VAR2='val2';"
|
||||
*
|
||||
* // cmd.exe: "set VAR1=val1 && set VAR2=val2 && command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "cmd")
|
||||
* // => "set VAR1=\"val1\" && set VAR2=\"val2\" &&"
|
||||
* ```
|
||||
*/
|
||||
export function buildEnvPrefix(
|
||||
env: Record<string, string>,
|
||||
shellType: ShellType
|
||||
): string {
|
||||
const entries = Object.entries(env)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch (shellType) {
|
||||
case "unix": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `${key}=${shellEscape(value, shellType)}`)
|
||||
.join(" ")
|
||||
return `export ${assignments};`
|
||||
}
|
||||
|
||||
case "powershell": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `$env:${key}=${shellEscape(value, shellType)}`)
|
||||
.join("; ")
|
||||
return `${assignments};`
|
||||
}
|
||||
|
||||
case "cmd": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `set ${key}=${shellEscape(value, shellType)}`)
|
||||
.join(" && ")
|
||||
return `${assignments} &&`
|
||||
}
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# TOOLS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Custom tools extending agent capabilities: LSP (11 tools), AST-aware search/replace, background tasks, and multimodal analysis.
|
||||
Custom tools extending agent capabilities: LSP (7 tools), AST-aware search/replace, background tasks, and multimodal analysis.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -20,11 +20,11 @@ tools/
|
||||
│ ├── tools.ts # Tool implementations
|
||||
│ └── config.ts, types.ts, utils.ts
|
||||
├── session-manager/ # OpenCode session history management
|
||||
├── sisyphus-task/ # Category-based delegation (583 lines)
|
||||
├── sisyphus-task/ # Category-based delegation (667 lines)
|
||||
├── skill/ # Skill loading/execution
|
||||
├── skill-mcp/ # Skill-embedded MCP invocation
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export (82 lines)
|
||||
└── index.ts # builtinTools export (75 lines)
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
@@ -46,7 +46,7 @@ tools/
|
||||
## LSP SPECIFICS
|
||||
- **Lifecycle**: Lazy initialization on first call; auto-shutdown on idle.
|
||||
- **Config**: Merges `opencode.json` and `oh-my-opencode.json`.
|
||||
- **Capability**: Supports full LSP spec including `codeAction/resolve` and `prepareRename`.
|
||||
- **Capability**: Supports full LSP spec including `rename` and `prepareRename`.
|
||||
|
||||
## AST-GREP SPECIFICS
|
||||
- **Precision**: Uses tree-sitter for structural matching (avoids regex pitfalls).
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -239,11 +240,26 @@ Session ID: ${task.sessionID}
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
const duration = formatDuration(task.startedAt, task.completedAt)
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No new output since last check)`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
for (const message of newMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
|
||||
@@ -290,11 +291,17 @@ async function executeSync(
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(sessionID, sortedMessages)
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
return `No new output since last check.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
for (const message of newMessages) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const part of (message as any).parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
lsp_goto_definition,
|
||||
lsp_find_references,
|
||||
lsp_symbols,
|
||||
lsp_diagnostics,
|
||||
lsp_servers,
|
||||
lsp_prepare_rename,
|
||||
@@ -56,9 +53,6 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
||||
}
|
||||
|
||||
export const builtinTools: Record<string, ToolDefinition> = {
|
||||
lsp_goto_definition,
|
||||
lsp_find_references,
|
||||
lsp_symbols,
|
||||
lsp_diagnostics,
|
||||
lsp_servers,
|
||||
lsp_prepare_rename,
|
||||
|
||||
@@ -64,7 +64,29 @@ export const interactive_bash: ToolDefinition = tool({
|
||||
|
||||
const subcommand = parts[0].toLowerCase()
|
||||
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
|
||||
return `Error: '${parts[0]}' is blocked. Use bash tool instead for capturing/printing terminal output.`
|
||||
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
|
||||
let sessionName = "omo-session"
|
||||
if (sessionIdx !== -1) {
|
||||
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
|
||||
sessionName = parts[sessionIdx + 1]
|
||||
} else if (parts[sessionIdx].startsWith("-t")) {
|
||||
sessionName = parts[sessionIdx].slice(2)
|
||||
}
|
||||
}
|
||||
|
||||
return `Error: '${parts[0]}' is blocked in interactive_bash.
|
||||
|
||||
**USE BASH TOOL INSTEAD:**
|
||||
|
||||
\`\`\`bash
|
||||
# Capture terminal output
|
||||
tmux capture-pane -p -t ${sessionName}
|
||||
|
||||
# Or capture with history (last 1000 lines)
|
||||
tmux capture-pane -p -t ${sessionName} -S -1000
|
||||
\`\`\`
|
||||
|
||||
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([tmuxPath, ...parts], {
|
||||
|
||||
73
src/tools/look-at/tools.test.ts
Normal file
73
src/tools/look-at/tools.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeArgs, validateArgs } from "./tools"
|
||||
|
||||
describe("look-at tool", () => {
|
||||
describe("normalizeArgs", () => {
|
||||
// #given LLM이 file_path 대신 path를 사용할 수 있음
|
||||
// #when path 파라미터로 호출
|
||||
// #then file_path로 정규화되어야 함
|
||||
test("normalizes path to file_path for LLM compatibility", () => {
|
||||
const args = { path: "/some/file.png", goal: "analyze" }
|
||||
const normalized = normalizeArgs(args as any)
|
||||
expect(normalized.file_path).toBe("/some/file.png")
|
||||
expect(normalized.goal).toBe("analyze")
|
||||
})
|
||||
|
||||
// #given 정상적인 file_path 사용
|
||||
// #when file_path 파라미터로 호출
|
||||
// #then 그대로 유지
|
||||
test("keeps file_path when properly provided", () => {
|
||||
const args = { file_path: "/correct/path.pdf", goal: "extract" }
|
||||
const normalized = normalizeArgs(args)
|
||||
expect(normalized.file_path).toBe("/correct/path.pdf")
|
||||
})
|
||||
|
||||
// #given 둘 다 제공된 경우
|
||||
// #when file_path와 path 모두 있음
|
||||
// #then file_path 우선
|
||||
test("prefers file_path over path when both provided", () => {
|
||||
const args = { file_path: "/preferred.png", path: "/fallback.png", goal: "test" }
|
||||
const normalized = normalizeArgs(args as any)
|
||||
expect(normalized.file_path).toBe("/preferred.png")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateArgs", () => {
|
||||
// #given 유효한 인자
|
||||
// #when 검증
|
||||
// #then null 반환 (에러 없음)
|
||||
test("returns null for valid args", () => {
|
||||
const args = { file_path: "/valid/path.png", goal: "analyze" }
|
||||
expect(validateArgs(args)).toBeNull()
|
||||
})
|
||||
|
||||
// #given file_path 누락
|
||||
// #when 검증
|
||||
// #then 명확한 에러 메시지
|
||||
test("returns error when file_path is missing", () => {
|
||||
const args = { goal: "analyze" } as any
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("file_path")
|
||||
expect(error).toContain("required")
|
||||
})
|
||||
|
||||
// #given goal 누락
|
||||
// #when 검증
|
||||
// #then 명확한 에러 메시지
|
||||
test("returns error when goal is missing", () => {
|
||||
const args = { file_path: "/some/path.png" } as any
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("goal")
|
||||
expect(error).toContain("required")
|
||||
})
|
||||
|
||||
// #given file_path가 빈 문자열
|
||||
// #when 검증
|
||||
// #then 에러 반환
|
||||
test("returns error when file_path is empty string", () => {
|
||||
const args = { file_path: "", goal: "analyze" }
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("file_path")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,27 @@ import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface LookAtArgsWithAlias extends LookAtArgs {
|
||||
path?: string
|
||||
}
|
||||
|
||||
export function normalizeArgs(args: LookAtArgsWithAlias): LookAtArgs {
|
||||
return {
|
||||
file_path: args.file_path ?? args.path ?? "",
|
||||
goal: args.goal ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArgs(args: LookAtArgs): string | null {
|
||||
if (!args.file_path) {
|
||||
return `Error: Missing required parameter 'file_path'. Usage: look_at(file_path="/path/to/file", goal="what to extract")`
|
||||
}
|
||||
if (!args.goal) {
|
||||
return `Error: Missing required parameter 'goal'. Usage: look_at(file_path="/path/to/file", goal="what to extract")`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function inferMimeType(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
@@ -50,7 +71,14 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
|
||||
file_path: tool.schema.string().describe("Absolute path to the file to analyze"),
|
||||
goal: tool.schema.string().describe("What specific information to extract from the file"),
|
||||
},
|
||||
async execute(args: LookAtArgs, toolContext) {
|
||||
async execute(rawArgs: LookAtArgs, toolContext) {
|
||||
const args = normalizeArgs(rawArgs as LookAtArgsWithAlias)
|
||||
const validationError = validateArgs(args)
|
||||
if (validationError) {
|
||||
log(`[look_at] Validation failed: ${validationError}`)
|
||||
return validationError
|
||||
}
|
||||
|
||||
log(`[look_at] Analyzing file: ${args.file_path}, goal: ${args.goal}`)
|
||||
|
||||
const mimeType = inferMimeType(args.file_path)
|
||||
|
||||
@@ -509,46 +509,6 @@ export class LSPClient {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
|
||||
async hover(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
})
|
||||
}
|
||||
|
||||
async definition(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
})
|
||||
}
|
||||
|
||||
async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
context: { includeDeclaration },
|
||||
})
|
||||
}
|
||||
|
||||
async documentSymbols(filePath: string): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/documentSymbol", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
})
|
||||
}
|
||||
|
||||
async workspaceSymbols(query: string): Promise<unknown> {
|
||||
return this.send("workspace/symbol", { query })
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
const absPath = resolve(filePath)
|
||||
const uri = pathToFileURL(absPath).href
|
||||
@@ -587,33 +547,6 @@ export class LSPClient {
|
||||
})
|
||||
}
|
||||
|
||||
async codeAction(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
startChar: number,
|
||||
endLine: number,
|
||||
endChar: number,
|
||||
only?: string[]
|
||||
): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/codeAction", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
range: {
|
||||
start: { line: startLine - 1, character: startChar },
|
||||
end: { line: endLine - 1, character: endChar },
|
||||
},
|
||||
context: {
|
||||
diagnostics: [],
|
||||
only,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async codeActionResolve(codeAction: unknown): Promise<unknown> {
|
||||
return this.send("codeAction/resolve", codeAction)
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.proc !== null && !this.processExited && this.proc.exitCode === null
|
||||
}
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import type { LSPServerConfig } from "./types"
|
||||
|
||||
export const SYMBOL_KIND_MAP: Record<number, string> = {
|
||||
1: "File",
|
||||
2: "Module",
|
||||
3: "Namespace",
|
||||
4: "Package",
|
||||
5: "Class",
|
||||
6: "Method",
|
||||
7: "Property",
|
||||
8: "Field",
|
||||
9: "Constructor",
|
||||
10: "Enum",
|
||||
11: "Interface",
|
||||
12: "Function",
|
||||
13: "Variable",
|
||||
14: "Constant",
|
||||
15: "String",
|
||||
16: "Number",
|
||||
17: "Boolean",
|
||||
18: "Array",
|
||||
19: "Object",
|
||||
20: "Key",
|
||||
21: "Null",
|
||||
22: "EnumMember",
|
||||
23: "Struct",
|
||||
24: "Event",
|
||||
25: "Operator",
|
||||
26: "TypeParameter",
|
||||
}
|
||||
|
||||
export const SEVERITY_MAP: Record<number, string> = {
|
||||
1: "error",
|
||||
2: "warning",
|
||||
@@ -36,8 +7,6 @@ export const SEVERITY_MAP: Record<number, string> = {
|
||||
4: "hint",
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_REFERENCES = 200
|
||||
export const DEFAULT_MAX_SYMBOLS = 200
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200
|
||||
|
||||
export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
@@ -80,6 +49,7 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
|
||||
"haskell-language-server": "ghcup install hls",
|
||||
bash: "npm install -g bash-language-server",
|
||||
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
|
||||
}
|
||||
|
||||
// Synced with OpenCode's server.ts
|
||||
@@ -246,6 +216,10 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
command: ["haskell-language-server-wrapper", "--lsp"],
|
||||
extensions: [".hs", ".lhs"],
|
||||
},
|
||||
"kotlin-ls": {
|
||||
command: ["kotlin-lsp"],
|
||||
extensions: [".kt", ".kts"],
|
||||
},
|
||||
}
|
||||
|
||||
// Synced with OpenCode's language.ts
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { getAllServers } from "./config"
|
||||
import {
|
||||
DEFAULT_MAX_REFERENCES,
|
||||
DEFAULT_MAX_SYMBOLS,
|
||||
DEFAULT_MAX_DIAGNOSTICS,
|
||||
} from "./constants"
|
||||
import {
|
||||
withLspClient,
|
||||
formatLocation,
|
||||
formatDocumentSymbol,
|
||||
formatSymbolInfo,
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
@@ -17,157 +12,14 @@ import {
|
||||
formatApplyResult,
|
||||
} from "./utils"
|
||||
import type {
|
||||
Location,
|
||||
LocationLink,
|
||||
DocumentSymbol,
|
||||
SymbolInfo,
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
WorkspaceEdit,
|
||||
} from "./types"
|
||||
|
||||
|
||||
|
||||
export const lsp_goto_definition: ToolDefinition = tool({
|
||||
description: "Jump to symbol definition. Find WHERE something is defined.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
line: tool.schema.number().min(1).describe("1-based"),
|
||||
character: tool.schema.number().min(0).describe("0-based"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.definition(args.filePath, args.line, args.character)) as
|
||||
| Location
|
||||
| Location[]
|
||||
| LocationLink[]
|
||||
| null
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
const output = "No definition found"
|
||||
return output
|
||||
}
|
||||
|
||||
const locations = Array.isArray(result) ? result : [result]
|
||||
if (locations.length === 0) {
|
||||
const output = "No definition found"
|
||||
return output
|
||||
}
|
||||
|
||||
const output = locations.map(formatLocation).join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_find_references: ToolDefinition = tool({
|
||||
description: "Find ALL usages/references of a symbol across the entire workspace.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
line: tool.schema.number().min(1).describe("1-based"),
|
||||
character: tool.schema.number().min(0).describe("0-based"),
|
||||
includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as
|
||||
| Location[]
|
||||
| null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
const output = "No references found"
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
|
||||
const lines = limited.map(formatLocation)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_symbols: ToolDefinition = tool({
|
||||
description: "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.",
|
||||
args: {
|
||||
filePath: tool.schema.string().describe("File path for LSP context"),
|
||||
scope: tool.schema.enum(["document", "workspace"]).default("document").describe("'document' for file symbols, 'workspace' for project-wide search"),
|
||||
query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"),
|
||||
limit: tool.schema.number().optional().describe("Max results (default 50)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const scope = args.scope ?? "document"
|
||||
|
||||
if (scope === "workspace") {
|
||||
if (!args.query) {
|
||||
return "Error: 'query' is required for workspace scope"
|
||||
}
|
||||
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.workspaceSymbols(args.query!)) as SymbolInfo[] | null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return "No symbols found"
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = result.slice(0, limit)
|
||||
const lines = limited.map(formatSymbolInfo)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
} else {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return "No symbols found"
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = truncated ? result.slice(0, limit) : result
|
||||
|
||||
const lines: string[] = []
|
||||
if (truncated) {
|
||||
lines.push(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
// NOTE: lsp_goto_definition, lsp_find_references, lsp_symbols are removed
|
||||
// as they duplicate OpenCode's built-in LSP tools (LspGotoDefinition, LspFindReferences, LspDocumentSymbols, LspWorkspaceSymbols)
|
||||
|
||||
export const lsp_diagnostics: ToolDefinition = tool({
|
||||
description: "Get errors, warnings, hints from language server BEFORE running build.",
|
||||
|
||||
@@ -17,33 +17,6 @@ export interface Range {
|
||||
end: Position
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string
|
||||
range: Range
|
||||
}
|
||||
|
||||
export interface LocationLink {
|
||||
targetUri: string
|
||||
targetRange: Range
|
||||
targetSelectionRange: Range
|
||||
originSelectionRange?: Range
|
||||
}
|
||||
|
||||
export interface SymbolInfo {
|
||||
name: string
|
||||
kind: number
|
||||
location: Location
|
||||
containerName?: string
|
||||
}
|
||||
|
||||
export interface DocumentSymbol {
|
||||
name: string
|
||||
kind: number
|
||||
range: Range
|
||||
selectionRange: Range
|
||||
children?: DocumentSymbol[]
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range
|
||||
severity?: number
|
||||
@@ -52,14 +25,6 @@ export interface Diagnostic {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface HoverResult {
|
||||
contents:
|
||||
| { kind?: string; value: string }
|
||||
| string
|
||||
| Array<{ kind?: string; value: string } | string>
|
||||
range?: Range
|
||||
}
|
||||
|
||||
export interface TextDocumentIdentifier {
|
||||
uri: string
|
||||
}
|
||||
@@ -111,31 +76,6 @@ export interface PrepareRenameDefaultBehavior {
|
||||
defaultBehavior: boolean
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
title: string
|
||||
command: string
|
||||
arguments?: unknown[]
|
||||
}
|
||||
|
||||
export interface CodeActionContext {
|
||||
diagnostics: Diagnostic[]
|
||||
only?: string[]
|
||||
triggerKind?: CodeActionTriggerKind
|
||||
}
|
||||
|
||||
export type CodeActionTriggerKind = 1 | 2
|
||||
|
||||
export interface CodeAction {
|
||||
title: string
|
||||
kind?: string
|
||||
diagnostics?: Diagnostic[]
|
||||
isPreferred?: boolean
|
||||
disabled?: { reason: string }
|
||||
edit?: WorkspaceEdit
|
||||
command?: Command
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface ServerLookupInfo {
|
||||
id: string
|
||||
command: string[]
|
||||
|
||||
@@ -3,21 +3,14 @@ import { fileURLToPath } from "node:url"
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs"
|
||||
import { LSPClient, lspManager } from "./client"
|
||||
import { findServerForExtension } from "./config"
|
||||
import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
|
||||
import { SEVERITY_MAP } from "./constants"
|
||||
import type {
|
||||
HoverResult,
|
||||
DocumentSymbol,
|
||||
SymbolInfo,
|
||||
Location,
|
||||
LocationLink,
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
Range,
|
||||
WorkspaceEdit,
|
||||
TextEdit,
|
||||
CodeAction,
|
||||
Command,
|
||||
ServerLookupResult,
|
||||
} from "./types"
|
||||
|
||||
@@ -113,73 +106,11 @@ export async function withLspClient<T>(filePath: string, fn: (client: LSPClient)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHoverResult(result: HoverResult | null): string {
|
||||
if (!result) return "No hover information available"
|
||||
|
||||
const contents = result.contents
|
||||
if (typeof contents === "string") {
|
||||
return contents
|
||||
}
|
||||
|
||||
if (Array.isArray(contents)) {
|
||||
return contents
|
||||
.map((c) => (typeof c === "string" ? c : c.value))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
if (typeof contents === "object" && "value" in contents) {
|
||||
return contents.value
|
||||
}
|
||||
|
||||
return "No hover information available"
|
||||
}
|
||||
|
||||
export function formatLocation(loc: Location | LocationLink): string {
|
||||
if ("targetUri" in loc) {
|
||||
const uri = uriToPath(loc.targetUri)
|
||||
const line = loc.targetRange.start.line + 1
|
||||
const char = loc.targetRange.start.character
|
||||
return `${uri}:${line}:${char}`
|
||||
}
|
||||
|
||||
const uri = uriToPath(loc.uri)
|
||||
const line = loc.range.start.line + 1
|
||||
const char = loc.range.start.character
|
||||
return `${uri}:${line}:${char}`
|
||||
}
|
||||
|
||||
export function formatSymbolKind(kind: number): string {
|
||||
return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})`
|
||||
}
|
||||
|
||||
export function formatSeverity(severity: number | undefined): string {
|
||||
if (!severity) return "unknown"
|
||||
return SEVERITY_MAP[severity] || `unknown(${severity})`
|
||||
}
|
||||
|
||||
export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string {
|
||||
const prefix = " ".repeat(indent)
|
||||
const kind = formatSymbolKind(symbol.kind)
|
||||
const line = symbol.range.start.line + 1
|
||||
let result = `${prefix}${symbol.name} (${kind}) - line ${line}`
|
||||
|
||||
if (symbol.children && symbol.children.length > 0) {
|
||||
for (const child of symbol.children) {
|
||||
result += "\n" + formatDocumentSymbol(child, indent + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatSymbolInfo(symbol: SymbolInfo): string {
|
||||
const kind = formatSymbolKind(symbol.kind)
|
||||
const loc = formatLocation(symbol.location)
|
||||
const container = symbol.containerName ? ` (in ${symbol.containerName})` : ""
|
||||
return `${symbol.name} (${kind})${container} - ${loc}`
|
||||
}
|
||||
|
||||
export function formatDiagnostic(diag: Diagnostic): string {
|
||||
const severity = formatSeverity(diag.severity)
|
||||
const line = diag.range.start.line + 1
|
||||
@@ -292,38 +223,6 @@ export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatCodeAction(action: CodeAction): string {
|
||||
let result = `[${action.kind || "action"}] ${action.title}`
|
||||
|
||||
if (action.isPreferred) {
|
||||
result += " ⭐"
|
||||
}
|
||||
|
||||
if (action.disabled) {
|
||||
result += ` (disabled: ${action.disabled.reason})`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatCodeActions(actions: (CodeAction | Command)[] | null): string {
|
||||
if (!actions || actions.length === 0) return "No code actions available"
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const action = actions[i]
|
||||
|
||||
if ("command" in action && typeof action.command === "string" && !("kind" in action)) {
|
||||
lines.push(`${i + 1}. [command] ${(action as Command).title}`)
|
||||
} else {
|
||||
lines.push(`${i + 1}. ${formatCodeAction(action as CodeAction)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
success: boolean
|
||||
filesModified: string[]
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { CategoryConfig } from "../../config/schema"
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
userCategories?: Record<string, CategoryConfig>
|
||||
): { config: CategoryConfig; promptAppend: string } | null {
|
||||
options: {
|
||||
userCategories?: Record<string, CategoryConfig>
|
||||
parentModelString?: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
|
||||
const { userCategories, parentModelString, systemDefaultModel } = options
|
||||
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
|
||||
@@ -14,10 +19,11 @@ function resolveCategoryConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model: userConfig?.model ?? defaultConfig?.model ?? "anthropic/claude-sonnet-4-5",
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
@@ -27,7 +33,7 @@ function resolveCategoryConfig(
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend }
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
describe("sisyphus-task", () => {
|
||||
@@ -114,7 +120,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "unknown-category"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName)
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
@@ -125,7 +131,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName)
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@@ -141,7 +147,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@@ -159,7 +165,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@@ -179,7 +185,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@@ -199,12 +205,53 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.temperature).toBe(0.3)
|
||||
})
|
||||
|
||||
test("parentModelString is used when no user model and takes precedence over default", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("cliproxy/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("user model takes precedence over parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": { model: "my-provider/my-model" },
|
||||
}
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("my-provider/my-model")
|
||||
})
|
||||
|
||||
test("default model is used when no user model and no parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
|
||||
})
|
||||
})
|
||||
|
||||
describe("category variant", () => {
|
||||
@@ -228,6 +275,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -285,6 +333,7 @@ describe("sisyphus-task", () => {
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -352,6 +401,7 @@ describe("sisyphus-task", () => {
|
||||
],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
@@ -409,6 +459,7 @@ describe("sisyphus-task", () => {
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
}
|
||||
|
||||
const tool = createSisyphusTask({
|
||||
@@ -460,6 +511,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -489,10 +541,12 @@ describe("sisyphus-task", () => {
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return error message with the prompt error
|
||||
// #then - should return detailed error message with args and stack trace
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("Failed to send prompt")
|
||||
expect(result).toContain("Send prompt failed")
|
||||
expect(result).toContain("JSON Parse error")
|
||||
expect(result).toContain("**Arguments**:")
|
||||
expect(result).toContain("**Stack Trace**:")
|
||||
})
|
||||
|
||||
test("sync mode success returns task result with content", async () => {
|
||||
@@ -518,6 +572,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -570,6 +625,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -624,6 +680,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
}
|
||||
|
||||
@@ -665,7 +722,7 @@ describe("sisyphus-task", () => {
|
||||
const { buildSystemContent } = require("./tools")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skills: undefined, categoryPromptAppend: undefined })
|
||||
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend: undefined })
|
||||
|
||||
// #then
|
||||
expect(result).toBeUndefined()
|
||||
@@ -710,4 +767,111 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toContain("\n\n")
|
||||
})
|
||||
})
|
||||
|
||||
describe("modelInfo detection via resolveCategoryConfig", () => {
|
||||
test("when parentModelString exists but default model wins - modelInfo should report category-default", () => {
|
||||
// #given - Bug scenario: parentModelString is passed but userModel is undefined,
|
||||
// and the resolution order is: userModel ?? parentModelString ?? defaultModel
|
||||
// If parentModelString matches the resolved model, it's "inherited"
|
||||
// If defaultModel matches, it's "category-default"
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = undefined
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be defaultModel, type should be "category-default"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
expect(actualModel).toBe(defaultModel)
|
||||
expect(actualModel).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("when parentModelString is used - modelInfo should report inherited", () => {
|
||||
// #given
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be parentModelString, type should be "inherited"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
expect(actualModel).toBe(parentModelString)
|
||||
})
|
||||
|
||||
test("when user defines model - modelInfo should report user-defined regardless of parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "ultrabrain"
|
||||
const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } }
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then - actualModel should be userModel, type should be "user-defined"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
const userDefinedModel = userCategories[categoryName]?.model
|
||||
expect(actualModel).toBe(userDefinedModel)
|
||||
expect(actualModel).toBe("my-provider/custom-model")
|
||||
})
|
||||
|
||||
test("detection logic: actualModel comparison correctly identifies source", () => {
|
||||
// #given - This test verifies the fix for PR #770 bug
|
||||
// The bug was: checking `if (parentModelString)` instead of `if (actualModel === parentModelString)`
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
const userCategories = { "ultrabrain": { model: "user/model" } }
|
||||
|
||||
// #when - user model wins
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
const actualModel = resolved!.config.model
|
||||
const userDefinedModel = userCategories[categoryName]?.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
|
||||
// #then - detection should compare against actual resolved model
|
||||
const detectedType = actualModel === userDefinedModel
|
||||
? "user-defined"
|
||||
: actualModel === parentModelString
|
||||
? "inherited"
|
||||
: actualModel === defaultModel
|
||||
? "category-default"
|
||||
: undefined
|
||||
|
||||
expect(detectedType).toBe("user-defined")
|
||||
expect(actualModel).not.toBe(parentModelString)
|
||||
})
|
||||
|
||||
test("systemDefaultModel is used when no other model is available", () => {
|
||||
// #given - custom category with no model, but systemDefaultModel is set
|
||||
const categoryName = "my-custom"
|
||||
// Using type assertion since we're testing fallback behavior for categories without model
|
||||
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel })
|
||||
|
||||
// #then - actualModel should be systemDefaultModel
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBe(systemDefaultModel)
|
||||
})
|
||||
|
||||
test("model is undefined when no model available anywhere", () => {
|
||||
// #given - custom category with no model, no systemDefaultModel
|
||||
const categoryName = "my-custom"
|
||||
// Using type assertion since we're testing fallback behavior for categories without model
|
||||
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then - model should be undefined
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import type { SisyphusTaskArgs } from "./types"
|
||||
import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema"
|
||||
import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { createBuiltinSkills } from "../../features/builtin-skills/skills"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
@@ -50,6 +51,54 @@ function formatDuration(start: Date, end?: Date): string {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
interface ErrorContext {
|
||||
operation: string
|
||||
args?: SisyphusTaskArgs
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
function formatDetailedError(error: unknown, ctx: ErrorContext): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const stack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
const lines: string[] = [
|
||||
`❌ ${ctx.operation} failed`,
|
||||
"",
|
||||
`**Error**: ${message}`,
|
||||
]
|
||||
|
||||
if (ctx.sessionID) {
|
||||
lines.push(`**Session ID**: ${ctx.sessionID}`)
|
||||
}
|
||||
|
||||
if (ctx.agent) {
|
||||
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
|
||||
}
|
||||
|
||||
if (ctx.args) {
|
||||
lines.push("", "**Arguments**:")
|
||||
lines.push(`- description: "${ctx.args.description}"`)
|
||||
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
|
||||
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
|
||||
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
|
||||
lines.push(`- skills: [${ctx.args.skills?.join(", ") ?? ""}]`)
|
||||
if (ctx.args.resume) {
|
||||
lines.push(`- resume: ${ctx.args.resume}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (stack) {
|
||||
lines.push("", "**Stack Trace**:")
|
||||
lines.push("```")
|
||||
lines.push(stack.split("\n").slice(0, 10).join("\n"))
|
||||
lines.push("```")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -60,8 +109,13 @@ type ToolContextWithMetadata = {
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
userCategories?: CategoriesConfig
|
||||
): { config: CategoryConfig; promptAppend: string } | null {
|
||||
options: {
|
||||
userCategories?: CategoriesConfig
|
||||
parentModelString?: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
|
||||
const { userCategories, parentModelString, systemDefaultModel } = options
|
||||
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
|
||||
@@ -70,10 +124,13 @@ function resolveCategoryConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
// Model priority: user override > parent model (inherit) > category default > system default
|
||||
// Parent model takes precedence over category default so custom providers work out-of-box
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model: userConfig?.model ?? defaultConfig?.model ?? "anthropic/claude-sonnet-4-5",
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
@@ -83,7 +140,7 @@ function resolveCategoryConfig(
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend }
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
export interface SisyphusTaskToolOptions {
|
||||
@@ -139,9 +196,10 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini
|
||||
|
||||
let skillContent: string | undefined
|
||||
if (args.skills.length > 0) {
|
||||
const { resolved, notFound } = resolveMultipleSkills(args.skills, { gitMasterConfig })
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig })
|
||||
if (notFound.length > 0) {
|
||||
const available = createBuiltinSkills().map(s => s.name).join(", ")
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return `❌ Skills not found: ${notFound.join(", ")}. Available: ${available}`
|
||||
}
|
||||
skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
@@ -194,8 +252,11 @@ Status: ${task.status}
|
||||
Agent continues with full previous context preserved.
|
||||
Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `❌ Failed to resume task: ${message}`
|
||||
return formatDetailedError(error, {
|
||||
operation: "Resume background task",
|
||||
args,
|
||||
sessionID: args.resume,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,12 +279,30 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
})
|
||||
|
||||
try {
|
||||
const resumeMessageDir = getMessageDir(args.resume)
|
||||
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
|
||||
const resumeAgent = resumeMessage?.agent
|
||||
const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
|
||||
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
|
||||
: undefined
|
||||
let resumeAgent: string | undefined
|
||||
let resumeModel: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({ path: { id: args.resume } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
resumeAgent = info.agent
|
||||
resumeModel = info.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const resumeMessageDir = getMessageDir(args.resume)
|
||||
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
|
||||
resumeAgent = resumeMessage?.agent
|
||||
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
|
||||
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: args.resume },
|
||||
@@ -325,18 +404,66 @@ ${textContent || "(No text output)"}`
|
||||
return `❌ Invalid arguments: Must provide either category or subagent_type.`
|
||||
}
|
||||
|
||||
// Fetch OpenCode config at boundary to get system default model
|
||||
let systemDefaultModel: string | undefined
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
systemDefaultModel = (openCodeConfig as { model?: string })?.model
|
||||
} catch {
|
||||
// Config fetch failed, proceed without system default
|
||||
systemDefaultModel = undefined
|
||||
}
|
||||
|
||||
let agentToUse: string
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryPromptAppend: string | undefined
|
||||
|
||||
const parentModelString = parentModel
|
||||
? `${parentModel.providerID}/${parentModel.modelID}`
|
||||
: undefined
|
||||
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
|
||||
if (args.category) {
|
||||
const resolved = resolveCategoryConfig(args.category, userCategories)
|
||||
const resolved = resolveCategoryConfig(args.category, {
|
||||
userCategories,
|
||||
parentModelString,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolved) {
|
||||
return `❌ Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
|
||||
}
|
||||
|
||||
// Determine model source by comparing against the actual resolved model
|
||||
const actualModel = resolved.model
|
||||
const userDefinedModel = userCategories?.[args.category]?.model
|
||||
const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model
|
||||
|
||||
if (!actualModel) {
|
||||
return `❌ No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
|
||||
}
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `❌ Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
}
|
||||
|
||||
switch (actualModel) {
|
||||
case userDefinedModel:
|
||||
modelInfo = { model: actualModel, type: "user-defined" }
|
||||
break
|
||||
case parentModelString:
|
||||
modelInfo = { model: actualModel, type: "inherited" }
|
||||
break
|
||||
case categoryDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "category-default" }
|
||||
break
|
||||
case systemDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "system-default" }
|
||||
break
|
||||
}
|
||||
|
||||
agentToUse = SISYPHUS_JUNIOR_AGENT
|
||||
const parsedModel = parseModelString(resolved.config.model)
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel
|
||||
? (resolved.config.variant
|
||||
? { ...parsedModel, variant: resolved.config.variant }
|
||||
@@ -344,10 +471,11 @@ ${textContent || "(No text output)"}`
|
||||
: undefined
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
} else {
|
||||
agentToUse = args.subagent_type!.trim()
|
||||
if (!agentToUse) {
|
||||
if (!args.subagent_type?.trim()) {
|
||||
return `❌ Agent name cannot be empty.`
|
||||
}
|
||||
const agentName = args.subagent_type.trim()
|
||||
agentToUse = agentName
|
||||
|
||||
// Validate agent exists and is callable (not a primary agent)
|
||||
try {
|
||||
@@ -406,8 +534,12 @@ Status: ${task.status}
|
||||
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `❌ Failed to launch task: ${message}`
|
||||
return formatDetailedError(error, {
|
||||
operation: "Launch background task",
|
||||
args,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +580,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
agent: agentToUse,
|
||||
isBackground: false,
|
||||
skills: args.skills,
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -477,9 +610,21 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
}
|
||||
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}`
|
||||
return formatDetailedError(new Error(`Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), {
|
||||
operation: "Send prompt to agent",
|
||||
args,
|
||||
sessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}`
|
||||
return formatDetailedError(promptError, {
|
||||
operation: "Send prompt",
|
||||
args,
|
||||
sessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
|
||||
// Poll for session completion with stability detection
|
||||
@@ -600,8 +745,13 @@ ${textContent || "(No text output)"}`
|
||||
if (syncSessionID) {
|
||||
subagentSessions.delete(syncSessionID)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `❌ Task failed: ${message}`
|
||||
return formatDetailedError(error, {
|
||||
operation: "Execute task",
|
||||
args,
|
||||
sessionID: syncSessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { dirname } from "node:path"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
||||
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { getAllSkills, extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
@@ -48,9 +47,7 @@ async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
return body.trim()
|
||||
return extractSkillTemplate(skill)
|
||||
}
|
||||
|
||||
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
@@ -135,7 +132,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
if (options.skills) return options.skills
|
||||
if (cachedSkills) return cachedSkills
|
||||
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
||||
cachedSkills = await getAllSkills()
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user