Merge pull request #4068 from code-yeongyu/feat/pre-publish-fix-v420

v4.2.0: pre-publish review fixes (BLOCKER-1..3, HIGH-5..10, MID-11/12)
This commit is contained in:
YeonGyu-Kim
2026-05-16 14:50:54 +09:00
committed by GitHub
19 changed files with 1707 additions and 726 deletions
+39
View File
@@ -0,0 +1,39 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.2.0] - 2026-05-15
### Added
- `createPluginModule` test seam moved out of public API surface to `src/testing/create-plugin-module.ts`. New public exports for the prompt-async-gate primitives: `promptAsyncAfterSessionIdle`, `promptAfterSessionIdle`, `releasePromptAsyncReservation`, `DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS`, `DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS`.
- `ParentWakeNotifier` module (`src/features/background-agent/parent-wake-notifier.ts`) extracted from `BackgroundManager`. Background-agent parent-wake state now lives in its own narrow class with dependency-injected client, directory, and notification enqueue callback.
### Changed
- `prompt-async-gate` now uses a shared internal runner for both sync (`prompt`) and async (`promptAsync`) dispatch wrappers, deduplicating the reserve/settle/check/dispatch/hold/release flow.
- `releasePromptAsyncReservation` accepts `reservedByPrefix` only when the prefix ends in `:` (e.g., `model-fallback:`), preventing accidental release of sibling reservations whose source merely starts with the same identifier characters.
- Version bump from 4.1.2 to 4.2.0. Reason: added public exports for the gate primitives qualify as MINOR per semver. No removals or breaking signature changes.
### Fixed
- `prompt-async-gate`: dispatch timeout via `Promise.race` with a default 30s window. Previously a hung `promptAsync` deadlocked the gate for that sessionID until process restart. (BLOCKER-1)
- `prompt-async-gate`: post-dispatch failure now keeps the reservation hold regardless of whether `promptAsync` resolved or threw. AGENTS.md's documented race window ("returns before durably accepted, later failures arrive as `session.error`") is now covered. (BLOCKER-2)
- `prompt-async-gate.test.ts`: replaced `setTimeout`-based synchronization with event-driven patterns to comply with the new `.sisyphus/rules/test-discipline.md` rule. (BLOCKER-3)
- `model-suggestion-retry`: releases the reservation before the suggested-model retry so the second attempt can dispatch immediately. Without this, BLOCKER-2's post-dispatch hold trapped the retry path.
### Internal
- `prompt-async-route-audit.test.ts` migrated to TypeScript compiler API for AST-based detection. Catches destructuring, bracket access, optional chaining, and type-cast aliasing bypass patterns. Two existing production callers are documented in `RAW_PROMPT_ALLOWLIST` with justifications: `src/plugin/event.ts` (team-idle-wake-hint client facade) and `src/hooks/session-recovery/recover-unavailable-tool.ts` (capability check before gate-routed dispatch). (HIGH-5)
- New `mock-module-lifecycle-audit.test.ts` enforces cleanup pairing for `mock.module(...)` calls in test files; existing offenders allowlisted with TODO references. (HIGH-10)
- `.sisyphus/rules/test-discipline.md` added in this release window forbidding `setTimeout(resolve, N)` and `await sleep(N)` in test bodies unless time is the SUT. Several CI sharding commits earlier in the window were superseded by removing the sharded runner in favor of the rule.
### Known Issues
- **Delegated child-session early-failure fallback (BLOCKER-4)**: PR #3825's `fac90d69f` was reverted by PR #4044 because its own regression test failed on clean root `bun test`. The delegate-task fallback bug for empty session history remains unaddressed in v4.2.0. Reland targets v4.2.1 once the regression test is stabilized against post-#4032 schema and the new gate semantics. See `docs/reference/known-issues.md` for details and workaround.
- **First-prompt watchdog supersession history (L16)**: PR #3952 was superseded by PR #4051 (rebased over #4007/factory refactor with `internallyAbortedSessions` threading). The supersession represents conflict resolution, not a feature pivot. The final watchdog logic shipped via #4051 + `a130fa70d` covers subagent first-prompt silence past 90 seconds with cleanup via session.deleted.
[4.2.0]: https://github.com/code-yeongyu/oh-my-openagent/compare/v4.1.2...v4.2.0
+29
View File
@@ -0,0 +1,29 @@
# Known Issues
Tracks bugs that are present in the current release but have been intentionally deferred. Each entry should explain the symptom, the history, any workaround, and the planned resolution.
## v4.2.0 - Delegate-task early-failure-fallback (BLOCKER-4, deferred from PR #3825)
### Symptom
A delegated child session that fails on its very first `promptAsync` call (for example, the provider rejects the request before any session history is persisted) may not advance to the configured fallback models. The session ends in early failure instead of retrying with the next fallback in the chain.
This affects subagents launched via the delegate-task tool (background or sync) where the first provider call fails immediately and `session.messages` is still empty.
### History
PR #3825 (`tw-yshuang/fix/delegated-child-session-early-failure-fallback`, merged as `cd33f3a39` and then `fac90d69f` on 2026-05-07) introduced a shared bootstrap context (`src/shared/delegated-child-session-bootstrap.ts`) to capture the retry payload before the first prompt dispatch, so empty-history failures could still retry with the fallback chain.
After the merge landed on `dev`, the PR's own regression test (`delegated child-session empty-history fallback retries with captured bootstrap prompt` in `src/hooks/runtime-fallback/index.test.ts`) failed on a clean root `bun test --timeout 30000` run (6828 pass / 1 fail). PR #4044 (`code-yeongyu/revert/3825-delegated-bootstrap`, revert commit `3c7d1299a`, merge-revert commit `e2b8e49e2`, merged on 2026-05-15) reverted the merge to keep `dev` green (6823 pass / 0 fail / 6 skip across 709 files).
The original failure-mode the PR targets remains in v4.2.0.
### Workaround
- For delegated subagents, prefer providers that succeed reliably on the first call (rarely fail with auth/quota errors at request time).
- Configure fallback models conservatively in `categories[].fallback_models` and accept that the very first failure may not auto-retry.
- The existing runtime-fallback persisted-history retry path still works after the subagent produces any history.
### Tracking
Issue #4059 tracks the reland with stabilized regression coverage. The reland is deferred to a follow-up release and should account for current schema-shape changes plus prompt-async-gate semantics.
+237
View File
@@ -0,0 +1,237 @@
# ADR: prompt-async-gate - reservation-based duplicate-injection guard
## Status
Accepted (introduced in v4.2.0)
## Context
Issue #4012 reported duplicate streaming output after OMO injected an
internal message into a live OpenCode session.
The user-visible failure was two assistant bubbles streaming the same
continuation.
The root race was not one hook making one bad decision. Multiple internal
routes could observe the same idle, completion, or error edge and each decide
that the parent session needed a wake or recovery prompt.
The most important race window was:
1. OpenCode emitted a `session.idle` event.
2. OMO started an `isSessionActive` HTTP poll.
3. OpenCode was still pacing the streaming animation for the previous answer.
4. The poll observed an inactive or idle-looking session.
5. OMO injected a continuation prompt.
6. A second hook observed the same edge and injected again.
7. The user saw two assistant bubbles.
The historical race site was visible in the built bundle at
`dist/index.js:69665-69680`. That code checked session activity before sending
an internal prompt, but the check and the prompt were not protected by a
shared reservation.
OpenCode's `prompt_async` route contributed to the failure mode because it has
fire-and-forget semantics. `session.promptAsync` can resolve before the prompt
is durably accepted by the target session. A later `session.error` event can
still arrive for the same attempt, so the caller can believe dispatch finished
while a recovery hook still treats the session as eligible for retry.
OMO has 13+ internal hook callers that can inject prompts, including:
- background task parent wakes
- runtime fallback retries
- model suggestion retries
- team mailbox live delivery
- session recovery continuations
- todo continuation resumes
- CLI run resumes
- Claude Code hook injections
- sync subagent prompts
- background subagent prompts
Route-local guards cannot close this race. Each route can be correct in
isolation and still collide with another route in the same process.
The root `AGENTS.md` now records the governing invariant in the section
"Internal message injection is dangerous": production code may call
`session.prompt` or `session.promptAsync` only inside
`src/shared/prompt-async-gate.ts`. Every other route must use the shared gate.
## Decision
Create `src/shared/prompt-async-gate.ts` as the single production owner of raw
OpenCode prompt dispatch.
The gate exposes the public wrappers that production callers must use:
```ts
export function promptAsyncAfterSessionIdle(
options: PromptAsyncAfterSessionIdleOptions,
): Promise<PromptAsyncGateResult>
export function promptAfterSessionIdle(
options: PromptAfterSessionIdleOptions,
): Promise<PromptAsyncGateResult>
```
The gate coordinates callers with a module-global reservation map:
```ts
const reservations = new Map<string, Reservation>()
```
The map is keyed by `sessionID`. A reservation records the source that claimed
the session, an expiration time, and a `Symbol(source)` token. The token gives
each reservation identity beyond its text source.
Every caller supplies a stable `source` string such as:
```ts
const source = `background-agent:${taskID}`
```
The shared flow is:
1. Prune expired reservations.
2. Reserve the session before waiting or dispatching.
3. Wait for the idle settle period.
4. Poll session activity unless the route has a proven opt-out.
5. Dispatch through the selected OpenCode prompt API.
6. Keep the reservation during the post-dispatch hold.
7. Release after the hold or through an explicit recovery path.
The reservation is taken before the activity poll so that two hooks cannot both
enter the poll-dispatch window.
The default post-dispatch hold is exported as:
```ts
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
```
`postDispatchHoldMs` defaults to 250 ms. The gate holds the reservation briefly
after the dispatch attempt even when dispatch throws synchronously or returns a
failed result. This closes the AGENTS.md hazard where `promptAsync` returns
before durable acceptance and a late OpenCode error races with retry logic.
The default dispatch timeout is 30 seconds:
```ts
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
```
`dispatchTimeoutMs` wraps the underlying `session.promptAsync` or
`session.prompt` call with `Promise.race`. A hung OpenCode API call must fail
closed instead of holding a reservation forever.
Both public gate helpers delegate to one internal runner:
```ts
dispatchAfterSessionIdle<TInput>(args)
```
`promptAsyncAfterSessionIdle` passes a `session.promptAsync` dispatcher.
`promptAfterSessionIdle` passes a `session.prompt` dispatcher. Sharing the
runner keeps reservation, hold, timeout, logging, and active-session behavior
identical for async and sync prompt routes.
The public gate result is a discriminated union. Callers must treat `active`
and `reserved` as successful suppression, not automatic retry signals. A route
that changed optimistic task or loop state before dispatch owns restoring that
state when the gate returns `failed`, `unavailable`, or a skipped status that
requires rollback.
The gate exposes `releasePromptAsyncReservation` for intentional recovery
paths. Prefix release is deliberately tight:
```ts
export function releasePromptAsyncReservation(
sessionID: string,
options?: {
reservedBy?: string
reservedByPrefix?: string
},
): boolean
releasePromptAsyncReservation(sessionID, {
reservedByPrefix: "runtime-fallback:",
})
```
`reservedByPrefix` must end in `:`. This prevents broad releases such as
`runtime` matching unrelated sources. Exact source release remains available
for callers that know the full reservation source.
Raw prompt calls outside the gate are blocked by
`src/shared/prompt-async-route-audit.test.ts`. The audit uses the TypeScript
Compiler API rather than regex so it catches destructuring, bracket access,
optional chaining, and aliased or cast access patterns.
## Consequences
### Positive
- Duplicate internal prompt injection now has one reservation winner per
session.
- The post-dispatch hold closes the AGENTS.md "returns before durably
accepted" hazard even when dispatch errors synchronously.
- Dispatch timeout prevents a stuck OpenCode call from holding the gate forever.
- 13+ internal hook callers share one result model and one safety primitive.
- The AST-based audit from HIGH-5 catches more bypass shapes than the prior
regex audit.
- Route-specific tests can focus on route behavior while the shared gate tests
reservation semantics.
### Negative
- Caller-side retry logic that releases and retries must call
`releasePromptAsyncReservation` explicitly when the original prompt did not
durably reach the server. `src/shared/model-suggestion-retry.ts` is the
reference case.
- 13+ wiring sites each need to be conscious of the gate result. Treating
`reserved` as a failure can create noisy retries.
- A valid retry can be delayed by the default 250 ms post-dispatch hold.
- The reservation map is process-local. It protects OMO hooks in the current
plugin process, not every possible OpenCode process.
### Migration
Existing `session.prompt` and `session.promptAsync` callers must route through
`promptAfterSessionIdle` or `promptAsyncAfterSessionIdle`.
Existing production callers were wired through the introduction PR #4034.
The AST-based audit fails CI if a raw prompt call is added without an allowlist
entry. Any allowlist entry must explain why the raw access is not a dispatch
route or why it is still gate-routed.
New internal message routes must include duplicate-injection regression tests
for their trigger. Static policy alone is not enough.
### Future work
- Replace prefix-tightened release with full Symbol-token-based release
ownership. This is the HIGH-7 deferred work.
- Define same-source concurrent caller handling. Some routes may need collapse
semantics by source rather than by session only.
- Add dispatch metrics for observability, including reservation win, reserved
skip, active skip, timeout, and failed dispatch counts.
- Consider cross-process coordination if OpenCode exposes a durable session
lock or idempotency key.
## References
- Issue #4012: duplicate streaming output and two assistant bubbles.
- PR #4034: introduction of `prompt-async-gate`.
- Commit `b333a5280`: `fix(prompt-async-gate): add dispatch timeout, shared runner, harden prefix release`.
- Commit `8c4cc09de`: `test(prompt-async-route-audit): migrate to TypeScript AST walker`.
- Commit `ff1b15d53`: `fix(model-suggestion-retry): release reservation before retry attempt`.
- Commit `f93d7297c`: `test(prompt-async-gate): cover dispatch timeout and post-dispatch error hold`.
- PR #3866 -> PR #4053: schema-compatible synthetic tool results for
post-compaction recovery, related to safe recovery dispatch.
- Root `AGENTS.md`: section "Internal message injection is dangerous".
- `.sisyphus/rules/test-discipline.md`: forbids `setTimeout(resolve, N)` and
`await sleep(N)` in tests unless time itself is the system under test.
- Implementation: `src/shared/prompt-async-gate.ts`.
- Audit: `src/shared/prompt-async-route-audit.test.ts`.
+30
View File
@@ -0,0 +1,30 @@
# Release Process
This reference records release gates that are not covered by CI alone.
## Standard Release Gates
Before publishing a release, maintainers verify:
- Version bump and package metadata are present on the release branch.
- Targeted tests for changed code pass.
- `bun run typecheck` passes.
- User-facing documentation covers new public behavior.
- Known issues are documented before the release notes are finalized.
CI green is required for release readiness, but CI does not replace manual verification for bugs whose reproducer depends on timing, providers, models, or external OpenCode behavior.
## Post-Fix Repro Verification
Race-condition and concurrency fixes must include reporter-verified repro confirmation before the originating issue is closed. CI green is necessary but not sufficient for this class of fix.
### Checklist
- [ ] Original issue reporter (or maintainer if reporter unavailable) re-runs the documented reproducer against the fix commit.
- [ ] Re-run result documented in the issue thread as "Repro retested: PASS/FAIL on commit <SHA>".
- [ ] If repro is environmental (specific OS, model, provider), repro is attempted in matching environment.
- [ ] If repro cannot be obtained, this is explicitly noted in the issue close comment AND recorded in release notes as "Fix unverified end-to-end".
### Rationale
Race-condition fixes that pass CI but were never retested against the original reproducer have historically regressed in production. Issues #4006, #3996, #3962 are recent examples where reporter confirmation was sparse. Issue #4012 (the prompt-async-gate motivating bug) had detailed reporter analysis that drove the eventual fix, and that level of post-fix verification should be the norm for this class.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "4.1.2",
"version": "4.2.0",
"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",
@@ -233,11 +233,15 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
}
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
return (cast<{ pendingParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).pendingParentWakes
return (cast<{
parentWakeNotifier: { getPendingParentWakes: () => Map<string, PendingParentWakeForTest> }
}>(manager)).parentWakeNotifier.getPendingParentWakes()
}
function getDispatchedParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
return (cast<{ dispatchedParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).dispatchedParentWakes
return (cast<{
parentWakeNotifier: { getDispatchedParentWakes: () => Map<string, PendingParentWakeForTest> }
}>(manager)).parentWakeNotifier.getDispatchedParentWakes()
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
+22 -386
View File
@@ -37,7 +37,7 @@ import {
type QueueItem,
} from "./constants"
import { resolveRegisteredAgentName, subagentSessions } from "../claude-code-session-state"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { formatDuration } from "./duration-formatter"
import {
@@ -63,10 +63,7 @@ import {
} from "./attempt-lifecycle"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import {
isSessionActive as isOpenCodeSessionActive,
settleAfterSessionIdle,
} from "../../hooks/shared/session-idle-settle"
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
import {
findNearestMessageExcludingCompaction,
@@ -96,39 +93,9 @@ import {
resolveSubagentSpawnContext,
type SubagentSpawnContext,
} from "./subagent-spawn-limits"
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
type OpencodeClient = PluginInput["client"]
type ParentWakePromptContext = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
}
type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
shouldReply: boolean
dispatchedAt?: number
toolCallDeferralStartedAt?: number
}
type ParentWakeSessionMessage = {
info?: {
role?: string
finish?: string
time?: { created?: unknown }
}
role?: string
finish?: string
time?: { created?: unknown }
parts?: Array<{
type?: string
text?: string
content?: unknown
}>
}
type ResumeTaskSnapshot = {
status: BackgroundTask["status"]
completedAt?: Date
@@ -272,10 +239,7 @@ export class BackgroundManager {
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private readonly parentWakeNotifier: ParentWakeNotifier
private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number>
@@ -306,6 +270,19 @@ export class BackgroundManager {
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
this.modelFallbackControllerAccessor = options?.modelFallbackControllerAccessor
this.logger = options?.log ?? log
this.parentWakeNotifier = new ParentWakeNotifier(
{
client: this.client,
directory: this.directory,
enqueueNotificationForParent: this.enqueueNotificationForParent.bind(this),
},
{
pendingRetryMs: PENDING_PARENT_WAKE_RETRY_MS,
acceptedMessageSkewMs: PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS,
toolCallDeferMaxMs: PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS,
failureRequeueWindowMs: PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS,
},
)
this.registerProcessCleanup()
}
@@ -1383,222 +1360,12 @@ The fallback retry session is now created and can be inspected directly.
this.observedOutputSessions.add(sessionID)
}
private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext {
const resolvedAgent = resolveRegisteredAgentName(promptContext.agent)
return {
...promptContext,
...(resolvedAgent ? { agent: resolvedAgent } : {}),
...(promptContext.model ? { model: { ...promptContext.model } } : {}),
...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}),
}
}
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
const promptContext = this.resolveParentWakePromptContext(wake.promptContext)
return {
promptContext,
notifications: [...wake.notifications],
shouldReply: wake.shouldReply,
...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}),
...(wake.toolCallDeferralStartedAt !== undefined
? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt }
: {}),
}
}
private clearDispatchedParentWake(sessionID: string): void {
const timer = this.dispatchedParentWakeTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
this.dispatchedParentWakeTimers.delete(sessionID)
}
this.dispatchedParentWakes.delete(sessionID)
}
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
this.clearDispatchedParentWake(sessionID)
const dispatchedWake = this.cloneParentWake(wake)
dispatchedWake.dispatchedAt = Date.now()
this.dispatchedParentWakes.set(sessionID, dispatchedWake)
const timer = setTimeout(() => {
this.dispatchedParentWakeTimers.delete(sessionID)
this.dispatchedParentWakes.delete(sessionID)
}, PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS)
this.dispatchedParentWakeTimers.set(sessionID, timer)
this.parentWakeNotifier.clearDispatchedParentWake(sessionID)
}
private async requeueDispatchedParentWake(sessionID: string, reason: string): Promise<boolean> {
const wake = this.dispatchedParentWakes.get(sessionID)
if (!wake) {
return false
}
await settleAfterSessionIdle()
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) {
this.clearDispatchedParentWake(sessionID)
log("[background-agent] Ignored late parent wake failure after assistant output:", {
sessionID,
reason,
})
return false
}
this.clearDispatchedParentWake(sessionID)
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.unshift(...wake.notifications)
pendingWake.shouldReply = pendingWake.shouldReply || wake.shouldReply
pendingWake.promptContext = wake.promptContext
pendingWake.toolCallDeferralStartedAt ??= wake.toolCallDeferralStartedAt
} else {
this.pendingParentWakes.set(sessionID, this.cloneParentWake(wake))
}
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
sessionID,
reason,
})
return true
}
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
try {
const messagesResp = await messagesInDirectory(this.client, {
path: { id: sessionID },
}, this.directory)
return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[])
} catch (error) {
log("[background-agent] Failed to inspect parent session messages for wake safety:", {
sessionID,
error,
})
return []
}
}
private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined {
return message.info?.role ?? message.role
}
private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined {
return message.info?.finish ?? message.finish
}
private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined {
const value = message.info?.time?.created ?? message.time?.created
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : undefined
}
if (value instanceof Date) {
return value.getTime()
}
return undefined
}
private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
if (!message) {
continue
}
const role = this.getParentWakeMessageRole(message)
if (role === "assistant") {
return this.getParentWakeMessageFinish(message) === "tool-calls"
}
if (role === "user") {
return false
}
}
return false
}
private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean {
const role = this.getParentWakeMessageRole(message)
if (role !== "assistant" && role !== "tool") {
return false
}
if (!message.parts || message.parts.length === 0) {
return role === "assistant"
}
return message.parts.some((part) => {
if (part.type === "text" || part.type === "reasoning") {
return typeof part.text === "string" && part.text.trim().length > 0
}
if (part.type === "tool" || part.type === "tool_result") {
return true
}
if (part.content !== undefined) {
if (typeof part.content === "string") {
return part.content.trim().length > 0
}
if (Array.isArray(part.content)) {
return part.content.length > 0
}
return true
}
return false
})
}
private parentWakeMessageContainsNotification(
message: ParentWakeSessionMessage,
wake: PendingParentWake,
): boolean {
if (this.getParentWakeMessageRole(message) !== "user") {
return false
}
return message.parts?.some((part) =>
typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification))
) ?? false
}
private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise<boolean> {
const messages = await this.loadParentWakeSessionMessages(sessionID)
if (!this.latestAssistantTurnIsWaitingOnTools(messages)) {
delete wake.toolCallDeferralStartedAt
return false
}
const now = Date.now()
wake.toolCallDeferralStartedAt ??= now
if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS) {
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
sessionID,
})
return false
}
log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", {
sessionID,
})
return true
}
private async hasAcceptedMessageAfterDispatchedParentWake(
sessionID: string,
wake: PendingParentWake,
): Promise<boolean> {
if (wake.dispatchedAt === undefined) {
return false
}
const dispatchedAt = wake.dispatchedAt
const messages = await this.loadParentWakeSessionMessages(sessionID)
return messages.some((message) => {
const createdAt = this.getParentWakeMessageCreatedAt(message)
if (createdAt === undefined) {
return false
}
if (
createdAt >= dispatchedAt - PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS
&& this.parentWakeMessageContainsNotification(message, wake)
) {
return true
}
return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message)
})
return this.parentWakeNotifier.requeueDispatchedParentWake(sessionID, reason)
}
private clearSessionOutputObserved(sessionID: string): void {
@@ -2697,132 +2464,11 @@ The task was re-queued on a fallback model after a retryable failure.
shouldReply: boolean,
delayMs?: number,
): void {
const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext)
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.push(notification)
pendingWake.promptContext = resolvedPromptContext
pendingWake.shouldReply = pendingWake.shouldReply || shouldReply
} else {
this.pendingParentWakes.set(sessionID, {
promptContext: resolvedPromptContext,
notifications: [notification],
shouldReply,
})
}
this.schedulePendingParentWakeFlush(sessionID, delayMs)
this.parentWakeNotifier.queuePendingParentWake(sessionID, notification, promptContext, shouldReply, delayMs)
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
if (!this.pendingParentWakes.has(sessionID)) {
this.clearPendingParentWakeTimer(sessionID)
return
}
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
const latestWake = this.pendingParentWakes.get(sessionID)
if (!latestWake) {
return
}
if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
const notificationContent = latestWake.notifications.join("\n\n")
try {
const promptResult = await promptAsyncAfterSessionIdle({
client: this.client,
sessionID,
source: "background-agent-parent-wake",
settleMs: 0,
postDispatchHoldMs: 250,
input: {
path: { id: sessionID },
body: {
noReply: !latestWake.shouldReply,
...latestWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
query: { directory: this.directory },
},
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.unshift(...latestWake.notifications)
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
pendingWake.promptContext = latestWake.promptContext
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
} else {
this.pendingParentWakes.set(sessionID, latestWake)
}
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
sessionID,
status: promptResult.status,
})
return
}
log("[background-agent] Sent deferred parent wake:", { sessionID })
this.trackDispatchedParentWake(sessionID, latestWake)
} catch (error) {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.unshift(...latestWake.notifications)
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
pendingWake.promptContext = latestWake.promptContext
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
} else {
this.pendingParentWakes.set(sessionID, latestWake)
}
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
private schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
if (this.pendingParentWakeTimers.has(sessionID)) {
return
}
const timer = setTimeout(() => {
this.pendingParentWakeTimers.delete(sessionID)
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
})
}, delayMs ?? PENDING_PARENT_WAKE_RETRY_MS)
this.pendingParentWakeTimers.set(sessionID, timer)
}
private clearPendingParentWakeTimer(sessionID: string): void {
const timer = this.pendingParentWakeTimers.get(sessionID)
if (!timer) {
return
}
clearTimeout(timer)
this.pendingParentWakeTimers.delete(sessionID)
await this.parentWakeNotifier.flushPendingParentWake(sessionID)
}
private hasRunningTasks(): boolean {
@@ -3145,15 +2791,7 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.idleDeferralTimers.clear()
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const timer of this.dispatchedParentWakeTimers.values()) {
clearTimeout(timer)
}
this.dispatchedParentWakeTimers.clear()
this.parentWakeNotifier.shutdown()
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
@@ -3166,8 +2804,6 @@ The task was re-queued on a fallback model after a retryable failure.
this.notifications.clear()
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.pendingParentWakes.clear()
this.dispatchedParentWakes.clear()
this.notificationQueueByParent.clear()
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
@@ -0,0 +1,432 @@
import { resolveRegisteredAgentName } from "../claude-code-session-state"
import { createInternalAgentTextPart, log, messagesInDirectory, normalizeSDKResponse } from "../../shared"
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
import type { PluginInput } from "@opencode-ai/plugin"
type OpencodeClient = PluginInput["client"]
export type ParentWakePromptContext = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
}
export type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
shouldReply: boolean
dispatchedAt?: number
toolCallDeferralStartedAt?: number
}
type ParentWakeSessionMessage = {
info?: {
role?: string
finish?: string
time?: { created?: unknown }
}
role?: string
finish?: string
time?: { created?: unknown }
parts?: Array<{
type?: string
text?: string
content?: unknown
}>
}
type ParentWakeNotifierDeps = {
client: OpencodeClient
directory: string
enqueueNotificationForParent: (parentSessionID: string | undefined, operation: () => Promise<void>) => Promise<void>
}
type ParentWakeNotifierOptions = {
pendingRetryMs: number
acceptedMessageSkewMs: number
toolCallDeferMaxMs: number
failureRequeueWindowMs: number
}
export class ParentWakeNotifier {
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
constructor(
private readonly deps: ParentWakeNotifierDeps,
private readonly options: ParentWakeNotifierOptions,
) {}
getPendingParentWakes(): Map<string, PendingParentWake> {
return this.pendingParentWakes
}
getPendingParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
return this.pendingParentWakeTimers
}
getDispatchedParentWakes(): Map<string, PendingParentWake> {
return this.dispatchedParentWakes
}
getDispatchedParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
return this.dispatchedParentWakeTimers
}
queuePendingParentWake(
sessionID: string,
notification: string,
promptContext: ParentWakePromptContext,
shouldReply: boolean,
delayMs?: number,
): void {
const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext)
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.push(notification)
pendingWake.promptContext = resolvedPromptContext
pendingWake.shouldReply = pendingWake.shouldReply || shouldReply
} else {
this.pendingParentWakes.set(sessionID, {
promptContext: resolvedPromptContext,
notifications: [notification],
shouldReply,
})
}
this.schedulePendingParentWakeFlush(sessionID, delayMs)
}
async flushPendingParentWake(sessionID: string): Promise<void> {
if (!this.pendingParentWakes.has(sessionID)) {
this.clearPendingParentWakeTimer(sessionID)
return
}
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
const latestWake = this.pendingParentWakes.get(sessionID)
if (!latestWake) {
return
}
if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
const notificationContent = latestWake.notifications.join("\n\n")
try {
const promptResult = await promptAsyncAfterSessionIdle({
client: this.deps.client,
sessionID,
source: "background-agent-parent-wake",
settleMs: 0,
postDispatchHoldMs: 250,
input: {
path: { id: sessionID },
body: {
noReply: !latestWake.shouldReply,
...latestWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
query: { directory: this.deps.directory },
},
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
this.requeueWake(sessionID, latestWake)
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
sessionID,
status: promptResult.status,
})
return
}
log("[background-agent] Sent deferred parent wake:", { sessionID })
this.trackDispatchedParentWake(sessionID, latestWake)
} catch (error) {
this.requeueWake(sessionID, latestWake)
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
clearDispatchedParentWake(sessionID: string): void {
const timer = this.dispatchedParentWakeTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
this.dispatchedParentWakeTimers.delete(sessionID)
}
this.dispatchedParentWakes.delete(sessionID)
}
async requeueDispatchedParentWake(sessionID: string, reason: string): Promise<boolean> {
const wake = this.dispatchedParentWakes.get(sessionID)
if (!wake) {
return false
}
await settleAfterSessionIdle()
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) {
this.clearDispatchedParentWake(sessionID)
log("[background-agent] Ignored late parent wake failure after assistant output:", {
sessionID,
reason,
})
return false
}
this.clearDispatchedParentWake(sessionID)
this.requeueWake(sessionID, wake)
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
sessionID,
reason,
})
return true
}
schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
if (this.pendingParentWakeTimers.has(sessionID)) {
return
}
const timer = setTimeout(() => {
this.pendingParentWakeTimers.delete(sessionID)
void this.deps.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
})
}, delayMs ?? this.options.pendingRetryMs)
this.pendingParentWakeTimers.set(sessionID, timer)
}
clearPendingParentWakeTimer(sessionID: string): void {
const timer = this.pendingParentWakeTimers.get(sessionID)
if (!timer) {
return
}
clearTimeout(timer)
this.pendingParentWakeTimers.delete(sessionID)
}
shutdown(): void {
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const timer of this.dispatchedParentWakeTimers.values()) {
clearTimeout(timer)
}
this.dispatchedParentWakeTimers.clear()
this.pendingParentWakes.clear()
this.dispatchedParentWakes.clear()
}
private async isSessionActive(sessionID: string): Promise<boolean> {
return isOpenCodeSessionActive(this.deps.client, sessionID)
}
private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext {
const resolvedAgent = resolveRegisteredAgentName(promptContext.agent)
return {
...promptContext,
...(resolvedAgent ? { agent: resolvedAgent } : {}),
...(promptContext.model ? { model: { ...promptContext.model } } : {}),
...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}),
}
}
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
const promptContext = this.resolveParentWakePromptContext(wake.promptContext)
return {
promptContext,
notifications: [...wake.notifications],
shouldReply: wake.shouldReply,
...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}),
...(wake.toolCallDeferralStartedAt !== undefined
? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt }
: {}),
}
}
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
this.clearDispatchedParentWake(sessionID)
const dispatchedWake = this.cloneParentWake(wake)
dispatchedWake.dispatchedAt = Date.now()
this.dispatchedParentWakes.set(sessionID, dispatchedWake)
const timer = setTimeout(() => {
this.dispatchedParentWakeTimers.delete(sessionID)
this.dispatchedParentWakes.delete(sessionID)
}, this.options.failureRequeueWindowMs)
this.dispatchedParentWakeTimers.set(sessionID, timer)
}
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
try {
const messagesResp = await messagesInDirectory(this.deps.client, {
path: { id: sessionID },
}, this.deps.directory)
return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[])
} catch (error) {
log("[background-agent] Failed to inspect parent session messages for wake safety:", {
sessionID,
error,
})
return []
}
}
private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined {
return message.info?.role ?? message.role
}
private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined {
return message.info?.finish ?? message.finish
}
private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined {
const value = message.info?.time?.created ?? message.time?.created
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : undefined
}
if (value instanceof Date) {
return value.getTime()
}
return undefined
}
private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
if (!message) {
continue
}
const role = this.getParentWakeMessageRole(message)
if (role === "assistant") {
return this.getParentWakeMessageFinish(message) === "tool-calls"
}
if (role === "user") {
return false
}
}
return false
}
private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean {
const role = this.getParentWakeMessageRole(message)
if (role !== "assistant" && role !== "tool") {
return false
}
if (!message.parts || message.parts.length === 0) {
return role === "assistant"
}
return message.parts.some((part) => {
if (part.type === "text" || part.type === "reasoning") {
return typeof part.text === "string" && part.text.trim().length > 0
}
if (part.type === "tool" || part.type === "tool_result") {
return true
}
if (part.content !== undefined) {
if (typeof part.content === "string") {
return part.content.trim().length > 0
}
if (Array.isArray(part.content)) {
return part.content.length > 0
}
return true
}
return false
})
}
private parentWakeMessageContainsNotification(message: ParentWakeSessionMessage, wake: PendingParentWake): boolean {
if (this.getParentWakeMessageRole(message) !== "user") {
return false
}
return message.parts?.some((part) =>
typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification))
) ?? false
}
private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise<boolean> {
const messages = await this.loadParentWakeSessionMessages(sessionID)
if (!this.latestAssistantTurnIsWaitingOnTools(messages)) {
delete wake.toolCallDeferralStartedAt
return false
}
const now = Date.now()
wake.toolCallDeferralStartedAt ??= now
if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs) {
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
sessionID,
})
return false
}
log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", {
sessionID,
})
return true
}
private async hasAcceptedMessageAfterDispatchedParentWake(sessionID: string, wake: PendingParentWake): Promise<boolean> {
if (wake.dispatchedAt === undefined) {
return false
}
const dispatchedAt = wake.dispatchedAt
const messages = await this.loadParentWakeSessionMessages(sessionID)
return messages.some((message) => {
const createdAt = this.getParentWakeMessageCreatedAt(message)
if (createdAt === undefined) {
return false
}
if (
createdAt >= dispatchedAt - this.options.acceptedMessageSkewMs
&& this.parentWakeMessageContainsNotification(message, wake)
) {
return true
}
return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message)
})
}
private requeueWake(sessionID: string, latestWake: PendingParentWake): void {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.unshift(...latestWake.notifications)
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
pendingWake.promptContext = latestWake.promptContext
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
return
}
this.pendingParentWakes.set(sessionID, this.cloneParentWake(latestWake))
}
}
+3
View File
@@ -1,6 +1,7 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
@@ -182,6 +183,7 @@ export async function startTask(
taskId: task.id,
})
try {
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
@@ -320,6 +322,7 @@ export async function resumeTask(
taskId: task.id,
})
try {
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
@@ -98,9 +98,8 @@ function createManager(
abort: async () => ({}),
},
}
const placeholderClient = {} as PluginInput["client"]
const ctx: PluginInput = {
client: placeholderClient,
client: client as PluginInput["client"],
project: {} as PluginInput["project"],
directory: tmpdir(),
worktree: tmpdir(),
@@ -111,7 +110,6 @@ function createManager(
const manager = new BackgroundManager(
{ pluginContext: ctx, config: undefined, enableParentSessionNotifications }
)
Reflect.set(manager, "client", client)
return { manager, promptAsyncCalls }
}
@@ -174,7 +172,10 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
}
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
return Reflect.get(manager, "pendingParentWakes") as Map<string, PendingParentWakeForTest>
const parentWakeNotifier = Reflect.get(manager, "parentWakeNotifier") as {
getPendingParentWakes: () => Map<string, PendingParentWakeForTest>
}
return parentWakeNotifier.getPendingParentWakes()
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
+151 -27
View File
@@ -3,8 +3,8 @@ import { afterEach, describe, expect, test } from "bun:test"
import {
promptAfterSessionIdle,
promptAsyncAfterSessionIdle,
releasePromptAsyncReservation,
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "./prompt-async-gate"
describe("promptAsyncAfterSessionIdle", () => {
@@ -76,7 +76,7 @@ describe("promptAsyncAfterSessionIdle", () => {
source: "test:hold:first",
settleMs: 0,
})
await new Promise((resolve) => setTimeout(resolve, 0))
const firstResult = await first
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_hold_after_dispatch",
@@ -84,7 +84,6 @@ describe("promptAsyncAfterSessionIdle", () => {
source: "test:hold:second",
settleMs: 0,
})
const firstResult = await first
// then
expect(firstResult.status).toBe("dispatched")
@@ -122,6 +121,9 @@ describe("promptAsyncAfterSessionIdle", () => {
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
// given
let promptCalls = 0
const originalDateNow = Date.now
let currentNow = originalDateNow()
Date.now = () => currentNow
const client = {
session: {
promptAsync: async () => {
@@ -130,29 +132,33 @@ describe("promptAsyncAfterSessionIdle", () => {
},
}
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:first",
settleMs: 0,
postDispatchHoldMs: 1,
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:second",
settleMs: 0,
postDispatchHoldMs: 0,
})
try {
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:first",
settleMs: 0,
postDispatchHoldMs: 1,
})
currentNow += 2
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:second",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(first.status).toBe("dispatched")
expect(second.status).toBe("dispatched")
expect(promptCalls).toBe(2)
// then
expect(first.status).toBe("dispatched")
expect(second.status).toBe("dispatched")
expect(promptCalls).toBe(2)
} finally {
Date.now = originalDateNow
}
})
test("#given a peer-message promptAsync hold #when an unrelated route releases the session #then the peer-message hold remains reserved", async () => {
@@ -243,6 +249,125 @@ describe("promptAsyncAfterSessionIdle", () => {
expect(promptCalls).toBe(2)
})
test("#given promptAsync dispatch never settles #when dispatch timeout elapses #then reservation is released for the next caller", async () => {
// given
let promptCalls = 0
const neverSettles = new Promise<void>(() => {})
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
await neverSettles
},
},
}
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_dispatch_timeout",
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
source: "test:timeout:first",
settleMs: 0,
dispatchTimeoutMs: 1,
postDispatchHoldMs: 0,
})
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_dispatch_timeout",
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
source: "test:timeout:second",
settleMs: 0,
dispatchTimeoutMs: 1,
postDispatchHoldMs: 0,
})
// then
expect(first.status).toBe("failed")
expect(second.status).toBe("failed")
expect(promptCalls).toBe(2)
})
test("#given promptAsync rejects after dispatch #when a second caller races immediately #then post-dispatch hold still blocks duplicate", async () => {
// given
let promptCalls = 0
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
throw new Error("post-dispatch failure")
},
},
}
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_post_dispatch_reject",
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
source: "test:reject:first",
settleMs: 0,
})
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_post_dispatch_reject",
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
source: "test:reject:second",
settleMs: 0,
})
// then
expect(first.status).toBe("failed")
expect(second).toEqual({ status: "reserved", reservedBy: "test:reject:first" })
expect(promptCalls).toBe(1)
})
test("#given a similarly named sibling route #when reservedByPrefix uses a strict family prefix #then release does not clear sibling reservation", async () => {
// given
let promptCalls = 0
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
},
},
}
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_prefix_sibling",
input: {
path: { id: "ses_prefix_sibling" },
body: { parts: [{ type: "text", text: "continue" }] },
},
source: "model-fallbackx:message.updated",
settleMs: 0,
})
const released = releasePromptAsyncReservation(
"ses_prefix_sibling",
"model-fallback-abort:session.error",
{ reservedByPrefix: "model-fallback:" },
)
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_prefix_sibling",
input: {
path: { id: "ses_prefix_sibling" },
body: { parts: [{ type: "text", text: "continue again" }] },
},
source: "model-fallback:session.error",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(first.status).toBe("dispatched")
expect(released).toBe(false)
expect(second).toEqual({ status: "reserved", reservedBy: "model-fallbackx:message.updated" })
expect(promptCalls).toBe(1)
})
test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => {
// given
let promptCalls = 0
@@ -306,7 +431,7 @@ describe("promptAsyncAfterSessionIdle", () => {
source: "test:prompt-hold:first",
settleMs: 0,
})
await new Promise((resolve) => setTimeout(resolve, 0))
const firstResult = await first
const second = await promptAfterSessionIdle({
client,
sessionID: "ses_prompt_hold_after_dispatch",
@@ -314,7 +439,6 @@ describe("promptAsyncAfterSessionIdle", () => {
source: "test:prompt-hold:second",
settleMs: 0,
})
const firstResult = await first
// then
expect(firstResult.status).toBe("dispatched")
+1 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createPluginModule } from "./index"
import { createPluginModule } from "./testing/create-plugin-module"
const mockInitConfigContext = mock(() => {})
const mockInjectServerAuthIntoClient = mock(() => {})
+1 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createPluginModule } from "./index"
import { createPluginModule } from "./testing/create-plugin-module"
const mockInitConfigContext = mock(() => {})
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
+5 -183
View File
@@ -1,196 +1,18 @@
import { initConfigContext } from "./cli/config-manager/config-context"
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
import type { HookName } from "./config"
import { createHooks } from "./create-hooks"
import { createManagers } from "./create-managers"
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runtime-tmux-config"
import { createTools } from "./create-tools"
import { initializeOpenClaw } from "./openclaw"
import { createPluginInterface } from "./plugin-interface"
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
type CompactionAutocontinueHook,
} from "./plugin/session-compacting"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { log } from "./shared/logger"
import { logLegacyPluginStartupWarning } from "./shared/log-legacy-plugin-startup-warning"
import { injectServerAuthIntoClient } from "./shared/opencode-server-auth"
import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
type HooksWithCompactionAutocontinue = Hooks & {
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
}
type PluginModuleDeps = {
initConfigContext: typeof initConfigContext
installAgentSortShim: typeof installAgentSortShim
setAgentSortOrder: typeof setAgentSortOrder
log: typeof log
logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning
detectExternalSkillPlugin: typeof detectExternalSkillPlugin
getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning
injectServerAuthIntoClient: typeof injectServerAuthIntoClient
loadPluginConfig: typeof loadPluginConfig
initializeOpenClaw: typeof initializeOpenClaw
isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled
startTmuxCheck: typeof startTmuxCheck
createFirstMessageVariantGate: typeof createFirstMessageVariantGate
createRuntimeTmuxConfig: typeof createRuntimeTmuxConfig
createModelCacheState: typeof createModelCacheState
createManagers: typeof createManagers
createTools: typeof createTools
createHooks: typeof createHooks
createPluginInterface: typeof createPluginInterface
}
const defaultPluginModuleDeps: PluginModuleDeps = {
initConfigContext,
installAgentSortShim,
setAgentSortOrder,
log,
logLegacyPluginStartupWarning,
detectExternalSkillPlugin,
getSkillPluginConflictWarning,
injectServerAuthIntoClient,
loadPluginConfig,
initializeOpenClaw,
isTmuxIntegrationEnabled,
startTmuxCheck,
createFirstMessageVariantGate,
createRuntimeTmuxConfig,
createModelCacheState,
createManagers,
createTools,
createHooks,
createPluginInterface,
}
export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): PluginModule {
const deps = { ...defaultPluginModuleDeps, ...overrides }
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
deps.installAgentSortShim()
deps.initConfigContext("opencode", null)
deps.log("[oh-my-openagent] ENTRY - plugin loading", {
directory: input.directory,
})
deps.logLegacyPluginStartupWarning()
const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
console.warn(deps.getSkillPluginConflictWarning(skillPluginCheck.pluginName))
}
deps.injectServerAuthIntoClient(input.client)
const pluginConfig = deps.loadPluginConfig(input.directory, input)
deps.setAgentSortOrder(pluginConfig.agent_order)
if (pluginConfig.openclaw) {
await deps.initializeOpenClaw(pluginConfig.openclaw)
}
if (pluginConfig.team_mode?.enabled) {
const teamModeConfig = pluginConfig.team_mode
try {
const { ensureBaseDirs, resolveBaseDir } = await import("./features/team-mode/team-registry/paths")
const { checkTeamModeDependencies } = await import("./features/team-mode/deps")
await checkTeamModeDependencies(teamModeConfig)
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
if (pluginConfig.disabled_skills?.includes("team-mode")) {
console.warn(
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
)
}
} catch (err) {
console.warn("[team-mode] init failed:", err)
}
}
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
if (tmuxIntegrationEnabled) {
deps.startTmuxCheck()
}
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName)
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
const firstMessageVariantGate = deps.createFirstMessageVariantGate()
const tmuxConfig = deps.createRuntimeTmuxConfig(pluginConfig)
const modelCacheState = deps.createModelCacheState()
const managers = deps.createManagers({
ctx: input,
pluginConfig,
tmuxConfig,
modelCacheState,
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
})
const toolsResult = await deps.createTools({
ctx: input,
pluginConfig,
managers,
})
const hooks = deps.createHooks({
ctx: input,
pluginConfig,
modelCacheState,
backgroundManager: managers.backgroundManager,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
})
const pluginInterface = deps.createPluginInterface({
ctx: input,
pluginConfig,
firstMessageVariantGate,
managers,
hooks,
tools: toolsResult.filteredTools,
})
const pluginHooks: HooksWithCompactionAutocontinue = {
...pluginInterface,
"experimental.session.compacting": createSessionCompactingHandler(hooks),
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
}
return pluginHooks
}
return {
id: "oh-my-openagent",
server: serverPlugin,
}
}
import type { PluginModule } from "@opencode-ai/plugin"
import { createPluginModule } from "./testing/create-plugin-module"
const pluginModule: PluginModule = createPluginModule()
export default pluginModule
export type {
OhMyOpenCodeConfig,
AgentName,
AgentOverrideConfig,
AgentOverrides,
McpName,
HookName,
BuiltinCommandName,
HookName,
McpName,
OhMyOpenCodeConfig,
} from "./config"
export type { ConfigLoadError } from "./shared/config-errors"
@@ -0,0 +1,203 @@
import { describe, expect, test } from "bun:test"
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
import ts from "typescript"
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
[
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
[
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for doctor dependency module mocks.
[
path.join(SOURCE_ROOT, "cli", "doctor", "checks", "dependencies.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for session recovery module mocks.
[
path.join(SOURCE_ROOT, "hooks", "session-recovery", "index.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for auto-update checker hook module mocks.
[
path.join(SOURCE_ROOT, "hooks", "auto-update-checker", "hook.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux layout-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "layout-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-dimensions module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-dimensions.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux stale-session sweep module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "stale-session-sweep-runtime.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
])
async function listTestFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true })
const nestedFiles = await Promise.all(entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
return listTestFiles(entryPath)
}
if (entry.isFile() && entry.name.endsWith(".test.ts") && !entry.name.endsWith(".d.ts")) {
return [entryPath]
}
return []
}))
return nestedFiles.flat()
}
function relativeSourcePath(filePath: string): string {
return path.relative(SOURCE_ROOT, filePath)
}
function isMockModuleCall(node: ts.CallExpression): boolean {
const expression = node.expression
return ts.isPropertyAccessExpression(expression)
&& ts.isIdentifier(expression.expression)
&& expression.expression.text === "mock"
&& expression.name.text === "module"
}
function getMockModulePath(node: ts.CallExpression): string | null {
if (!isMockModuleCall(node)) {
return null
}
const modulePath = node.arguments[0]
if (!modulePath || !ts.isStringLiteralLike(modulePath)) {
return null
}
return modulePath.text
}
function collectMockModulePaths(sourceFile: ts.SourceFile): string[] {
const modulePaths: string[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const modulePath = getMockModulePath(node)
if (modulePath) {
modulePaths.push(modulePath)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return modulePaths
}
function hasMockModuleCall(sourceFile: ts.SourceFile): boolean {
return collectMockModulePaths(sourceFile).length > 0
}
function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean {
const seenModulePaths = new Set<string>()
for (const modulePath of collectMockModulePaths(sourceFile)) {
if (seenModulePaths.has(modulePath)) {
return true
}
seenModulePaths.add(modulePath)
}
return false
}
function isCleanupCall(node: ts.CallExpression): boolean {
if (ts.isIdentifier(node.expression)) {
return node.expression.text === "afterEach" || node.expression.text === "afterAll"
}
const expression = node.expression
return ts.isPropertyAccessExpression(expression)
&& ts.isIdentifier(expression.expression)
&& expression.expression.text === "mock"
&& expression.name.text === "restore"
}
function hasCleanupPattern(sourceFile: ts.SourceFile): boolean {
if (hasDuplicateModuleReset(sourceFile)) {
return true
}
let foundCleanup = false
const visit = (node: ts.Node): void => {
if (foundCleanup) {
return
}
if (ts.isCallExpression(node) && isCleanupCall(node)) {
foundCleanup = true
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return foundCleanup
}
describe("mock.module lifecycle hygiene", () => {
test("#given test files using mock.module #when audited #then each must pair with cleanup", async () => {
// given
const files = await listTestFiles(SOURCE_ROOT)
const offenders: string[] = []
// when
for (const filePath of files) {
if (MOCK_MODULE_LIFECYCLE_ALLOWLIST.has(filePath)) {
continue
}
const contents = await readFile(filePath, "utf8")
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
offenders.push(relativeSourcePath(filePath))
}
}
// then
expect(offenders.sort()).toEqual([])
})
})
+11 -1
View File
@@ -5,7 +5,11 @@ import {
PROMPT_TIMEOUT_MS,
type PromptRetryOptions,
} from "./prompt-timeout-context"
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle } from "./prompt-async-gate"
import {
promptAfterSessionIdle,
promptAsyncAfterSessionIdle,
releasePromptAsyncReservation,
} from "./prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -119,6 +123,7 @@ export async function promptWithModelSuggestionRetry(
if (timeoutContext.wasTimedOut()) {
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
}
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry")
throw error
} finally {
timeoutContext.cleanup()
@@ -169,6 +174,11 @@ export async function promptSyncWithModelSuggestionRetry(
throw error
}
// The first attempt failed synchronously with ProviderModelNotFoundError, which means the
// prompt did not reach the server. Release the post-dispatch reservation hold so the
// immediate retry can dispatch without waiting for the hold window to expire.
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry:sync")
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
original: `${suggestion.providerID}/${suggestion.modelID}`,
suggested: suggestion.suggestion,
+140 -103
View File
@@ -6,6 +6,7 @@ import {
} from "./session-idle-settle"
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
type PromptAsyncInput = {
path?: { id?: string }
@@ -36,6 +37,9 @@ type PromptAsyncReservation = {
expiresAt?: number
}
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
export type PromptAsyncGateResult =
| { status: "dispatched"; response: unknown }
| { status: "active" }
@@ -84,11 +88,114 @@ function reservationSourceMatches(
return false
}
if (typeof expectedPrefix === "string") {
return reservationSource.startsWith(expectedPrefix)
const prefixes = typeof expectedPrefix === "string" ? [expectedPrefix] : expectedPrefix
return prefixes
.filter((prefix) => prefix.length > 0 && prefix.endsWith(":"))
.some((prefix) => reservationSource.startsWith(prefix))
}
async function withDispatchTimeout<T>(
operation: Promise<T>,
dispatchTimeoutMs: number,
operationName: string,
): Promise<T> {
if (dispatchTimeoutMs <= 0) {
return operation
}
return expectedPrefix.some((prefix) => reservationSource.startsWith(prefix))
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
}, dispatchTimeoutMs)
})
try {
return await Promise.race([operation, timeoutPromise])
} finally {
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
}
}
}
async function dispatchAfterSessionIdle<TInput>(args: {
sessionName: "promptAsync" | "prompt"
client: { session?: { status?: () => Promise<unknown> } }
sessionID: string
input: TInput
source: string
settleMs: number
postDispatchHoldMs: number
dispatchTimeoutMs: number
checkStatus: boolean
dispatch: (input: TInput) => Promise<unknown>
}): Promise<PromptAsyncGateResult> {
const {
sessionName,
client,
sessionID,
input,
source,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus,
dispatch,
} = args
const existing = getActiveReservation(sessionID)
if (existing) {
log(`[prompt-async-gate] ${sessionName} skipped because session is reserved`, {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let dispatchAttempted = false
try {
const canReadStatus = checkStatus && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
return { status: "active" }
}
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
dispatchAttempted = true
const response = await withDispatchTimeout(
dispatch(input),
dispatchTimeoutMs,
`[prompt-async-gate] ${sessionName} dispatch`,
)
log(`[prompt-async-gate] ${sessionName} dispatched`, { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (dispatchAttempted && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
}
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
@@ -98,6 +205,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult> {
const {
@@ -108,62 +216,26 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
} = args
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
const promptAsync = client.session?.promptAsync
if (typeof client.session?.promptAsync !== "function") {
if (typeof promptAsync !== "function") {
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
return { status: "unavailable" }
}
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
return dispatchAfterSessionIdle({
sessionName: "promptAsync",
client,
sessionID,
input,
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log("[prompt-async-gate] promptAsync skipped because session is active", { sessionID, source })
return { status: "active" }
}
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
const response = await client.session.promptAsync(input)
if (postDispatchHoldMs > 0) {
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log("[prompt-async-gate] promptAsync failed", { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
dispatch: (dispatchInput) => promptAsync(dispatchInput),
})
}
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
@@ -173,6 +245,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult> {
const {
@@ -183,62 +256,26 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
} = args
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
const prompt = client.session?.prompt
if (typeof client.session?.prompt !== "function") {
if (typeof prompt !== "function") {
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
return { status: "unavailable" }
}
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] prompt skipped because session is reserved", {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
return dispatchAfterSessionIdle({
sessionName: "prompt",
client,
sessionID,
input,
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log("[prompt-async-gate] prompt skipped because session is active", { sessionID, source })
return { status: "active" }
}
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
const response = await client.session.prompt(input)
if (postDispatchHoldMs > 0) {
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log("[prompt-async-gate] prompt failed", { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
dispatch: (dispatchInput) => prompt(dispatchInput),
})
}
export function releaseAllPromptAsyncReservationsForTesting(): void {
+213 -17
View File
@@ -1,9 +1,20 @@
import { describe, expect, test } from "bun:test"
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
import ts from "typescript"
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts")
const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
[
path.join(SOURCE_ROOT, "plugin", "event.ts"),
"team idle wake hint wires a client facade for downstream gate-routed dispatch",
],
[
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
"runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle",
],
])
async function listSourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true })
@@ -30,35 +41,220 @@ function relativeSourcePath(filePath: string): string {
return path.relative(SOURCE_ROOT, filePath)
}
function uncommentedLines(contents: string): string[] {
return contents
.split("\n")
.map((line) => line.trimStart())
.filter((line) => !line.startsWith("//") && !line.startsWith("*"))
function getPropertyName(node: ts.PropertyName | ts.MemberName | ts.Expression): string | null {
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
return node.text
}
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text
}
return null
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
if (ts.isParenthesizedExpression(expression)) {
return unwrapExpression(expression.expression)
}
if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
return unwrapExpression(expression.expression)
}
if (ts.isNonNullExpression(expression)) {
return unwrapExpression(expression.expression)
}
return expression
}
function isSessionAccessExpression(expression: ts.Expression): boolean {
const unwrapped = unwrapExpression(expression)
if (ts.isIdentifier(unwrapped)) {
return unwrapped.text === "session"
}
if (
ts.isPropertyAccessExpression(unwrapped)
|| ts.isPropertyAccessChain(unwrapped)
) {
const propertyName = getPropertyName(unwrapped.name)
return propertyName === "session"
}
if (
ts.isElementAccessExpression(unwrapped)
|| ts.isElementAccessChain(unwrapped)
) {
const argument = unwrapped.argumentExpression
if (!argument) {
return false
}
return getPropertyName(argument) === "session"
}
return false
}
function isRawPromptPropertyAccess(node: ts.Node): boolean {
if (
ts.isPropertyAccessExpression(node)
|| ts.isPropertyAccessChain(node)
) {
const propertyName = getPropertyName(node.name)
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
return false
}
return isSessionAccessExpression(node.expression)
}
if (
ts.isElementAccessExpression(node)
|| ts.isElementAccessChain(node)
) {
const argument = node.argumentExpression
if (!argument) {
return false
}
const propertyName = getPropertyName(argument)
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
return false
}
return isSessionAccessExpression(node.expression)
}
return false
}
function isPromptBindingPattern(node: ts.Node): boolean {
if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isObjectBindingPattern(node.name)) {
return false
}
if (!isSessionAccessExpression(node.initializer)) {
return false
}
return node.name.elements.some((element) => {
const keyName = element.propertyName
? getPropertyName(element.propertyName)
: getPropertyName(element.name)
return keyName === "prompt" || keyName === "promptAsync"
})
}
function isReflectApplyPromptCall(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) {
return false
}
const callee = unwrapExpression(node.expression)
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "apply") {
return false
}
if (!ts.isIdentifier(callee.expression) || callee.expression.text !== "Reflect") {
return false
}
const firstArgument = node.arguments[0]
if (!firstArgument) {
return false
}
return isRawPromptPropertyAccess(firstArgument)
}
function isTypeofPromptCheck(node: ts.Node): boolean {
return ts.isTypeOfExpression(node.parent)
}
function detectRawPromptInSnippet(contents: string): boolean {
const sourceFile = ts.createSourceFile("audit-snippet.ts", contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
let detected = false
const visit = (node: ts.Node): void => {
if (detected) {
return
}
const isRawPromptAccess = isRawPromptPropertyAccess(node) && !isTypeofPromptCheck(node)
if (isRawPromptAccess || isPromptBindingPattern(node) || isReflectApplyPromptCall(node)) {
detected = true
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return detected
}
describe("production prompt injection routes", () => {
test("#given a destructuring promptAsync reference #when audit scans snippet #then it is flagged", () => {
// given
const snippet = "const { promptAsync } = client.session"
// when
const detected = detectRawPromptInSnippet(snippet)
// then
expect(detected).toBe(true)
})
test("#given bracket promptAsync reference #when audit scans snippet #then it is flagged", () => {
// given
const snippet = "const value = client['session']['promptAsync']"
// when
const detected = detectRawPromptInSnippet(snippet)
// then
expect(detected).toBe(true)
})
test("#given type-cast promptAsync reference #when audit scans snippet #then it is flagged", () => {
// given
const snippet = "const promptAsync = (client.session as { promptAsync?: unknown }).promptAsync"
// when
const detected = detectRawPromptInSnippet(snippet)
// then
expect(detected).toBe(true)
})
test("#given optional-chain promptAsync call #when audit scans snippet #then it is flagged", () => {
// given
const snippet = "await client.session?.promptAsync({ body: { text: 'hi' } })"
// when
const detected = detectRawPromptInSnippet(snippet)
// then
expect(detected).toBe(true)
})
test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => {
// given
const files = await listSourceFiles(SOURCE_ROOT)
const offenders: string[] = []
const rawPromptPatterns = [
/\bsession\.promptAsync\s*\(/,
/\bsession\.prompt\s*\(/,
/\bReflect\.apply\s*\(\s*\w*promptAsync\b/,
/\bReflect\.apply\s*\(\s*\w*prompt\b/,
/\b(?:const|let|var)\s+\w*promptAsync\w*\s*=\s*[\w.]+\.session\.promptAsync\b/,
/\b(?:const|let|var)\s+\w*prompt\w*\s*=\s*[\w.]+\.session\.prompt\b/,
]
// when
for (const filePath of files) {
if (filePath === PROMPT_GATE_FILE) {
if (filePath === PROMPT_GATE_FILE || RAW_PROMPT_ALLOWLIST.has(filePath)) {
continue
}
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
if (rawPromptPatterns.some((pattern) => pattern.test(contents))) {
const contents = await readFile(filePath, "utf8")
if (detectRawPromptInSnippet(contents)) {
offenders.push(relativeSourcePath(filePath))
}
}
@@ -74,7 +270,7 @@ describe("production prompt injection routes", () => {
// when
for (const filePath of files) {
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
const contents = await readFile(filePath, "utf8")
if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) {
offenders.push(relativeSourcePath(filePath))
}
+178
View File
@@ -0,0 +1,178 @@
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
import type { HookName } from "../config"
import { initConfigContext } from "../cli/config-manager/config-context"
import { createHooks } from "../create-hooks"
import { createManagers } from "../create-managers"
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "../create-runtime-tmux-config"
import { createTools } from "../create-tools"
import { initializeOpenClaw } from "../openclaw"
import { createPluginInterface } from "../plugin-interface"
import { loadPluginConfig } from "../plugin-config"
import { createModelCacheState } from "../plugin-state"
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
type CompactionAutocontinueHook,
} from "../plugin/session-compacting"
import { installAgentSortShim, setAgentSortOrder } from "../shared/agent-sort-shim"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "../shared/external-plugin-detector"
import { createFirstMessageVariantGate } from "../shared/first-message-variant"
import { log } from "../shared/logger"
import { logLegacyPluginStartupWarning } from "../shared/log-legacy-plugin-startup-warning"
import { injectServerAuthIntoClient } from "../shared/opencode-server-auth"
import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash"
type HooksWithCompactionAutocontinue = Hooks & {
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
}
export type PluginModuleDeps = {
initConfigContext: typeof initConfigContext
installAgentSortShim: typeof installAgentSortShim
setAgentSortOrder: typeof setAgentSortOrder
log: typeof log
logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning
detectExternalSkillPlugin: typeof detectExternalSkillPlugin
getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning
injectServerAuthIntoClient: typeof injectServerAuthIntoClient
loadPluginConfig: typeof loadPluginConfig
initializeOpenClaw: typeof initializeOpenClaw
isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled
startTmuxCheck: typeof startTmuxCheck
createFirstMessageVariantGate: typeof createFirstMessageVariantGate
createRuntimeTmuxConfig: typeof createRuntimeTmuxConfig
createModelCacheState: typeof createModelCacheState
createManagers: typeof createManagers
createTools: typeof createTools
createHooks: typeof createHooks
createPluginInterface: typeof createPluginInterface
}
const defaultPluginModuleDeps: PluginModuleDeps = {
initConfigContext,
installAgentSortShim,
setAgentSortOrder,
log,
logLegacyPluginStartupWarning,
detectExternalSkillPlugin,
getSkillPluginConflictWarning,
injectServerAuthIntoClient,
loadPluginConfig,
initializeOpenClaw,
isTmuxIntegrationEnabled,
startTmuxCheck,
createFirstMessageVariantGate,
createRuntimeTmuxConfig,
createModelCacheState,
createManagers,
createTools,
createHooks,
createPluginInterface,
}
export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): PluginModule {
const deps = { ...defaultPluginModuleDeps, ...overrides }
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
deps.installAgentSortShim()
deps.initConfigContext("opencode", null)
deps.log("[oh-my-openagent] ENTRY - plugin loading", {
directory: input.directory,
})
deps.logLegacyPluginStartupWarning()
const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
console.warn(deps.getSkillPluginConflictWarning(skillPluginCheck.pluginName))
}
deps.injectServerAuthIntoClient(input.client)
const pluginConfig = deps.loadPluginConfig(input.directory, input)
deps.setAgentSortOrder(pluginConfig.agent_order)
if (pluginConfig.openclaw) {
await deps.initializeOpenClaw(pluginConfig.openclaw)
}
if (pluginConfig.team_mode?.enabled) {
const teamModeConfig = pluginConfig.team_mode
try {
const { ensureBaseDirs, resolveBaseDir } = await import("../features/team-mode/team-registry/paths")
const { checkTeamModeDependencies } = await import("../features/team-mode/deps")
await checkTeamModeDependencies(teamModeConfig)
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
if (pluginConfig.disabled_skills?.includes("team-mode")) {
console.warn(
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
)
}
} catch (err) {
console.warn("[team-mode] init failed:", err)
}
}
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
if (tmuxIntegrationEnabled) {
deps.startTmuxCheck()
}
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName)
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
const firstMessageVariantGate = deps.createFirstMessageVariantGate()
const tmuxConfig = deps.createRuntimeTmuxConfig(pluginConfig)
const modelCacheState = deps.createModelCacheState()
const managers = deps.createManagers({
ctx: input,
pluginConfig,
tmuxConfig,
modelCacheState,
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
})
const toolsResult = await deps.createTools({
ctx: input,
pluginConfig,
managers,
})
const hooks = deps.createHooks({
ctx: input,
pluginConfig,
modelCacheState,
backgroundManager: managers.backgroundManager,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
})
const pluginInterface = deps.createPluginInterface({
ctx: input,
pluginConfig,
firstMessageVariantGate,
managers,
hooks,
tools: toolsResult.filteredTools,
})
const pluginHooks: HooksWithCompactionAutocontinue = {
...pluginInterface,
"experimental.session.compacting": createSessionCompactingHandler(hooks),
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
}
return pluginHooks
}
return {
id: "oh-my-openagent",
server: serverPlugin,
}
}