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
@@ -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,
}
}