fix(metis): switch primary model to claude-sonnet-4-6 + correct AGENTS.md inaccuracies

Source code change:
- src/shared/model-requirements.ts: prepend claude-sonnet-4-6 to metis fallback
  chain so Sonnet becomes the default. Opus 4.7 max remains as the immediate
  fallback for callers who want extra reasoning.
- src/shared/model-requirements.test.ts: update assertion to expect Sonnet
  primary + Opus secondary.

AGENTS.md accuracy fixes (verified against source):
- Agent modes: Sisyphus/Hephaestus are 'primary' (not 'all'); Sisyphus-Junior
  is 'subagent' (not 'all'). Confirmed via 'const MODE: AgentMode = ...' in
  each agent file. Also clarified Prometheus has no agentSources factory and
  is built via buildPrometheusAgentConfig.
- Sisyphus fallback chain: corrected order to kimi-k2.6 → k2p5 → kimi-k2.5
  → gpt-5.5 medium → glm-5 → big-pickle (was missing kimi-k2.5).
- Librarian/Explore: added missing minimax-m2.7 step between -highspeed and
  claude-haiku-4-5.
- Metis chain: removed fictitious gemini-3.1-pro entry.
- Sisyphus-Junior chain: spelled out the actual fallback (was 'user-configurable').
- Temperatures: Sisyphus/Hephaestus do not set explicit temperature (model
  default); Sisyphus-Junior is 0.1 via SISYPHUS_JUNIOR_DEFAULTS.
- Quick category default: gpt-5.4-mini (not gpt-5.4-mini-fast).

Team-mode corrections:
- Eligibility registry has 3 verdicts: eligible (sisyphus, atlas, sisyphus-junior),
  conditional (hephaestus — needs D-36 teammate permission), hard-reject
  (oracle, librarian, explore, multimodal-looker, metis, momus, prometheus).
- Schema has 11 fields, not 4: added max_messages_per_run, max_wall_clock_minutes,
  max_member_turns, base_dir, message_payload_max_bytes, recipient_unread_max_bytes,
  mailbox_poll_interval_ms.
- Hooks: 'team-session-events' is 4 sub-handlers in src/plugin/event.ts
  (team-idle-wake-hint, team-lead-orphan-handler, team-member-error-handler,
  team-member-status-handler), not a single Continuation-tier hook.
- Tier counts now show base + team-mode: ToolGuard 14/15, Transform 5/7.
- Total: 52 base hooks, 59 with team-mode.

