- Replace the simple scheduledDelays array with an activeTimers Map so
that clearTimeout removes timers from the tracked set.
- This prevents false positives when internal withDispatchTimeout calls
setTimeout for safety timeouts that are immediately cancelled.
- Keeps the test intent unchanged: only genuinely scheduled retries are
counted as delayed duplicate retries.
- Wrap isSessionActive in withDispatchTimeout (capped at 5s) so a
stuck OpenCode SDK status() call cannot block internal prompts forever.
- Catch the timeout and treat session as inactive so the prompt can
proceed rather than hanging indefinitely.
- Add regression test: session.status that never resolves now times out
and allows dispatch instead of hanging the test (and production).
Refs: AGENTS.md internal-message-injection safety note
The SDK client disables fetch timeout (req.timeout = false). When the
opencode server is slow or unresponsive, client.session.status() hangs
forever, blocking the entire dispatchToHooks chain (sequential await)
and deadlocking the plugin.
Add a 5s timeout wrapper around the status call in isSessionActive().
On timeout, the catch block returns false (treat as inactive), letting
the caller proceed instead of hanging indefinitely.
The timeout parameter is exposed for testing to avoid 5s test delays.
Refs: .debugging/status-timeout-hang.md
Red: a43215f24 introduced plugin/build-team-idle-wake-hint-client.ts which
accesses session.promptAsync for method binding. The audit test flagged it
as a raw prompt route offender, breaking CI on dev.
Green: Add the narrow client facade to RAW_PROMPT_ALLOWLIST with the same
justification pattern used for event.ts and recover-unavailable-tool.ts.
The facade binds SDK methods back to the Session instance and performs
no direct dispatch itself; all downstream calls flow through the shared
prompt-async gate.
Verification: bun test src/shared/prompt-async-route-audit.test.ts passes
(6 pass, 0 fail, offenders list empty).
The team-mode wiring at `createEventHandler` extracted
`pluginContext.client.session.promptAsync` and `.status` into a fresh
wrapper object. The methods were copied by reference, so the prompt-async
gate's `session.promptAsync.bind(session)` was binding to that plain
wrapper rather than the underlying SDK `Session` instance. The opencode
SDK's `promptAsync` reads `this._client.post(...)`, so production calls
threw `TypeError: undefined is not an object (evaluating 'this._client')`
on the very first dispatch — fingerprinted in /tmp/oh-my-opencode.log as
688 `background-agent-parent-wake`, 47 `model-suggestion-retry`, and 4
`team-idle-wake-hint` failures over the past three days.
Move the wrapper construction into `buildTeamIdleWakeHintClient`, which
preserves the narrow factory contract while binding both methods back to
the SDK `Session` so `_client` survives the dispatch. Cover the contract
with four BDD-style tests including the historical destructure-only
failure mode so any future regression is caught at unit-test time.
The rule scan cache stored a path[] keyed by (projectRoot|startDir|
skipClaudeUserRules), so two issues stacked up on every tracked tool
call:
- Cache hits still ran safeRealpathSync(realpathSync) and re-derived
isGlobal / distance / isSingleFile for every cached path. That is a
per-candidate sync syscall plus repeated string-prefix walks.
- Sibling files in the same project landed under different startDir
keys, so the entire walk-and-recursive-scan chain repeated even
though every ancestor rule directory was identical.
Store the full RuleFileCandidate[] in the per-call cache so a cache hit
returns immediately with no realpath syscall. Add a separate per-
directory scan cache (getDirScan/setDirScan) keyed by absolute rule
directory path, so two sibling files reuse the same readdir + realpath
work for every shared ancestor.
Microbench (200 files / 20 modules / cached session):
- single sweep: 41.8ms -> 2.5ms (16x)
- 3-pass replay: 88.6ms -> 3.2ms (28x)
Pin the new invariants with two new tests:
- 'does not re-resolve symlinked rule path on cache hit' via a
retargeted directory symlink.
- 'reuses ancestor directory scan for sibling files in the same
project' by deleting the source rule file between the two calls.
findProjectRoot was keyed by exact startPath, so sibling files in the
same project repeated the entire upward marker walk. The walk does one
existsSync per marker per ancestor directory, which adds up on every
read/write/edit/multiedit tool call.
Track every directory visited during the walk and seed the cache with
the resolved root for each of them. Subsequent lookups for any
descendant short-circuit to the cached ancestor without re-running
marker probes. Cache invalidation still happens on session.deleted /
session.compacted, so production semantics are unchanged.
Pin the new contract via a sibling-startpath test, and make the
existing finder.test.ts beforeEach explicit about cache state so the
more aggressive cache does not leak between tests.
External review on PR #4074 noted that adding setSessionAgent for child
sessions left sessionAgentMap holding entries after the session was
deleted or after the sync call_omo_agent executor cleaned up other
owned state. The map only grows; entries never get reused but they do
accumulate across long-running plugin instances.
Close both gaps:
- BackgroundManager.handleEvent for session.deleted now calls
clearSessionAgent for the deleted session id on both the early-return
no-task branch and the cascade tail. This pairs with the existing
clearDelegatedChildSessionBootstrap and SessionCategoryRegistry.remove
so all owned session state is dropped together.
- sync-executor finally for createdSessionForExecution now calls
clearSessionAgent alongside the existing subagentSessions,
syncSubagentSessions, and deleteSessionTools cleanup so sessions this
executor created cannot leak their agent mapping.
Adds focused tests:
- BackgroundManager.handleEvent - session.deleted cascade > should
clear session agent state for deleted sessions to prevent map leak
- executeSync > registers child-session bootstrap and tracked prompt
state before sync prompt dispatch (extended assertion for cleanup)
Two adjacent gaps cubic flagged on the previous diff:
1. spawner.startTask stored input.agent (potentially prefixed with sort
marker and ZWSP) in setSessionAgent, but the prompt body used the
stripped/normalized form. The session-agent registry therefore did
not match what promptAsync actually dispatched. Capture the
normalized agent once at the top of startTask and use it for
setSessionAgent plus the launch log lines.
2. manager.startTask wrote setSessionAgent(sessionID, input.agent)
before the cancelled and stale-attempt cleanup branches, but those
branches only cleared subagentSessions and the delegated bootstrap.
The session->agent mapping survived as orphan state after an aborted
launch. Call clearSessionAgent inside both early-return paths so
nothing remains tied to a session we just aborted.
Adds focused tests for both: spawner persistence parity with promptAsync
and manager cancellation cleanup leaving getSessionAgent undefined.
Tests that mock an AgentFactory were using an `as AgentFactory` cast
and a separate mutation of `mockFactory.mode` to satisfy the type.
Replace with Object.assign so the factory type is constructed correctly
without casts. Also type the empty discoveredSkills fixture so its
element type is inferred from the function signature instead of
collapsing to never[].
Two call sites built the sync delegate tool gate independently:
sync-prompt-sender's prompt body construction and sync-task's bootstrap
registration. Drift between them would let bootstrap claim one tool set
while the actual prompt sent a different one. Extract buildSyncPromptTools
and route both call sites through it so the registered bootstrap and the
dispatched prompt always agree.
call_omo_agent sync path created the child OpenCode session and went
straight into promptAsync without registering child session agent,
session tools, or bootstrap state. If first dispatch failed before any
durable user message persisted, runtime fallback could not reconstruct
the original prompt or the agent identity for that child session.
Bind setSessionAgent and setSessionTools to the child session id with
the same tool restrictions that the prompt body sends, register a
delegated child session bootstrap with the prompt text, fallback chain,
and prompt tools, then clean bootstrap + session tools in finally for
sessions this call created.
Three coupled gaps surfaced after the initial spawn fix:
1. fallback-retry-handler dropped task.skillContent and
task.sessionPermission when rebuilding LaunchInput, so the retried
background task lost the delegated system prompt and question-deny
permission rule.
2. manager.startTask never bound the child sessionID to the resolved
agent via setSessionAgent, leaving runtime fallback and other hooks
with no idea which agent owned the new child session.
3. The fallback-to-general path in spawner.ts rebuilt the prompt body
without going through buildFallbackBody, so bootstrap state, session
tools, and session agent updates drifted apart.
Persist skillContent and sessionPermission on BackgroundTask, bind
setSessionAgent/updateSessionAgent at session creation and on fallback,
and route the FALLBACK_AGENT retry through buildFallbackBody so the
prompt body, bootstrap tools, and session registries all agree.
When the first prompt fails before any durable user message persists,
runtime fallback retry was rebuilding the request from parts alone and
losing the delegated agent system prompt and tool gates. Now it threads
bootstrap.system and bootstrap.tools into the retry body alongside the
captured retry parts, so the retried prompt keeps the same scope as the
initial delegate launch.
Add optional system and tools fields to DelegatedChildSessionBootstrap
so callers can stash the original delegated context alongside retry
parts. Backward compatible - existing callers stay unchanged.
- script/run-ci-tests.ts: CI test sharding and isolation logic
- script/run-ci-tests.test.ts: tests for CI test target selection
- src/features/background-agent/session-route.ts: session prompt routing for background agents
- src/hooks/interactive-bash-session/parser.ts: interactive bash output parser
- src/hooks/ralph-loop/completion-promise-detector-test-input.ts: test fixture for completion promise detection
Adds a new `notepad-write-guard` hook that intercepts Write tool calls
whose target path matches `**/.sisyphus/notepads/**` and throws an
actionable error instead of allowing the write to proceed.
Without this guard, an agent that hits an Edit hash-mismatch failure
could silently fall back to Write, destroying the entire history of an
append-only notepad file (decisions.md, issues.md, etc.). The file
carries an explicit "NEVER overwrite" warning that the agent ignores
under context pressure.
The guard is path-based so it works regardless of plan name or nesting
depth. Non-notepad `.sisyphus/**` paths (e.g. plan files) are
unaffected. The hook is wired into `create-tool-guard-hooks` under the
hook name `notepad-write-guard` and follows the same safeCreateHook +
HookName schema pattern as every other tool-guard hook.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add optional `displayName` field to AgentOverrideConfigSchema (next to
the existing `color` field) so users can specify localized agent names
in oh-my-openagent.json:
{ "agents": { "sisyphus": { "displayName": "总指挥" } } }
When set, the override takes precedence everywhere
AGENT_DISPLAY_NAMES[agentName] is used — TUI agent selector, the
agent list key, and the internal `name` field. When not set, behavior
is identical to before (hardcoded English names from AGENT_DISPLAY_NAMES).
Implementation touches:
- AgentOverrideConfigSchema: adds displayName?: z.string().optional()
- getAgentDisplayName / getAgentListDisplayName: accept optional overrides
map and check displayName before the hardcoded table
- remapAgentKeysToDisplayNames: forwards overrides map to name resolution
- agent-config-handler: passes pluginConfig.agents as the overrides map
Backward compatible — existing configs without displayName continue to
work unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
oh-my-openagent injects MCP servers (websearch, context7, grep_app) at
runtime via the OpenCode plugin API. The `opencode mcp list` command reads
only OpenCode's static config and therefore reports no servers even though
the plugin MCPs are active — this is expected, not a bug.
Add a "Native vs plugin-injected MCPs" subsection to docs/reference/features.md
that explains the three-tier architecture, shows the visibility table, and
points users to `bunx oh-my-openagent doctor --verbose` for runtime
inspection. Add brief inline notes in README.md at both MCP bullet points.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two P0 fixes for the assistant loop that repeats its final summary 3-5 times
after all todos are marked completed before stagnation detection finally halts it.
P0.1 — session-level stop flag: when handleSessionIdle detects incompleteCount===0
it now sets state.allTodosCompletedAt. Subsequent idle events for the same session
bail out immediately at the top of the function before any HTTP fetch or injection
logic runs, preventing the re-entry loop regardless of todo-fetch caching latency.
The flag is cleared by resetContinuationProgress so sessions that receive new todos
after completion resume enforcement normally.
P0.2 — snapshot comparison scope: getTodoSnapshot now only serialises the
{id → status} mapping (sorted by key). Content and priority changes are excluded
from the comparison. Previously those fields were included, causing hasTodoSnapshotChanged
to return true whenever the LLM re-wrote todo text with identical status — which
reported progressSource="todo" and reset stagnationCount to 0, preventing
MAX_STAGNATION_COUNT=3 from ever being reached.
P1 fixes (CONTINUATION_PROMPT adversarial wording, 10 s completion grace period)
are deferred to a follow-up PR as noted in the issue.
Preserve delegated child prompt/bootstrap metadata for early runtime fallback before OpenCode has persisted the first user turn. Bind prompt gate calls to the SDK session receiver and keep completed background task lookup visible across plugin manager instances.
When a team-mode subagent hit a fallback model (rate limit / quota
exhaustion on the primary), the fallback continuation started a
fresh subagent session that was not registered in the team's
member registry under the original role. Subsequent
team_send_message / team_status calls from the fallback agent
threw "not in team" because the membership lookup missed.
Capture teamRunId + member identity at fallback initiation and
carry them onto the fallback session so the fallback agent
remains a first-class team participant. If preservation is not
possible, surface a bounded structured error instead of letting
the runtime fail mid-flight with a confusing membership message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When oh-my-opencode created a delegated tmux pane, terminal
capability/color probe replies emitted by tmux or the freshly
attaching opencode session could end up in the caller pane's
input buffer instead of being consumed by the delegated pane,
appearing as literal text in the main OpenCode chat (e.g.
"414/21212a2/...").
Root cause: buildSplitArgs in team-layout-tmux/layout.ts called
split-window without the -d (detached/don't-switch-focus) flag.
Without -d, tmux briefly grants focus to the new pane during
creation; the outer terminal then sends DA1/DA2 and OSC color
probe replies into what it believes is the active pane, but the
focus handoff races and those bytes land in the caller pane's
stdin buffer instead.
Fix: add -d to every split-window call in buildSplitArgs, matching
the same flag already used in pane-spawn.ts for inline subagent
panes. This keeps the caller pane focused throughout the delegated
pane lifecycle so probe replies are consumed by the correct target.
Existing tests pass; one new test asserts -d is present on every
split-window call to guard this invariant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a hard-reject agent (e.g. prometheus) called team_create with an
explicit `lead` in the spec, the eligibility check that runs in the
no-lead branch of shouldReuseCallerLeadSession was bypassed. The
caller session was never registered in the team, the spawned lead
ran as a detached child, and replies routed to the spawned lead
never reached the caller — the caller became an orphan that could
send but never receive.
Move the caller eligibility guard to the top of team_create.execute
so it runs unconditionally before any team-run state mutates. Throw
an actionable error naming the agent and explaining hard-reject
agents cannot lead teams regardless of an explicit `lead` in the
spec.
When a team member task errored, the failure stayed in the member's
internal state and the main/coordinator agent's wait/status loop
kept polling indefinitely — the run stalled with no visible error.
Emit a structured `member_error` peer_message into the team mailbox
on member error transition, naming the member and including the
underlying error text so the main agent's next team_status (or
pending-message read) returns a terminal failure instead of an
empty in-progress poll.
Regression test asserts the failure is visible in the main agent's
view after a member task errors mid-execution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When ctx.serverUrl had a port string of "0", TmuxSessionManager
silently replaced it with the localhost:4096 fallback and
createTeamLayout subsequently skipped pane creation without any
user-visible signal. The two-step silent failure made team_mode
tmux_visualization look broken in default TUI mode.
Surface the failure path:
- TmuxSessionManager now retains ctx.serverUrl on the instance and
exposes it via getCtxServerUrl(), and emits a structured warning
log on the port-0 fallback branch naming both the discarded URL
and the fallback it landed on.
- createTeamLayout's "opencode server not reachable" log is
upgraded to a structured warning including ctxServerUrl and a
hint to launch with --port N + OPENCODE_PORT=N.
No behavior change to the fallback resolution itself - only the
silence. Existing port-0 fallback tests still pass; two new tests
assert the warning fires on port 0 and is absent for real ports.
Add optional enabled_expansions field to keyword_detector config schema.
When set, acts as an allowlist - only those expansion types fire.
Empty array disables all expansions. Absent field keeps all enabled (backward-compatible).
Also supports coexistence with disabled_keywords denylist for fine-grained control.
- src/config/schema/keyword-detector.ts: add enabled_expansions field
- src/hooks/keyword-detector/detector.ts: apply allowlist filter in detectKeywordsWithType
- src/hooks/keyword-detector/hook.ts: pass enabled_expansions from config
- src/hooks/keyword-detector/index.test.ts: add 4 tests for enabled_expansions behavior
- assets/oh-my-opencode.schema.json: regenerate schema