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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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>> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user