Doc cascade for the Metis change:
- docs/guide/orchestration.md, agent-model-matching.md, installation.md
- docs/reference/configuration.md, features.md
This commit is contained in:
YeonGyu-Kim
2026-05-08 13:06:34 +09:00
parent 838b5ae216
commit 2dfa6336f5
29 changed files with 2023 additions and 104 deletions
+26 -18
View File
@@ -28,8 +28,8 @@ serverPlugin(input, options)
3. detectExternalSkillPlugin() # warn if conflicting plugin loaded
4. injectServerAuthIntoClient() # wire auth headers into shared SDK client
5. loadPluginConfig() # walk project + user JSONC → Zod safeParse → migrate
6. initializeOpenClaw() # if openclaw config present (start reply-listener daemon)
6. checkTeamModeDependencies() # if team_mode.enabled (verify git, tmux, ensure ~/.omo/teams/)
6a. initializeOpenClaw() # if openclaw config present (start reply-listener daemon)
6b. checkTeamModeDependencies() # if team_mode.enabled (verify git, tmux, ensure ~/.omo/teams/)
7. createManagers/Tools/Hooks/PluginInterface
```
@@ -50,31 +50,39 @@ loadPluginConfig(directory, ctx)
## HOOK COMPOSITION (5-tier)
Counts verified from each composer's return object. Numbers in brackets show counts when `team_mode.enabled`.
```
createHooks()
├─→ createCoreHooks()
│ ├─ createSessionHooks() # 24: contextWindowMonitor, thinkMode, ralphLoop, modelFallback,
│ │ runtimeFallback, anthropicEffort, anthropicContextWindowLimitRecovery,
│ │ autoUpdateChecker, agentUsageReminder, nonInteractiveEnv,
│ │ interactiveBashSession, editErrorRecovery, delegateTaskRetry,
│ │ startWork, prometheusMdOnly, sisyphusJuniorNotepad,
│ │ questionLabelTruncator, taskResumeInfo, noSisyphusGpt,
│ │ noHephaestusNonGpt, legacyPluginToast, sessionRecovery,
│ │ sessionNotification, preemptiveCompaction
│ ├─ createToolGuardHooks() # 14: commentChecker, toolOutputTruncator, directoryAgentsInjector,
│ │ directoryReadmeInjector, emptyTaskResponseDetector, rulesInjector,
│ │ tasksTodowriteDisabler, writeExistingFileGuard, bashFileReadGuard,
│ │ readImageResizer, todoDescriptionOverride, webfetchRedirectGuard,
│ │ hashlineReadEnhancer, jsonErrorRecovery
└─ createTransformHooks() # 5: claudeCodeHooks, keywordDetector, contextInjectorMessagesTransform,
thinkingBlockValidator, toolPairValidator
│ ├─ createSessionHooks() # 24: contextWindowMonitor, preemptiveCompaction, sessionRecovery,
│ │ sessionNotification, thinkMode, modelFallback,
│ │ anthropicContextWindowLimitRecovery, autoUpdateChecker,
│ │ agentUsageReminder, nonInteractiveEnv, interactiveBashSession,
│ │ ralphLoop, editErrorRecovery, delegateTaskRetry, startWork,
│ │ prometheusMdOnly, sisyphusJuniorNotepad, noSisyphusGpt,
│ │ noHephaestusNonGpt, questionLabelTruncator, taskResumeInfo,
│ │ anthropicEffort, runtimeFallback, legacyPluginToast
│ ├─ createToolGuardHooks() # 14 [+1 with team-mode]: commentChecker, toolOutputTruncator,
│ │ directoryAgentsInjector, directoryReadmeInjector,
│ │ emptyTaskResponseDetector, rulesInjector, tasksTodowriteDisabler,
│ │ writeExistingFileGuard, bashFileReadGuard, hashlineReadEnhancer,
│ │ jsonErrorRecovery, readImageResizer, todoDescriptionOverride,
│ webfetchRedirectGuard [+ teamToolGating]
└─ createTransformHooks() # 5 [+2 with team-mode]: claudeCodeHooks, keywordDetector,
│ contextInjectorMessagesTransform, thinkingBlockValidator,
│ toolPairValidator [+ teamModeStatusInjector, teamMailboxInjector]
├─→ createContinuationHooks() # 7: stopContinuationGuard, compactionContextInjector,
│ compactionTodoPreserver, todoContinuationEnforcer (boulder),
│ unstableAgentBabysitter, backgroundNotificationHook, atlasHook
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
Direct event handlers (src/plugin/event.ts, when team_mode.enabled): +4
team-idle-wake-hint, team-lead-orphan-handler,
team-member-error-handler, team-member-status-handler
```
Each tier produces an array of `(input, output) => void` handlers; the matching OpenCode handler iterates and calls each in registration order.
Total: 52 base, 59 with team-mode. Each tier produces an object whose values are `(input, output) => void` handlers; the matching OpenCode handler invokes them in registration order via `safeHook()` wrappers.
## SUBSYSTEM INVENTORY
+235
View File
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const mockInitConfigContext = mock(() => {})
const mockInjectServerAuthIntoClient = mock(() => {})
const mockLogLegacyPluginStartupWarning = mock(() => {})
const mockLoadPluginConfig = mock(() => ({}))
const mockIsTmuxIntegrationEnabled = mock(() => false)
const mockCreateRuntimeTmuxConfig = mock(() => ({
enabled: false,
layout: "tiled" as const,
main_pane_size: 60,
main_pane_min_width: 80,
agent_pane_min_width: 40,
isolation: "inline" as const,
}))
const mockCreateManagers = mock(() => ({
backgroundManager: { shutdown: async () => {} },
skillMcpManager: { disconnectAll: async () => {} },
configHandler: async () => {},
}))
const mockCreateTools = mock(async () => ({
mergedSkills: [],
availableSkills: [],
filteredTools: {},
}))
const mockCreateHooks = mock(() => ({
disposeHooks: () => {},
compactionContextInjector: undefined,
compactionTodoPreserver: undefined,
claudeCodeHooks: undefined,
}))
const mockCreatePluginInterface = mock(() => ({}))
const mockCreatePluginPostHog = mock(() => ({
trackActive: () => {
throw new Error("telemetry failed")
},
capture: mock(() => {}),
captureException: mock(() => {}),
shutdown: mock(async () => {}),
}))
const mockGetPostHogDistinctId = mock(() => "plugin-distinct-id")
function installModuleMocks(): void {
mock.module("./cli/config-manager/config-context", () => ({
initConfigContext: mockInitConfigContext,
}))
mock.module("./shared/external-plugin-detector", () => ({
detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })),
getSkillPluginConflictWarning: mock(() => ""),
}))
mock.module("./shared", () => ({
injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
log: mock(() => {}),
logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning,
}))
mock.module("./plugin-config", () => ({
loadPluginConfig: mockLoadPluginConfig,
}))
mock.module("./create-runtime-tmux-config", () => ({
createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig,
isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled,
}))
mock.module("./create-managers", () => ({
createManagers: mockCreateManagers,
}))
mock.module("./create-tools", () => ({
createTools: mockCreateTools,
}))
mock.module("./create-hooks", () => ({
createHooks: mockCreateHooks,
}))
mock.module("./plugin-interface", () => ({
createPluginInterface: mockCreatePluginInterface,
}))
mock.module("./plugin-state", () => ({
createModelCacheState: mock(() => ({})),
}))
mock.module("./shared/first-message-variant", () => ({
createFirstMessageVariantGate: mock(() => ({
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
})),
}))
mock.module("./openclaw", () => ({
initializeOpenClaw: mock(async () => {}),
}))
mock.module("./tools/interactive-bash", () => ({
interactive_bash: {},
startBackgroundCheck: mock(() => {}),
}))
mock.module("./tools/lsp/client", () => ({
lspManager: {
getClient: mock(async () => ({
diagnostics: mock(async () => ({ items: [] })),
})),
stopAll: mock(async () => {}),
releaseClient: mock(() => {}),
cleanupTempDirectoryClients: mock(async () => {}),
},
}))
mock.module("./shared/posthog", () => ({
createPluginPostHog: mockCreatePluginPostHog,
getPostHogDistinctId: mockGetPostHogDistinctId,
}))
mock.module("./shared/posthog-activity-state", () => ({
getPluginLoadedCaptureState: () => ({
dayUTC: "2026-04-18",
capturePluginLoaded: true,
}),
}))
}
describe("oh-my-openagent telemetry isolation", () => {
beforeEach(() => {
mock.restore()
installModuleMocks()
})
afterEach(() => {
mock.restore()
})
it("does not crash plugin load when telemetry throws", async () => {
// given
const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`)
// when
const result = await plugin.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof plugin.server>[0])
// then
expect(typeof result).toBe("object")
expect(result).not.toBeNull()
})
})
describe("oh-my-openagent plugin_loaded daily dedupe", () => {
afterEach(() => {
mock.restore()
})
async function loadPluginWithMocks(
captureMock: ReturnType<typeof mock>,
pluginLoadedState:
| { dayUTC: string; capturePluginLoaded: boolean }
| { throwError: true },
): Promise<typeof import("./index").default> {
mock.restore()
installModuleMocks()
mock.module("./shared/posthog", () => ({
createPluginPostHog: () => ({
trackActive: () => {},
capture: captureMock,
captureException: mock(() => {}),
shutdown: mock(async () => {}),
}),
getPostHogDistinctId: mockGetPostHogDistinctId,
}))
mock.module("./shared/posthog-activity-state", () => ({
getPluginLoadedCaptureState: () => {
if ("throwError" in pluginLoadedState) {
throw new Error("activity-state read failed")
}
return pluginLoadedState
},
}))
const { default: plugin } = await import(
`./index?telemetry-dedupe=${Date.now()}-${Math.random()}`
)
return plugin
}
it("emits plugin_loaded capture when capturePluginLoaded is true", async () => {
// given
const captureMock = mock(() => {})
const plugin = await loadPluginWithMocks(captureMock, {
dayUTC: "2026-04-18",
capturePluginLoaded: true,
})
// when
await plugin.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof plugin.server>[0])
// then
expect(captureMock).toHaveBeenCalledTimes(1)
const [firstCall] = captureMock.mock.calls
const [capturePayload] = firstCall as unknown as [
{ event: string; distinctId: string },
]
expect(capturePayload?.event).toBe("plugin_loaded")
expect(capturePayload?.distinctId).toBe("plugin-distinct-id")
})
it("skips plugin_loaded capture when capturePluginLoaded is false", async () => {
// given
const captureMock = mock(() => {})
const plugin = await loadPluginWithMocks(captureMock, {
dayUTC: "2026-04-18",
capturePluginLoaded: false,
})
// when
await plugin.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof plugin.server>[0])
// then
expect(captureMock).not.toHaveBeenCalled()
})
it("skips plugin_loaded capture when getPluginLoadedCaptureState throws", async () => {
// given
const captureMock = mock(() => {})
const plugin = await loadPluginWithMocks(captureMock, { throwError: true })
// when
const result = await plugin.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof plugin.server>[0])
// then
expect(captureMock).not.toHaveBeenCalled()
expect(typeof result).toBe("object")
expect(result).not.toBeNull()
})
})
+28 -16
View File
@@ -9,25 +9,27 @@ description: Developer reference for all 11 Oh My OpenAgent agent definitions, f
## OVERVIEW
Agent factories follow `createXXXAgent(model) → AgentConfig` pattern. Each has static `mode` property. Built via `buildAgent()` compositing factory + categories + skills. Built-in agent registry: [`builtin-agents.ts`](file:///Users/yeongyu/local-workspaces/omo/src/agents/builtin-agents.ts) `agentSources`. Type definition: [`types.ts`](file:///Users/yeongyu/local-workspaces/omo/src/agents/types.ts) `BuiltinAgentName` (10 names + sisyphus-junior derived = 11 distinct agents).
11 built-in agents. Type enum: [`src/config/schema/agent-names.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/agent-names.ts) `BuiltinAgentNameSchema`. 10 of them register via [`builtin-agents.ts`](file:///Users/yeongyu/local-workspaces/omo/src/agents/builtin-agents.ts) `agentSources` record (factory functions). **Prometheus is special-cased** — it has no `createPrometheusAgent` factory; instead [`prometheus-agent-config-builder.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin-handlers/prometheus-agent-config-builder.ts) constructs its config directly during `agent-config-handler` Phase 3.
All factories follow `createXXXAgent(model) → AgentConfig`. Each carries a static `mode` property (`AgentFactory` type in [`src/agents/types.ts`](file:///Users/yeongyu/local-workspaces/omo/src/agents/types.ts)). Composed via `buildAgent()`.
## AGENT INVENTORY
| Agent | Model | Temp | Mode | Fallback Chain (top of) | Purpose |
|-------|-------|------|------|--------------------------|---------|
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 → kimi-k2.6 → gpt-5.5 medium → glm-5 → big-pickle | Main orchestrator, plans + delegates |
| **Hephaestus** | gpt-5.5 medium | 0.1 | all | (GPT-only) | Autonomous deep worker — "Legitimate Craftsman" |
Modes verified from each agent file's `const MODE: AgentMode = ...` and (for Prometheus) [`prometheus-agent-config-builder.ts:100`](file:///Users/yeongyu/local-workspaces/omo/src/plugin-handlers/prometheus-agent-config-builder.ts#L100). Chains verified from [`src/shared/model-requirements.ts`](file:///Users/yeongyu/local-workspaces/omo/src/shared/model-requirements.ts).
| Agent | Default Model | Temp | Mode | Fallback (after default) | Purpose |
|-------|---------------|------|------|--------------------------|---------|
| **Sisyphus** | claude-opus-4-7 max | (model default) | primary | kimi-k2.6 → k2p5 → kimi-k2.5 → gpt-5.5 medium → glm-5 → big-pickle | Main orchestrator, plans + delegates; `thinking: { type: "enabled", budgetTokens: 32000 }` |
| **Hephaestus** | gpt-5.5 medium | (model default) | primary | (single-entry chain — `requiresProvider`: openai \| github-copilot \| venice \| opencode \| vercel) | Autonomous deep worker |
| **Oracle** | gpt-5.5 high | 0.1 | subagent | gemini-3.1-pro high → claude-opus-4-7 max → glm-5.1 | Read-only consultation |
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus → minimax-m2.7-highspeed → claude-haiku-4-5 → gpt-5.4-nano | External docs/code search |
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus → minimax-m2.7-highspeed → claude-haiku-4-5 → gpt-5.4-nano | Contextual grep |
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus → minimax-m2.7-highspeed → minimax-m2.7 → claude-haiku-4-5 → gpt-5.4-nano | External docs/code search |
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus → minimax-m2.7-highspeed → minimax-m2.7 → claude-haiku-4-5 → gpt-5.4-nano | Contextual grep |
| **Multimodal-Looker** | gpt-5.5 medium | 0.1 | subagent | kimi-k2.6 → glm-4.6v → gpt-5-nano | PDF/image analysis |
| **Metis** | claude-opus-4-7 max | **0.3** | subagent | gpt-5.5 high → gemini-3.1-pro high → glm-5.1 → k2p5 | Pre-planning consultant |
| **Metis** | claude-sonnet-4-6 | **0.3** | subagent | claude-opus-4-7 max → gpt-5.5 high → glm-5.1 → k2p5 | Pre-planning consultant |
| **Momus** | gpt-5.5 xhigh | 0.1 | subagent | claude-opus-4-7 max → gemini-3.1-pro high → glm-5.1 | Plan reviewer |
| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | kimi-k2.6 → gpt-5.5 medium → minimax-m2.7 | Todo-list orchestrator |
| **Prometheus** | claude-opus-4-7 max | 0.1 | primary | gpt-5.5 high → glm-5.1 → gemini-3.1-pro | Strategic planner (interview) |
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor |
Authoritative chains live in [`src/shared/model-requirements.ts`](file:///Users/yeongyu/local-workspaces/omo/src/shared/model-requirements.ts).
| **Prometheus** | claude-opus-4-7 max | (override-only) | primary | gpt-5.5 high → glm-5.1 → gemini-3.1-pro | Strategic planner (interview); built via `buildPrometheusAgentConfig` (not in `agentSources`) |
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 (`SISYPHUS_JUNIOR_DEFAULTS`) | subagent | kimi-k2.6 → gpt-5.5 medium → minimax-m2.7 → big-pickle | Category-spawned executor |
## TOOL RESTRICTIONS
@@ -45,7 +47,15 @@ Defined in [`src/shared/agent-tool-restrictions.ts`](file:///Users/yeongyu/local
## TEAM-MODE ELIGIBILITY
Only **sisyphus, atlas, sisyphus-junior, hephaestus** can be team members. Read-only agents (oracle, librarian, explore, multimodal-looker, metis, momus, prometheus) are rejected at TeamSpec parse. See [`team-mode/AGENTS.md`](file:///Users/yeongyu/local-workspaces/omo/src/features/team-mode/AGENTS.md).
Authoritative registry: [`AGENT_ELIGIBILITY_REGISTRY`](file:///Users/yeongyu/local-workspaces/omo/src/features/team-mode/types.ts) in `team-mode/types.ts`. Three verdict tiers:
| Verdict | Agents |
|---------|--------|
| `eligible` | sisyphus, atlas, sisyphus-junior |
| `conditional` | hephaestus (lacks `teammate: "allow"` permission by default — see D-36 / `tool-config-handler.ts`; use `subagent_type: "sisyphus"` instead) |
| `hard-reject` | oracle, librarian, explore, multimodal-looker, metis, momus, prometheus (each with a specific rejection message) |
Read-only agents are rejected at TeamSpec parse time. For those, the lead delegates via `task` (delegate-task) instead. See [`team-mode/AGENTS.md`](file:///Users/yeongyu/local-workspaces/omo/src/features/team-mode/AGENTS.md).
## STRUCTURE
@@ -94,9 +104,11 @@ Model resolution: 4-step pipeline → override → category-default → provider
## MODES
- **`primary`** — respects UI-selected model, uses fallback chain (Atlas, Prometheus)
- **`subagent`** — uses own fallback chain, ignores UI selection (Oracle, Librarian, Explore, etc.)
- **`all`** — available in both contexts (Sisyphus, Hephaestus, Sisyphus-Junior)
Definition (from [`src/agents/types.ts`](file:///Users/yeongyu/local-workspaces/omo/src/agents/types.ts)):
- **`primary`** — respects user's UI-selected model. Used by: sisyphus, hephaestus, atlas, prometheus.
- **`subagent`** — uses own fallback chain, ignores UI selection. Used by: oracle, librarian, explore, multimodal-looker, metis, momus, sisyphus-junior.
- **`all`** — declared in the type for OpenCode compatibility but no built-in agent currently uses it.
## CANONICAL ORDER
+17 -9
View File
@@ -11,11 +11,11 @@
```
config/schema/
├── oh-my-opencode-config.ts # ROOT: composes all sub-schemas
├── agent-names.ts # BuiltinAgentNameSchema (10) + sisyphus-junior
├── agent-names.ts # BuiltinAgentNameSchema enum (11 names: sisyphus, hephaestus, prometheus, oracle, librarian, explore, multimodal-looker, metis, momus, atlas, sisyphus-junior)
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
├── agent-definitions.ts # custom agent definition schema
├── categories.ts # 8 built-in + custom categories
├── hooks.ts # HookNameSchema (50+ hooks)
├── hooks.ts # HookNameSchema (53 enum values; `team-tool-gating` is the only team-* one in schema — others are wired by direct config gates)
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
├── commands.ts # BuiltinCommandNameSchema
├── experimental.ts # Feature flags incl plugin_load_timeout_ms (min 1000), task_system, max_tools
@@ -46,22 +46,30 @@ config/schema/
`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `start_work`, `_migrations`, `model_fallback`, `model_capabilities`, `openclaw`, `mcp_env_allowlist`, `keyword_detector`, **`team_mode`**, `runtime_fallback`, `dynamic_context_pruning`.
## TEAM_MODE SCHEMA
## TEAM_MODE SCHEMA (11 fields)
```jsonc
{
"team_mode": {
"enabled": false, // gate for 12 team_* tools and conditional hooks
"max_parallel_members": 4, // concurrent active members
"max_members": 8, // hard cap on team size
"tmux_visualization": false // render tmux pane layout for the team
"enabled": false, // gate for 12 team_* tools and conditional hooks
"tmux_visualization": false, // render tmux pane layout for the team
"max_parallel_members": 4, // 1..8 concurrent active members
"max_members": 8, // 1..8 hard cap on team size
"max_messages_per_run": 10000, // ≥1
"max_wall_clock_minutes": 120, // ≥1
"max_member_turns": 500, // ≥1
"base_dir": null, // override of ~/.omo/teams or <project>/.omo/teams
"message_payload_max_bytes": 32768, // ≥1024
"recipient_unread_max_bytes": 262144, // ≥1024
"mailbox_poll_interval_ms": 3000 // ≥500
}
}
```
When `enabled: true`:
- 12 `team_*` tools register
- 4 team-mode hooks activate (status injector, mailbox injector, session events, tool gating)
- 12 `team_*` tools register (`tool-registry.ts` `teamModeToolsRecord`)
- 3 team-mode hooks register conditionally: `team-mode-status-injector` + `team-mailbox-injector` (Transform tier) and `team-tool-gating` (Tool Guard tier)
- 4 team-session-event handlers register in `src/plugin/event.ts`: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler`
- `team-mode` built-in skill loads
- Doctor check `cli/doctor/checks/team-mode.ts` runs
+25 -18
View File
@@ -10,19 +10,26 @@ User docs: [`docs/guide/team-mode.md`](file:///Users/yeongyu/local-workspaces/om
## CONFIG
Full schema: [`src/config/schema/team-mode.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/team-mode.ts).
```jsonc
{
"team_mode": {
"enabled": true,
"max_parallel_members": 4, // concurrent active members
"max_members": 8, // hard cap on team size
"tmux_visualization": false // optional tmux pane layout
"enabled": false, // gate
"tmux_visualization": false, // optional tmux pane layout
"max_parallel_members": 4, // 1..8
"max_members": 8, // 1..8 hard cap
"max_messages_per_run": 10000, // 1..∞
"max_wall_clock_minutes": 120, // 1..∞
"max_member_turns": 500, // 1..∞
"base_dir": null, // optional override of ~/.omo/teams or <project>/.omo/teams
"message_payload_max_bytes": 32768, // 1024..∞ — per-message payload cap
"recipient_unread_max_bytes": 262144, // 1024..∞ — per-recipient inbox cap
"mailbox_poll_interval_ms": 3000 // 500..∞ — recipient poll cadence
}
}
```
Schema: [`src/config/schema/team-mode.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/team-mode.ts).
## 12 TEAM_* TOOLS
Registered via [`src/plugin/tool-registry.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) `teamModeToolsRecord` only when enabled.
@@ -44,14 +51,15 @@ Registered via [`src/plugin/tool-registry.ts`](file:///Users/yeongyu/local-works
## ELIGIBLE AGENTS
```
ALLOWED: sisyphus, atlas, sisyphus-junior, hephaestus
REJECTED at parse: oracle, librarian, explore, multimodal-looker, metis, momus, prometheus
```
[`AGENT_ELIGIBILITY_REGISTRY`](file:///Users/yeongyu/local-workspaces/omo/src/features/team-mode/types.ts) in `types.ts` — three verdict tiers, each with its own rejection message:
Read-only and orchestration-only agents are blocked at TeamSpec parse time. For those, the lead delegates via `task` (delegate-task) instead.
| Verdict | Agents | Notes |
|---------|--------|-------|
| `eligible` | sisyphus, atlas, sisyphus-junior | Three only |
| `conditional` | hephaestus | Lacks `teammate: "allow"` permission by default. Either apply D-36 patch (add `teammate: "allow"` in `tool-config-handler.ts`) or use `subagent_type: "sisyphus"` instead |
| `hard-reject` | oracle, librarian, explore, multimodal-looker, metis, momus, prometheus | Read-only or plan-mode-only — cannot write to mailbox; use `task` (delegate-task) instead |
Eligibility registry: [`types.ts`](file:///Users/yeongyu/local-workspaces/omo/src/features/team-mode/types.ts) `AGENT_ELIGIBILITY_REGISTRY`.
Hard-reject agents throw at TeamSpec parse with a specific message ("Agent 'X' is read-only…"). The error message points members at delegate-task as the right escape hatch.
## MEMBER KINDS
@@ -131,13 +139,12 @@ team-mode/
| Where | What |
|-------|------|
| [`src/index.ts`](file:///Users/yeongyu/local-workspaces/omo/src/index.ts) (entry) | `checkTeamModeDependencies()` + `ensureBaseDirs()` if `team_mode.enabled` |
| [`src/plugin/tool-registry.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) | `teamModeToolsRecord` gate registers 12 tools |
| `src/hooks/team-mode-status-injector/` | Injects `<team_mode_status>` block into messages |
| `src/hooks/team-mailbox-injector/` | Pulls pending mailbox messages into agent context |
| `src/hooks/team-session-events/` | React to member session lifecycle |
| `src/hooks/team-tool-gating/` | Restrict `team_*` tools by member role |
| [`src/plugin/tool-registry.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) `teamModeToolsRecord` | Registers 12 `team_*` tools |
| [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Conditionally builds `teamModeStatusInjector` (`team-mode-status-injector` hook) and `teamMailboxInjector` (`team-mailbox-injector` hook) — both Transform tier |
| [`create-tool-guard-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-tool-guard-hooks.ts) | Conditionally builds `teamToolGating` (`team-tool-gating` hook) — Tool Guard tier |
| [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Registers 4 team-session-event handlers from `src/hooks/team-session-events/`: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` |
| [`src/cli/doctor/checks/team-mode.ts`](file:///Users/yeongyu/local-workspaces/omo/src/cli/doctor/checks/team-mode.ts) | Doctor check for team-mode prerequisites |
| [`src/features/builtin-skills/skills/team-mode.ts`](file:///Users/yeongyu/local-workspaces/omo/src/features/builtin-skills/skills/team-mode.ts) | Built-in skill that documents the tools — only loaded when enabled |
| [`src/features/builtin-skills/skills/team-mode.ts`](file:///Users/yeongyu/local-workspaces/omo/src/features/builtin-skills/skills/team-mode.ts) | Built-in skill documenting the 12 tools — gated on `team_mode.enabled` |
## WHERE TO LOOK
+42
View File
@@ -0,0 +1,42 @@
import type { TmuxConfig } from "../../config/schema"
import { log } from "../../shared"
import type { TrackedSession } from "./types"
import { queryWindowState } from "./pane-state-querier"
import { executeAction } from "./action-executor"
export async function cleanupTmuxSessions(params: {
tmuxConfig: TmuxConfig
serverUrl: string
sourcePaneId: string | undefined
sessions: Map<string, TrackedSession>
stopPolling: () => void
}): Promise<void> {
params.stopPolling()
if (params.sessions.size === 0) {
log("[tmux-session-manager] cleanup complete")
return
}
log("[tmux-session-manager] closing all panes", { count: params.sessions.size })
const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null
if (state) {
const closePromises = Array.from(params.sessions.values()).map((tracked) =>
executeAction(
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
).catch((error) =>
log("[tmux-session-manager] cleanup error for pane", {
paneId: tracked.paneId,
error: String(error),
}),
),
)
await Promise.all(closePromises)
}
params.sessions.clear()
log("[tmux-session-manager] cleanup complete")
}
@@ -0,0 +1,175 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema"
import type { CapacityConfig, TrackedSession } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor"
import type { SessionCreatedEvent } from "./session-created-event"
import { createTrackedSession } from "./tracked-session-state"
type OpencodeClient = PluginInput["client"]
export interface SessionCreatedHandlerDeps {
client: OpencodeClient
tmuxConfig: TmuxConfig
serverUrl: string
sourcePaneId: string | undefined
sessions: Map<string, TrackedSession>
pendingSessions: Set<string>
isInsideTmux: () => boolean
isEnabled: () => boolean
getCapacityConfig: () => CapacityConfig
getSessionMappings: () => SessionMapping[]
waitForSessionReady: (sessionId: string) => Promise<boolean>
startPolling: () => void
}
export async function handleSessionCreated(
deps: SessionCreatedHandlerDeps,
event: SessionCreatedEvent,
): Promise<void> {
const enabled = deps.isEnabled()
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: deps.tmuxConfig.enabled,
isInsideTmux: deps.isInsideTmux(),
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,
})
if (!enabled) return
if (event.type !== "session.created") return
const info = event.properties?.info
if (!info?.id || !info?.parentID) return
const sessionId = info.id
const title = info.title ?? "Subagent"
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
log("[tmux-session-manager] session already tracked or pending", { sessionId })
return
}
if (!deps.sourcePaneId) {
log("[tmux-session-manager] no source pane id")
return
}
deps.pendingSessions.add(sessionId)
try {
const state = await queryWindowState(deps.sourcePaneId)
if (!state) {
log("[tmux-session-manager] failed to query window state")
return
}
log("[tmux-session-manager] window state queried", {
windowWidth: state.windowWidth,
mainPane: state.mainPane?.paneId,
agentPaneCount: state.agentPanes.length,
agentPanes: state.agentPanes.map((p) => p.paneId),
})
const decision = decideSpawnActions(
state,
sessionId,
title,
deps.getCapacityConfig(),
deps.getSessionMappings(),
)
log("[tmux-session-manager] spawn decision", {
canSpawn: decision.canSpawn,
reason: decision.reason,
actionCount: decision.actions.length,
actions: decision.actions.map((a) => {
if (a.type === "close") return { type: "close", paneId: a.paneId }
if (a.type === "replace") {
return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
}
return { type: "spawn", sessionId: a.sessionId }
}),
})
if (!decision.canSpawn) {
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
return
}
const result = await executeActions(decision.actions, {
config: deps.tmuxConfig,
serverUrl: deps.serverUrl,
windowState: state,
})
for (const { action, result: actionResult } of result.results) {
if (action.type === "close" && actionResult.success) {
deps.sessions.delete(action.sessionId)
log("[tmux-session-manager] removed closed session from cache", {
sessionId: action.sessionId,
})
}
if (action.type === "replace" && actionResult.success) {
deps.sessions.delete(action.oldSessionId)
log("[tmux-session-manager] removed replaced session from cache", {
oldSessionId: action.oldSessionId,
newSessionId: action.newSessionId,
})
}
}
if (!result.success || !result.spawnedPaneId) {
log("[tmux-session-manager] spawn failed", {
success: result.success,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
return
}
const sessionReady = await deps.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
sessionId,
paneId: result.spawnedPaneId,
})
await executeActions(
[{ type: "close", paneId: result.spawnedPaneId, sessionId }],
{
config: deps.tmuxConfig,
serverUrl: deps.serverUrl,
windowState: state,
},
)
return
}
deps.sessions.set(
sessionId,
createTrackedSession({
sessionId,
paneId: result.spawnedPaneId,
description: title,
}),
)
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
deps.startPolling()
} finally {
deps.pendingSessions.delete(sessionId)
}
}
@@ -0,0 +1,50 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeAction } from "./action-executor"
export interface SessionDeletedHandlerDeps {
tmuxConfig: TmuxConfig
serverUrl: string
sourcePaneId: string | undefined
sessions: Map<string, TrackedSession>
isEnabled: () => boolean
getSessionMappings: () => SessionMapping[]
stopPolling: () => void
}
export async function handleSessionDeleted(
deps: SessionDeletedHandlerDeps,
event: { sessionID: string },
): Promise<void> {
if (!deps.isEnabled()) return
if (!deps.sourcePaneId) return
const tracked = deps.sessions.get(event.sessionID)
if (!tracked) return
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
const state = await queryWindowState(deps.sourcePaneId)
if (!state) {
deps.sessions.delete(event.sessionID)
return
}
const closeAction = decideCloseAction(state, event.sessionID, deps.getSessionMappings())
if (closeAction) {
await executeAction(closeAction, {
config: deps.tmuxConfig,
serverUrl: deps.serverUrl,
windowState: state,
})
}
deps.sessions.delete(event.sessionID)
if (deps.sessions.size === 0) {
deps.stopPolling()
}
}
+23 -16
View File
@@ -8,14 +8,18 @@
## TIER COMPOSITION
| Tier | Composer | Count | When |
|------|----------|-------|------|
| **Session** | `create-session-hooks.ts` | 24 | OpenCode session lifecycle (created/idle/error/status) + chat.params + chat.message |
| **Tool Guard** | `create-tool-guard-hooks.ts` | 14 | Pre/post tool execution |
| **Transform** | `create-transform-hooks.ts` | 5 | `experimental.chat.messages.transform` |
| **Continuation** | `create-continuation-hooks.ts` | 7 | Boulder/atlas/compaction/notification |
| **Skill** | `create-skill-hooks.ts` | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) |
| **Team-mode** | conditional in registries | 4 | When `team_mode.enabled`: team-mailbox-injector, team-mode-status-injector, team-session-events, team-tool-gating |
| Tier | Composer | Base | With team-mode | Where |
|------|----------|------|----------------|-------|
| **Session** | `create-session-hooks.ts` | 24 | 24 | OpenCode session lifecycle + chat.params + chat.message |
| **Tool Guard** | `create-tool-guard-hooks.ts` | 14 | 15 | Pre/post tool execution (+1: `team-tool-gating`) |
| **Transform** | `create-transform-hooks.ts` | 5 | 7 | `experimental.chat.messages.transform` (+2: `team-mode-status-injector`, `team-mailbox-injector`) |
| **Continuation** | `create-continuation-hooks.ts` | 7 | 7 | Boulder/atlas/compaction/notification |
| **Skill** | `create-skill-hooks.ts` | 2 | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) |
| **Direct event handlers** | `src/plugin/event.ts` | 0 | +4 | `team-session-events/` sub-files: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` |
Total exposed hooks: **52 base, 59 with team-mode** (counts the 4 team-session-events handlers individually).
Hook name allowlist for `disabled_hooks`: 53 enum values in [`src/config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`. Team-session-event sub-hooks are not individually listed in the schema — they activate together with `team_mode.enabled`.
### Tier 1: Session Hooks (24)
@@ -94,16 +98,19 @@
| `categorySkillReminder` | chat.message | Hint to load skills before invoking categories |
| `autoSlashCommand` | chat.message | Auto-execute matching `/command` from user message |
### Team-mode Hooks (4, conditional)
### Team-mode Hooks (conditional, only when `team_mode.enabled: true`)
Activated only when `team_mode.enabled: true`:
| Hook | Tier | Registered In | Purpose |
|------|------|---------------|---------|
| `team-mode-status-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Inject `<team_mode_status>` block into messages |
| `team-mailbox-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Pull pending team mailbox messages into agent context |
| `team-tool-gating` | Tool Guard | [`create-tool-guard-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-tool-guard-hooks.ts) | Restrict `team_*` tools based on member role + permissions |
| `team-idle-wake-hint` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Nudge idle team members back to work |
| `team-lead-orphan-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Detect lead departure → orphan members |
| `team-member-error-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | React to member session errors |
| `team-member-status-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Track member status transitions |
| Hook | Tier | Purpose |
|------|------|---------|
| `team-mode-status-injector` | Transform | Inject `<team_mode_status>` block into messages |
| `team-mailbox-injector` | Transform | Pull pending team mailbox messages into agent context |
| `team-session-events` | Continuation | React to member session lifecycle (created/idle/deleted) |
| `team-tool-gating` | Tool Guard | Restrict `team_*` tools based on member role + permissions |
The 4 `team-session-events/` handlers live in `src/hooks/team-session-events/` (separate files: `team-idle-wake-hint.ts`, `team-lead-orphan-handler.ts`, `team-member-error-handler.ts`, `team-member-status-handler.ts`) and are wired into `src/plugin/event.ts` directly, not through a tier composer.
## STRUCTURE
+237
View File
@@ -0,0 +1,237 @@
import { describe, expect, spyOn, test } from "bun:test"
import { disposeCreatedHooks } from "./create-hooks"
import { createPluginDispose } from "./plugin-dispose"
describe("createPluginDispose", () => {
test("#given plugin with active managers and hooks #when dispose() is called #then backgroundManager.shutdown() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with active MCP connections #when dispose() is called #then skillMcpManager.disconnectAll() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with hooks that have dispose #when dispose() is called #then each hook's dispose is called", async () => {
// given
const claudeCodeHooks = {
dispose: (): void => {},
}
const commentChecker = {
dispose: (): void => {},
}
const runtimeFallback = {
dispose: (): void => {},
}
const todoContinuationEnforcer = {
dispose: (): void => {},
}
const autoSlashCommand = {
dispose: (): void => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const claudeCodeHooksDisposeSpy = spyOn(claudeCodeHooks, "dispose")
const commentCheckerDisposeSpy = spyOn(commentChecker, "dispose")
const runtimeFallbackDisposeSpy = spyOn(runtimeFallback, "dispose")
const todoContinuationEnforcerDisposeSpy = spyOn(todoContinuationEnforcer, "dispose")
const autoSlashCommandDisposeSpy = spyOn(autoSlashCommand, "dispose")
const dispose = createPluginDispose({
backgroundManager: {
shutdown: async (): Promise<void> => {},
},
skillMcpManager: {
disconnectAll: async (): Promise<void> => {},
},
lspManager,
disposeHooks: (): void => {
disposeCreatedHooks({
claudeCodeHooks,
commentChecker,
runtimeFallback,
todoContinuationEnforcer,
autoSlashCommand,
})
},
})
// when
await dispose()
// then
expect(claudeCodeHooksDisposeSpy).toHaveBeenCalledTimes(1)
expect(commentCheckerDisposeSpy).toHaveBeenCalledTimes(1)
expect(runtimeFallbackDisposeSpy).toHaveBeenCalledTimes(1)
expect(todoContinuationEnforcerDisposeSpy).toHaveBeenCalledTimes(1)
expect(autoSlashCommandDisposeSpy).toHaveBeenCalledTimes(1)
})
test("#given dispose already called #when dispose() called again #then no errors", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooks = {
run: (): void => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const stopAllSpy = spyOn(lspManager, "stopAll")
const disposeHooksSpy = spyOn(disposeHooks, "run")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: disposeHooks.run,
})
// when
await dispose()
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(stopAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksSpy).toHaveBeenCalledTimes(1)
})
test("#given backgroundManager.shutdown() throws #when dispose() is called #then skillMcpManager.disconnectAll() and disposeHooks() are still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {
throw new Error("shutdown failed")
},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooksCalls: number[] = []
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
test("#given skillMcpManager.disconnectAll() throws #when dispose() is called #then disposeHooks() is still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {
throw new Error("disconnectAll failed")
},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooksCalls: number[] = []
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
test("#given active LSP clients #when dispose runs #then lsp manager is stopped", async () => {
// given
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const stopAllSpy = spyOn(lspManager, "stopAll")
const dispose = createPluginDispose({
backgroundManager: {
shutdown: async (): Promise<void> => {},
},
skillMcpManager: {
disconnectAll: async (): Promise<void> => {},
},
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(stopAllSpy).toHaveBeenCalledTimes(1)
})
})
+51
View File
@@ -0,0 +1,51 @@
import { log } from "./shared"
export type PluginDispose = () => Promise<void>
export function createPluginDispose(args: {
backgroundManager: {
shutdown: () => void | Promise<void>
}
skillMcpManager: {
disconnectAll: () => Promise<void>
}
lspManager: {
stopAll: () => Promise<void>
}
disposeHooks: () => void
}): PluginDispose {
const { backgroundManager, skillMcpManager, lspManager, disposeHooks } = args
let disposePromise: Promise<void> | null = null
return async (): Promise<void> => {
if (disposePromise) {
await disposePromise
return
}
disposePromise = (async (): Promise<void> => {
try {
await backgroundManager.shutdown()
} catch (error) {
log("[plugin-dispose] backgroundManager.shutdown() error:", error)
}
try {
await skillMcpManager.disconnectAll()
} catch (error) {
log("[plugin-dispose] skillMcpManager.disconnectAll() error:", error)
}
try {
await lspManager.stopAll()
} catch (error) {
log("[plugin-dispose] lspManager.stopAll() error:", error)
}
try {
disposeHooks()
} catch (error) {
log("[plugin-dispose] disposeHooks() error:", error)
}
})()
await disposePromise
}
}
+8 -4
View File
@@ -176,20 +176,24 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.variant).toBe("max")
})
test("metis has claude-opus-4-7 as primary", () => {
test("metis has claude-sonnet-4-6 as primary", () => {
// #given - metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
// #when - accessing Metis requirement
// #then - claude-opus-4-7 is first
// #then - claude-sonnet-4-6 is first, claude-opus-4-7 max is the immediate fallback
expect(metis).toBeDefined()
expect(metis.fallbackChain).toBeArray()
expect(metis.fallbackChain.length).toBeGreaterThan(1)
const primary = metis.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-7")
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
expect(primary.variant).toBe("max")
expect(primary.variant).toBeUndefined()
const opusFallback = metis.fallbackChain[1]
expect(opusFallback.model).toBe("claude-opus-4-7")
expect(opusFallback.variant).toBe("max")
const openAiFallback = metis.fallbackChain.find((entry) => entry.providers.includes("openai"))
expect(openAiFallback).toEqual({
+4
View File
@@ -124,6 +124,10 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
metis: {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
+12 -12
View File
@@ -48,20 +48,20 @@ Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-worksp
## DELEGATION CATEGORIES (built-in 8)
`task` (delegate) selects model by category; categories defined in `delegate-task/constants.ts`:
`task` (delegate) selects model by category. Default category models live in provider-specific files under `src/tools/delegate-task/` and aggregate via `BUILTIN_CATEGORIES` in `builtin-categories.ts`. Authoritative fallback chains in [`src/shared/model-requirements.ts`](file:///Users/yeongyu/local-workspaces/omo/src/shared/model-requirements.ts) `CATEGORY_MODEL_REQUIREMENTS`.
| Category | Default Model | Domain |
|----------|---------------|--------|
| `visual-engineering` | gemini-3.1-pro high | Frontend, UI/UX |
| `ultrabrain` | gpt-5.5 xhigh | Hard logic / heavy reasoning |
| `deep` | gpt-5.5 medium | Autonomous multi-step problem-solving |
| `artistry` | gemini-3.1-pro high | Creative / unconventional approaches |
| `quick` | gpt-5.4-mini-fast | Trivial single-file changes |
| `unspecified-low` | claude-sonnet-4-6 | Moderate effort fallback |
| `unspecified-high` | claude-opus-4-7 max | High effort fallback |
| `writing` | gemini-3-flash | Documentation, prose |
| Category | Default Model | Source File | Domain |
|----------|---------------|-------------|--------|
| `visual-engineering` | google/gemini-3.1-pro (variant: high) | google-categories.ts | Frontend, UI/UX |
| `ultrabrain` | openai/gpt-5.5 (variant: xhigh) | openai-categories.ts | Hard logic / heavy reasoning |
| `deep` | openai/gpt-5.5 (variant: medium) | openai-categories.ts | Autonomous multi-step problem-solving |
| `artistry` | google/gemini-3.1-pro (variant: high) | google-categories.ts | Creative / unconventional approaches |
| `quick` | openai/gpt-5.4-mini | openai-categories.ts | Trivial single-file changes |
| `unspecified-low` | anthropic/claude-sonnet-4-6 | anthropic-categories.ts | Moderate effort fallback |
| `unspecified-high` | anthropic/claude-opus-4-7 (variant: max) | anthropic-categories.ts | High effort fallback |
| `writing` | kimi-for-coding/k2p5 (default) → gemini-3-flash (first fallback) | kimi-categories.ts | Documentation, prose |
User-defined categories declared in `categories: { ... }` config override and add to this set.
User-defined categories declared in `categories: { ... }` config override and extend this set.
## TOOL DIR LAYOUT
@@ -0,0 +1,63 @@
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModelID }
}
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const parts = trimmedModel.split("/")
if (parts.length < 2) {
return undefined
}
const providerID = parts[0]?.trim()
const rawModelID = parts.slice(1).join("/").trim()
if (!providerID || !rawModelID) {
return undefined
}
const parsedModel = parseVariantFromModelID(rawModelID)
if (!parsedModel.modelID) {
return undefined
}
return parsedModel.variant
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
: { providerID, modelID: parsedModel.modelID }
}
@@ -0,0 +1,40 @@
import { describe, test, expect } from "bun:test"
import { resolveCallID } from "./resolve-call-id"
import type { ToolContextWithMetadata } from "./types"
describe("resolveCallID", () => {
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
return {
sessionID: "ses_test",
messageID: "msg_test",
agent: "sisyphus",
abort: new AbortController().signal,
...overrides,
}
}
test("#given callID is set #then returns callID", () => {
const ctx = makeCtx({ callID: "call_abc" })
expect(resolveCallID(ctx)).toBe("call_abc")
})
test("#given only callId is set #then returns callId", () => {
const ctx = makeCtx({ callId: "call_def" })
expect(resolveCallID(ctx)).toBe("call_def")
})
test("#given only call_id is set #then returns call_id", () => {
const ctx = makeCtx({ call_id: "call_ghi" })
expect(resolveCallID(ctx)).toBe("call_ghi")
})
test("#given callID and callId are both set #then prefers callID", () => {
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
expect(resolveCallID(ctx)).toBe("preferred")
})
test("#given no call ID variants are set #then returns undefined", () => {
const ctx = makeCtx()
expect(resolveCallID(ctx)).toBeUndefined()
})
})
@@ -0,0 +1,5 @@
import type { ToolContextWithMetadata } from "./types"
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
return ctx.callID ?? ctx.callId ?? ctx.call_id
}