mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
[comment audit] Complete repository-wide comment cleanup (#33249)
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Executes the generated OpenCode plugin artifact to verify that a running
|
||||
* background child session keeps the pane working, so a root turn that ends
|
||||
* first cannot publish a false completion for the whole task tree.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Executes the generated OpenCode plugin artifact to verify that descendant
|
||||
* blockers roll up to their root pane without giving child lifecycle or
|
||||
* message events authority over that pane.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -454,7 +449,7 @@ describe('OpenCode plugin child attention', () => {
|
||||
it('keeps the oldest blocker waiting when 128 newer blockers resolve', async () => {
|
||||
const hooks = await createHooks([])
|
||||
|
||||
// Why: live blocker ownership cannot be LRU-evicted; 129 crosses the prior 128-entry cap.
|
||||
// Cross the old 128-entry cap: live blocker ownership must survive ancestry-cache eviction.
|
||||
for (let index = 1; index <= 129; index += 1) {
|
||||
await hooks.event({
|
||||
event: attention('question.asked', `question-${String(index)}`, `session-${String(index)}`)
|
||||
@@ -603,8 +598,6 @@ describe('OpenCode plugin child attention', () => {
|
||||
}
|
||||
await Promise.all([seedRole, preview, childIdle])
|
||||
|
||||
// Why: child completion may clean its blockers, but it cannot cancel or
|
||||
// replace a parent preview that was already on its way to the pane.
|
||||
expect(names()).toEqual(['MessagePart'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Executes the generated plugin through each OpenCode host entry point, because
|
||||
* what disposal means differs by host: OpenCode 1 disposes only on instance
|
||||
* teardown (which cancels every run), OpenCode 2 also on a plugin hot reload.
|
||||
*/
|
||||
// OpenCode 1 disposes on teardown; OpenCode 2 also disposes during plugin hot reload.
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -40,7 +36,6 @@ const ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_TOKEN'
|
||||
] as const
|
||||
|
||||
// A live OpenCode 2 event bus: stays open until the subscriber aborts.
|
||||
function createEventBus(): {
|
||||
push: (event: HostEvent) => void
|
||||
subscribe: (input: { signal: AbortSignal }) => AsyncGenerator<HostEvent>
|
||||
@@ -156,7 +151,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host',
|
||||
await firstCleanup?.()
|
||||
expect(names).toEqual(['SessionBusy'])
|
||||
|
||||
// The turn kept running; its end reaches the reloaded plugin.
|
||||
const secondBus = createEventBus()
|
||||
const secondCleanup = await module.default?.setup?.(setupContext(secondBus))
|
||||
secondBus.push({ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } })
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Executes the generated OpenCode plugin source because fail-open ownership
|
||||
* lives inside OpenCode's process, not in Orca's TypeScript runtime.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -19,7 +15,6 @@ vi.mock('electron', () => ({
|
||||
import { _internals } from './hook-service'
|
||||
|
||||
type SessionFixture = { id: string; parentID?: string }
|
||||
/** The session half of the SDK client, as the plugin's ancestry lookup uses it. */
|
||||
type SessionClientFixture = {
|
||||
list: (options?: { signal?: AbortSignal }) => Promise<{ data: SessionFixture[] }>
|
||||
}
|
||||
@@ -224,7 +219,7 @@ describe('OpenCode plugin fail-open ownership', () => {
|
||||
})
|
||||
await partStarted
|
||||
|
||||
// Why: evict only root-0's ancestry cache entry while its preview is in flight.
|
||||
// Evict root-0 ancestry while its preview remains in flight.
|
||||
for (let index = 1; index < sessions.length; index += 1) {
|
||||
await hooks.event({
|
||||
event: {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Executes the generated OpenCode plugin source because this delivery state
|
||||
* lives inside OpenCode's process, not in Orca's TypeScript runtime.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -20,8 +16,7 @@ import { _internals } from './hook-service'
|
||||
|
||||
type SessionFixture = { id: string; parentID?: string }
|
||||
|
||||
/** The plugin probes both SDK call conventions — current `(parameters, options)` and legacy
|
||||
* single-options — so fixtures for one session-client method differ in arity. */
|
||||
/** Fixtures cover both current two-argument and legacy single-options SDK calls. */
|
||||
type SessionClientCall = (...args: never[]) => Promise<{ data: SessionFixture[] }>
|
||||
|
||||
type SessionClientFixture = { list: SessionClientCall; get?: SessionClientCall }
|
||||
@@ -217,8 +212,7 @@ describe('OpenCode plugin lifecycle delivery', () => {
|
||||
const idle = handler({ event: status('idle') })
|
||||
|
||||
try {
|
||||
// Why: OpenCode does not await event hooks, so the plugin must prevent
|
||||
// the later idle lookup from overtaking the blocked busy lookup.
|
||||
// OpenCode does not await hooks; later idle lookup must not overtake blocked busy lookup.
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(posts).toHaveLength(0)
|
||||
} finally {
|
||||
@@ -306,7 +300,6 @@ describe('OpenCode plugin lifecycle delivery', () => {
|
||||
await handler({ event: status('idle') })
|
||||
expect(names()).toEqual(['SessionBusy', 'SessionIdle'])
|
||||
|
||||
// Why: a stale retry must not resurrect Working after OpenCode is done.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(names()).toEqual(['SessionBusy', 'SessionIdle'])
|
||||
})
|
||||
@@ -324,8 +317,7 @@ describe('OpenCode plugin lifecycle delivery', () => {
|
||||
await handler({ event: status('busy') })
|
||||
expect(names()).toEqual(['SessionBusy'])
|
||||
|
||||
// Why: current OpenCode streams deltas after an empty text-start part; the
|
||||
// live delta proves work continued and should recover a lost Busy post.
|
||||
// Deltas after empty text-start parts must recover a lost Busy post.
|
||||
await handler({ event: delta('hello') })
|
||||
expect(names()).toEqual(['SessionBusy', 'SessionBusy'])
|
||||
|
||||
@@ -443,8 +435,7 @@ describe('OpenCode plugin lifecycle delivery', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
try {
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
// Busy is safe to fail open: even a child means its root is working.
|
||||
// Idle still requires a later confirmed-root lookup.
|
||||
// Child activity safely marks its root busy; idle still requires confirmed root ownership.
|
||||
expect(names()).toEqual(['SessionBusy', 'SessionIdle'])
|
||||
} finally {
|
||||
releaseLookup?.()
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Executes the generated OpenCode plugin source (the artifact that runs inside
|
||||
* OpenCode's process) to verify streamed message.part.updated events are
|
||||
* coalesced and capped before POSTing to Orca's agent-hook server. The
|
||||
* un-throttled plugin re-posted the full accumulated reply per streamed
|
||||
* append — O(n²) bytes per turn — which saturated Orca's main + renderer
|
||||
* event loops on Windows and froze the UI mid-reply.
|
||||
*/
|
||||
// Reposting accumulated replies per delta causes quadratic traffic and freezes the UI.
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -81,7 +74,6 @@ describe('OpenCode plugin MessagePart throttling', () => {
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
// No parentID → root session, events flow through.
|
||||
list: async () => ({ data: [{ id: 'session-1' }] })
|
||||
}
|
||||
}
|
||||
@@ -121,22 +113,19 @@ describe('OpenCode plugin MessagePart throttling', () => {
|
||||
const handler = await loadPluginEventHandler()
|
||||
await seedAssistantRole(handler)
|
||||
|
||||
// Simulate a streaming turn: 50 part updates, each carrying the full
|
||||
// accumulated text so far (how OpenCode actually publishes parts).
|
||||
// OpenCode streams full accumulated text in each part update.
|
||||
let text = ''
|
||||
for (let i = 0; i < 50; i++) {
|
||||
text += 'chunk-of-streamed-reply-text-'.repeat(10)
|
||||
await handler(assistantPartEvent(text))
|
||||
}
|
||||
|
||||
// Leading edge only — everything else is pending behind the throttle.
|
||||
expect(messagePartPosts()).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
const parts = messagePartPosts()
|
||||
expect(parts).toHaveLength(2)
|
||||
// Trailing post carries the LATEST snapshot, capped.
|
||||
const trailing = parts[1].body.payload
|
||||
expect(trailing.text!.length).toBeLessThanOrEqual(4000)
|
||||
expect(text.startsWith(trailing.text!)).toBe(true)
|
||||
@@ -145,7 +134,7 @@ describe('OpenCode plugin MessagePart throttling', () => {
|
||||
it('flushes the pending reply snapshot before posting SessionIdle', async () => {
|
||||
const handler = await loadPluginEventHandler()
|
||||
await seedAssistantRole(handler)
|
||||
// Mark the session busy so the idle transition is not deduped away.
|
||||
// Seed busy so the idle transition is not deduplicated.
|
||||
await handler({
|
||||
event: {
|
||||
type: 'session.status',
|
||||
@@ -270,8 +259,6 @@ describe('OpenCode plugin MessagePart throttling', () => {
|
||||
expect(parts[0].body.payload.role).toBe('user')
|
||||
expect(parts[0].body.payload.text!.length).toBe(4000)
|
||||
|
||||
// An assistant part right after the user prompt still posts immediately
|
||||
// (leading edge) because user posts do not touch the throttle clock.
|
||||
await seedAssistantRole(handler)
|
||||
await handler(assistantPartEvent('assistant reply'))
|
||||
expect(messagePartPosts()).toHaveLength(2)
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock('electron', () => ({
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
|
||||
// Execute the generated module against legacy and current plugin contracts.
|
||||
describe('OpenCode status plugin module contract', () => {
|
||||
type PluginHooks = {
|
||||
event: (input: { event: unknown }) => Promise<void>
|
||||
@@ -31,9 +30,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<PluginHooks>
|
||||
}
|
||||
|
||||
// Why: the plugin resolves hook coords from the endpoint file first and only then from
|
||||
// env. Pin every input here so the run does not depend on the developer's Orca session
|
||||
// (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app).
|
||||
// Isolate endpoint-file and environment inputs so tests cannot post to a live Orca session.
|
||||
const ENV_KEYS = [
|
||||
'ORCA_OPENCODE_PLUGIN_API',
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -53,9 +50,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
// Why: the generated plugin self-disables when this names a different major,
|
||||
// so an inherited value from the developer's own Orca pane would leave
|
||||
// `hooks.event` undefined and fail the contract for the wrong reason.
|
||||
// An inherited major selector can disable the generated plugin.
|
||||
delete process.env.ORCA_OPENCODE_PLUGIN_API
|
||||
delete process.env.ORCA_OPENCODE_AGENT
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
@@ -95,7 +90,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
async function loadPluginModule(
|
||||
source = _internals.getOpenCodePluginSource()
|
||||
): Promise<PluginModule> {
|
||||
// Why: a unique basename per load defeats the ESM module cache between cases.
|
||||
// Bypass ESM caching between cases.
|
||||
const pluginPath = join(
|
||||
tempDir,
|
||||
`orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`
|
||||
@@ -145,8 +140,6 @@ describe('OpenCode status plugin module contract', () => {
|
||||
const hooks = await module.default?.server?.({
|
||||
client: {
|
||||
session: {
|
||||
// Why: a root session (no parentID) must pass the child-session filter,
|
||||
// otherwise every event is dropped before it can post.
|
||||
get: async () => ({ data: { id: 'ses_root', parentID: undefined } })
|
||||
}
|
||||
}
|
||||
@@ -158,7 +151,7 @@ describe('OpenCode status plugin module contract', () => {
|
||||
properties: { sessionID: 'ses_root', status: { type: 'busy' } }
|
||||
}
|
||||
})
|
||||
// Why: lifecycle delivery is queued; let the plugin's FIFO drain before asserting.
|
||||
// Let queued lifecycle delivery drain before asserting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const hookPosts = posts.filter((post) => post.url.includes('/hook/opencode'))
|
||||
|
||||
@@ -12,9 +12,7 @@ vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
|
||||
// Why: live probe of opencode v2.0.12 — session.get resolves the session record
|
||||
// itself (no { data } envelope), rejects the legacy { path: { id } } argument,
|
||||
// and session.list does not exist. Anything less faithful hides the defect.
|
||||
// OpenCode 2.0.12 returns bare session records, requires sessionID and has no session.list.
|
||||
type LiveSession = { id: string; parentID?: string }
|
||||
|
||||
const ROOT = 'ses_root'
|
||||
@@ -99,7 +97,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage'
|
||||
const module = await loadPlugin()
|
||||
const cleanup = await module.default?.setup?.({
|
||||
session: {
|
||||
// Why: one declared parameter, exactly like the shipped SDK method.
|
||||
// Match the shipped SDK’s single-parameter arity.
|
||||
get: async ({ sessionID }: { sessionID?: string }) => {
|
||||
if (!sessionID) {
|
||||
throw new Error('Missing key at ["sessionID"]')
|
||||
@@ -151,7 +149,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage'
|
||||
0
|
||||
)
|
||||
})
|
||||
// The child rolls up under its root; it never publishes as a root itself.
|
||||
expect(posts.map((post) => post.sessionID)).not.toContain(CHILD)
|
||||
expect(lookups).toContain(CHILD)
|
||||
await cleanup?.()
|
||||
@@ -184,7 +181,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage'
|
||||
await vi.waitFor(() => {
|
||||
expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion')
|
||||
})
|
||||
// The root turn is still running, so the pane goes back to busy rather than staying blocked.
|
||||
await vi.waitFor(() => {
|
||||
expect(posts.at(-1)?.hook_event_name).toBe('SessionBusy')
|
||||
})
|
||||
@@ -192,10 +188,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage'
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why this case: it is the shape reported in #22371 — a subagent asks, the lead
|
||||
// agent truthfully reports no pending question, and the pane stays "needs input"
|
||||
// with nothing able to clear it. With lineage unresolved the blocker keys to the
|
||||
// child's own session id, so the root's own idle never matches it.
|
||||
// Unresolved child lineage leaves blockers keyed to the child, unreachable by root idle.
|
||||
it('retires a child raised blocker when the root turn goes idle', async () => {
|
||||
const { posts, cleanup } = await runSetupBridge([
|
||||
created(ROOT),
|
||||
@@ -239,7 +232,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage'
|
||||
await vi.waitFor(() => {
|
||||
expect(posts.length).toBeGreaterThan(0)
|
||||
})
|
||||
// The root arrived via session.created, so only the child needs one lookup.
|
||||
expect(lookups).toEqual([CHILD])
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock('electron', () => ({
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
|
||||
// Execute the generated module against legacy and current plugin contracts.
|
||||
describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (agent) => {
|
||||
type PostBody = { payload?: unknown }
|
||||
|
||||
@@ -43,9 +42,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<PluginHooks>
|
||||
}
|
||||
|
||||
// Why: the plugin resolves hook coords from the endpoint file first and only then from
|
||||
// env. Pin every input here so the run does not depend on the developer's Orca session
|
||||
// (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app).
|
||||
// Isolate endpoint-file and environment inputs so tests cannot post to a live Orca session.
|
||||
const ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_OPENCODE_AGENT',
|
||||
@@ -88,7 +85,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
async function loadPluginModule(
|
||||
source = _internals.getOpenCodePluginSource()
|
||||
): Promise<PluginModule> {
|
||||
// Why: a unique basename per load defeats the ESM module cache between cases.
|
||||
// Bypass ESM caching between cases.
|
||||
const pluginPath = join(
|
||||
tempDir,
|
||||
`orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`
|
||||
@@ -119,9 +116,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
? _internals.getOpenCode2PluginSource()
|
||||
: _internals.getOpenCodePluginSource()
|
||||
)
|
||||
// Why: OpenCode probes setup() during startup, and the setup API shape can
|
||||
// drift between releases. A throw surfaces as a plugin failed error in the
|
||||
// TUI, so every shape must resolve to a callable cleanup instead.
|
||||
// OpenCode probes varying setup shapes; each must return cleanup without throwing in the TUI.
|
||||
const contexts: unknown[] = [
|
||||
undefined,
|
||||
{},
|
||||
@@ -161,8 +156,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
? _internals.getOpenCode2PluginSource()
|
||||
: _internals.getOpenCodePluginSource()
|
||||
)
|
||||
// Why: both plugin files share one config dir, so distinct ids keep the
|
||||
// loader from reporting a duplicate-id collision as a plugin failure.
|
||||
// Both plugins share one directory; duplicate IDs disable the second plugin.
|
||||
expect(module.default?.id).toBe(
|
||||
agent === 'opencode2' ? 'orca-opencode2-status' : 'orca-opencode-status'
|
||||
)
|
||||
@@ -225,7 +219,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
expect(subscriptionSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
// Why: the host's OpenCode 1 session binder must stay off OpenCode 2 posts only.
|
||||
it('declares no OpenCode major on posts from the OpenCode 1 server() entry', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
@@ -341,9 +334,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 raises one form primitive for several producers, and its owner
|
||||
// id — not its metadata — decides whether Orca can ever retire the blocker
|
||||
// (v2.0.12 capture in docs/bug-reproductions/opencode2-form-created-kinds).
|
||||
// OpenCode 2 form ownership decides blocker retirement; metadata alone cannot identify it.
|
||||
async function runSetupBridge(
|
||||
events: { type: string; data: Record<string, unknown> }[]
|
||||
): Promise<{ names: string[]; cleanup?: () => Promise<void> }> {
|
||||
@@ -409,8 +400,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why: metadata is optional in OpenCode's schema and its kind is a convention,
|
||||
// so any session-owned form must block rather than silently disappear.
|
||||
// Optional metadata kinds cannot decide whether session-owned forms block.
|
||||
it.each([
|
||||
['no metadata at all', undefined],
|
||||
['metadata without a kind', { server: 'server' }],
|
||||
@@ -541,7 +531,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
|
||||
if (transition === 'disposed') {
|
||||
await hooks?.dispose?.()
|
||||
} else {
|
||||
// Seed ancestry while the earlier prompt lookup remains suspended.
|
||||
await hooks?.event({
|
||||
event: { type: 'session.created', properties: { info: { id: 'ses_root' } } }
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ const ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_TOKEN'
|
||||
] as const
|
||||
|
||||
// Event shapes as OpenCode 2.0.14 delivers them to both server and TUI plugins.
|
||||
// OpenCode 2.0.14 event shapes, shared by server and TUI plugins.
|
||||
function turn(sessionID: string, text: string): { start: BusEvent[]; finish: BusEvent[] } {
|
||||
const assistantMessageID = `msg_${sessionID}_a`
|
||||
return {
|
||||
@@ -83,7 +83,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
let savedArgv: string[]
|
||||
let posts: Post[]
|
||||
// Every post in arrival order, including the start boundary each fresh TUI lands.
|
||||
/** Includes the sessionless start boundary from each fresh TUI. */
|
||||
let allPosts: Post[]
|
||||
let failPosts: boolean
|
||||
let postDelayMs: number
|
||||
@@ -123,7 +123,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
return new Response('{}', { status: 500 })
|
||||
}
|
||||
allPosts.push(body)
|
||||
// Why apart: a fresh TUI's start boundary names no session; per-session cases assert what follows it.
|
||||
// Per-session assertions exclude the sessionless fresh-TUI boundary.
|
||||
if (
|
||||
body.payload?.hook_event_name !== 'SessionStart' ||
|
||||
body.payload.sessionID !== undefined
|
||||
@@ -178,7 +178,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
})
|
||||
|
||||
async function loadPlugin(dir = tempDir): Promise<PluginModule> {
|
||||
// Why: a unique basename per load defeats the ESM module cache, like a separate process.
|
||||
// Bypass ESM caching to simulate a separate process.
|
||||
const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`)
|
||||
writeFileSync(pluginPath, _internals.getOpenCodePluginSource())
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent.
|
||||
@@ -294,7 +294,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined()
|
||||
})
|
||||
|
||||
// Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server.
|
||||
it('gives each overlapping pane only its own session and its own Idle', async () => {
|
||||
const a = turn(SES_A, 'A long SLEEP=12')
|
||||
const b = turn(SES_B, 'B short SLEEP=3')
|
||||
@@ -311,7 +310,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
|
||||
expect(paneA.every((post) => post.paneKey === PANE_A)).toBe(true)
|
||||
expect(paneB.every((post) => post.paneKey === PANE_B)).toBe(true)
|
||||
// Keeps the host's OpenCode 1 session binder off these posts.
|
||||
// Prevent the host’s OpenCode 1 session binder from consuming these posts.
|
||||
expect([...paneA, ...paneB].every((post) => post.opencodeMajor === 2)).toBe(true)
|
||||
expect(new Set(paneA.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_A]))
|
||||
expect(new Set(paneB.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_B]))
|
||||
@@ -326,7 +325,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Captured s5 shape: the TUI's route reached the new session after execution had started.
|
||||
it('hydrates a session whose route switch lands after execution started', async () => {
|
||||
const b = turn(SES_B, 'B tui SLEEP=4')
|
||||
const paneB = await runPane(PANE_B, SES_B, async (tui) => {
|
||||
@@ -372,7 +370,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
expect(posts).toEqual([])
|
||||
})
|
||||
|
||||
// Why: a turn the TUI did not see end must not hold the pane Working (e.g. across a reconnect).
|
||||
it('settles an owned turn once the session data says it ended without an end event', async () => {
|
||||
process.env.ORCA_PANE_KEY = PANE_A
|
||||
const tui = fakeTui()
|
||||
@@ -387,7 +384,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why: a turn that fails fast arrives as one burst; each level must post once, not flicker.
|
||||
it('does not settle a turn early while its events are still queued', async () => {
|
||||
process.env.ORCA_PANE_KEY = PANE_A
|
||||
const tui = fakeTui()
|
||||
@@ -518,7 +514,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done.
|
||||
it('shows Done for a turn that ended while the plugin was reloading', async () => {
|
||||
const tui = fakeTui()
|
||||
const firstGeneration = await start(tui)
|
||||
@@ -567,7 +562,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
await secondGeneration?.()
|
||||
})
|
||||
|
||||
// Why: with posts slower than events, the old adapter's decisions trailed the data and flickered.
|
||||
it('posts each level once and in order while posts are slow', async () => {
|
||||
postDelayMs = 120
|
||||
const tui = fakeTui()
|
||||
@@ -587,7 +581,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
})
|
||||
await tick(300)
|
||||
await cleanup?.()
|
||||
// The reply text lands after Busy and before Idle, so Done shows it.
|
||||
expect(summary(posts)).toEqual([
|
||||
`SessionStart:${SES_A}`,
|
||||
`MessagePart:${SES_A}`,
|
||||
@@ -599,8 +592,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
expect(summary(allPosts)[0]).toBe('SessionStart:undefined')
|
||||
})
|
||||
|
||||
// Why: Orca may still show this pane a status another process left, e.g. an older shared-service
|
||||
// plugin that posted another pane's turn here before an upgrade.
|
||||
// Fresh panes may retain status left by another process before an upgrade.
|
||||
it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => {
|
||||
const tui = fakeTui()
|
||||
const firstGeneration = await start(tui)
|
||||
@@ -650,7 +642,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])
|
||||
})
|
||||
|
||||
// Why: without plugin memory a reload cannot be told from a start, so it must not reset a Done.
|
||||
it('lands no start boundary where OpenCode keeps no plugin memory', async () => {
|
||||
const tui = fakeTui()
|
||||
Reflect.deleteProperty(tui.ctx, 'storage')
|
||||
@@ -724,7 +715,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why: Orca reads every MessagePart as Working, so reply text must not bury an open request.
|
||||
// MessagePart implies Working, so it must not bury an open request.
|
||||
it('keeps Needs input while the root streams text beside a waiting background child', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
@@ -744,7 +735,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)])
|
||||
await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
|
||||
// The user browses away; the reply lands while this TUI is disconnected.
|
||||
tui.navigate(SES_B)
|
||||
await pump(tui, [{ type: 'server.connected', data: {} }])
|
||||
await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`))
|
||||
@@ -770,7 +760,6 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why: an Orca restart moves the hook endpoint while the pane's level stays the same.
|
||||
it('re-posts the current level once the hook endpoint moves', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
@@ -860,7 +849,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
// Why: an older SSH relay installs this file but not the TUI copy; nothing else would report.
|
||||
// Older SSH relays install the server file without a TUI copy.
|
||||
it('keeps reporting from a serve process whose installer wrote no TUI copy', async () => {
|
||||
process.argv = [...savedArgv, 'serve', '--service']
|
||||
const server = serverContext()
|
||||
|
||||
@@ -21,8 +21,7 @@ type PluginModule = { OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<Plugi
|
||||
|
||||
const ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN'] as const
|
||||
|
||||
// OpenCode 1 `run` loads the plugin in-process; the host already shows the run as Working from the
|
||||
// process, so a session-start idle row there would only blink the pane.
|
||||
// The host already marks OpenCode 1 run processes Working; a plugin start-idle event would flicker.
|
||||
describe('OpenCode plugin session start inside a `run` process', () => {
|
||||
let tempDir: string
|
||||
let events: string[]
|
||||
|
||||
@@ -58,7 +58,6 @@ describe('OpenCodeHookService.refreshInstalledPlugins (app start)', () => {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why: QA saw an upgraded app keep a running service on the old plugin until any pane opened.
|
||||
it('upgrades an existing install, TUI copy included, so a running service reloads it', () => {
|
||||
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
@@ -119,7 +118,6 @@ describe('OpenCodeHookService.refreshInstalledPlugins (app start)', () => {
|
||||
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
writeFileSync(join(pluginsDir, PLUGIN), '// old')
|
||||
// A file where the TUI copy's directory belongs makes the config-dir write fail.
|
||||
writeFileSync(join(pluginsDir, 'orca-opencode2-status-tui'), 'obstruction')
|
||||
const overlayPlugins = join(userDataDir, 'opencode2-config-overlays', 'a', 'plugins')
|
||||
mkdirSync(overlayPlugins, { recursive: true })
|
||||
|
||||
@@ -50,7 +50,6 @@ const { isUsableId, toSafeDirName } = _internals
|
||||
|
||||
describe('OpenCode id safety guard', () => {
|
||||
it('accepts the daemon-path sessionId shape (worktreeId@@uuid with ::/...)', () => {
|
||||
// Why: after #1148 pty.ts mints sessionIds like <worktreeId>@@<uuid> with "::" and a path; the old strict regex rejected every real id.
|
||||
const daemonSessionId =
|
||||
'50c010a2-bc8e-4eb1-8847-5812133ad6df::/Users/thebr/ghostx/workspaces/noqa/autoheal@@a1b2c3d4'
|
||||
expect(isUsableId(daemonSessionId)).toBe(true)
|
||||
@@ -66,7 +65,6 @@ describe('OpenCode id safety guard', () => {
|
||||
})
|
||||
|
||||
it('rejects non-string runtime values even though the type says string', () => {
|
||||
// Why: the typeof guard is defense-in-depth for any-typed callers; a test keeps a refactor from silently deleting it.
|
||||
expect(isUsableId(undefined as unknown as string)).toBe(false)
|
||||
expect(isUsableId(null as unknown as string)).toBe(false)
|
||||
expect(isUsableId(42 as unknown as string)).toBe(false)
|
||||
@@ -74,7 +72,6 @@ describe('OpenCode id safety guard', () => {
|
||||
|
||||
it('derives a filesystem-safe directory name independent of the raw id', () => {
|
||||
const name = toSafeDirName('50c010::/Users/thebr/x/y@@uuid')
|
||||
// Pure hex, bounded length — no slashes, colons, or caller content.
|
||||
expect(name).toMatch(/^[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
@@ -89,7 +86,6 @@ describe('OpenCode id safety guard', () => {
|
||||
})
|
||||
|
||||
describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
// Why: exercise the public surface against a real filesystem so regressions fail loudly (before #1148 daemon-shaped ids silently returned {}).
|
||||
const daemonSessionId =
|
||||
'50c010a2-bc8e-4eb1-8847-5812133ad6df::/Users/thebr/ghostx/workspaces/noqa/autoheal@@a1b2c3d4'
|
||||
const plainUuidId = 'c0ffee00-0000-4000-8000-000000000000'
|
||||
@@ -128,10 +124,8 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
expect(env).toEqual({})
|
||||
const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js')
|
||||
expect(existsSync(pluginPath)).toBe(true)
|
||||
// Sanity-check the file has plugin source, not a stray write.
|
||||
const pluginSource = readFileSync(pluginPath, 'utf8')
|
||||
expect(pluginSource).toContain('messageID: part.messageID')
|
||||
// Legacy attach loads an object TUI entry; the server export stays callable.
|
||||
const tuiEntry = join(
|
||||
resolveOpenCodeConfigDirectory(),
|
||||
'plugins',
|
||||
@@ -144,7 +138,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime())
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn.
|
||||
// OpenCode 2 reloads on mtime changes, resetting status mid-turn.
|
||||
it('leaves a current installed plugin untouched and replaces a stale one', () => {
|
||||
const service = new OpenCodeHookService()
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
@@ -160,7 +154,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource())
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target.
|
||||
// OpenCode 2 stats file-level symlink targets used by dotfile managers.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'compares a symlinked plugin by its target and writes through only when stale',
|
||||
() => {
|
||||
@@ -189,14 +183,13 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: a service that reloads between the two writes must already find the TUI copy and stand down.
|
||||
// A service reloading between writes must already find the TUI copy and stand down.
|
||||
it('writes the TUI copy before the server plugin file', () => {
|
||||
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
|
||||
const serverPath = join(pluginsDir, 'orca-opencode2-status.js')
|
||||
const tuiDir = join(pluginsDir, 'orca-opencode2-status-tui')
|
||||
rmSync(serverPath, { recursive: true, force: true })
|
||||
rmSync(tuiDir, { recursive: true, force: true })
|
||||
// A directory in the server file's place makes that write fail.
|
||||
mkdirSync(serverPath, { recursive: true })
|
||||
try {
|
||||
openCode2HookService.buildPtyEnv(daemonSessionId)
|
||||
@@ -207,15 +200,12 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader
|
||||
// rejects a default export that only has server(). Asserting the emitted *source* is
|
||||
// not enough; the installed file is what the v2 server validates, so load it.
|
||||
it('installs a plugin whose default export satisfies both the v1 and v2 loaders', async () => {
|
||||
const service = new OpenCodeHookService()
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
|
||||
const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js')
|
||||
// Why: a .mjs copy so Node parses the installed file as ESM without a package.json.
|
||||
// Use .mjs to load installed ESM without a package.json.
|
||||
const modulePath = join(userDataDir, `installed-opencode-plugin-${Date.now()}.mjs`)
|
||||
writeFileSync(modulePath, readFileSync(pluginPath, 'utf8'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertions below validate the shape this names.
|
||||
@@ -224,15 +214,11 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
}
|
||||
|
||||
expect(module.default?.id).toBe('orca-opencode-status')
|
||||
// v1 loader: "must default export an object with server()".
|
||||
expect(module.default?.server).toBeTypeOf('function')
|
||||
// v2 loader: "Plugin must export a default definition with an id and an effect or setup function."
|
||||
expect(module.default?.setup).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
// Why: pre-1.4.209 Orca wrote a server()-only plugin into <userData>/opencode-hooks/shared and
|
||||
// stopped maintaining it; shells and OpenCode 2 background services still pointing there got
|
||||
// "Plugin must export a default definition with an id and an effect or setup function."
|
||||
// Pre-1.4.209 installs left server-only plugins in this retired but still-used directory.
|
||||
it('refreshes a stale plugin left in the retired shared hooks dir', async () => {
|
||||
const legacyPluginPath = join(
|
||||
userDataDir,
|
||||
@@ -258,7 +244,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
expect(module.default?.id).toBe('orca-opencode-status')
|
||||
expect(module.default?.server).toBeTypeOf('function')
|
||||
expect(module.default?.setup).toBeTypeOf('function')
|
||||
// A service loading this dir stands down only when the TUI copy sits beside it.
|
||||
// Services stand down only when their TUI copy exists alongside them.
|
||||
expect(
|
||||
readFileSync(join(legacyPluginPath, '..', 'orca-opencode-status-tui', 'tui.js'), 'utf8')
|
||||
).toContain('tui: setupLegacyOpenCodeTui')
|
||||
@@ -320,23 +306,18 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(legacyPluginPath, past, past)
|
||||
new OpenCodeHookService().buildPtyEnv(daemonSessionId)
|
||||
// Why: a running OpenCode 2 service re-runs its plugin load on every write to a watched plugin file.
|
||||
expect(statSync(legacyPluginPath).mtimeMs).toBe(past.getTime())
|
||||
})
|
||||
|
||||
// Why: #22506 — both variants install side by side in one global plugins dir, and
|
||||
// OpenCode 2 kills every plugin after the first that reuses an id ("Duplicate plugin
|
||||
// ID"). Discovery sorts by path, so orca-opencode-status.js always wins and the
|
||||
// opencode2 plugin never loads. Assert the installed files, not just the sources.
|
||||
// OpenCode 2 disables later plugins with duplicate IDs; verify installed artifacts.
|
||||
it('installs both family plugins into one config dir under distinct ids', async () => {
|
||||
expect(new OpenCodeHookService().buildPtyEnv(daemonSessionId)).toEqual({})
|
||||
// #22440 Issue 2: the opencode2 variant must not shadow global config discovery either.
|
||||
expect(openCode2HookService.buildPtyEnv(daemonSessionId)).toEqual({})
|
||||
|
||||
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
|
||||
const ids: string[] = []
|
||||
for (const fileName of ['orca-opencode-status.js', 'orca-opencode2-status.js']) {
|
||||
// Why: a .mjs copy so Node parses the installed file as ESM without a package.json.
|
||||
// Use .mjs to load installed ESM without a package.json.
|
||||
const modulePath = join(userDataDir, `installed-${fileName}-${Date.now()}.mjs`)
|
||||
writeFileSync(modulePath, readFileSync(join(pluginsDir, fileName), 'utf8'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertions below validate the shape this names.
|
||||
@@ -373,7 +354,6 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
})
|
||||
|
||||
it('buildPtyEnv preserves a user-set OPENCODE_CONFIG_DIR when the id is unusable', () => {
|
||||
// Why: even when the id is rejected, don't blow away the user's own OPENCODE_CONFIG_DIR.
|
||||
const service = new OpenCodeHookService()
|
||||
const userDir = mkdtempSync(join(tmpdir(), 'orca-opencode-userdir-'))
|
||||
try {
|
||||
@@ -396,7 +376,6 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
})
|
||||
|
||||
describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () => {
|
||||
// Why: with a user-set OPENCODE_CONFIG_DIR, mirror it into an overlay rather than overwrite it (docs/opencode-config-dir-collision.md).
|
||||
const ptyId = 'overlay-pty-1'
|
||||
let userDataDir: string
|
||||
let userConfigDir: string
|
||||
@@ -417,7 +396,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
|
||||
beforeEach(() => {
|
||||
userConfigDir = mkdtempSync(join(tmpdir(), 'orca-opencode-overlay-userconfig-'))
|
||||
// Realistic user config: top-level files plus a plugins/ dir with a user plugin.
|
||||
writeFileSync(join(userConfigDir, 'opencode.json'), '{"userTheme":"solarized"}')
|
||||
writeFileSync(join(userConfigDir, 'auth.json'), 'user-auth-token')
|
||||
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
|
||||
@@ -449,7 +427,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
)
|
||||
expect(env.OPENCODE_CONFIG_DIR).not.toBe(userConfigDir)
|
||||
|
||||
// Mirrored user files reachable via the overlay.
|
||||
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'opencode.json'), 'utf8')).toBe(
|
||||
'{"userTheme":"solarized"}'
|
||||
)
|
||||
@@ -460,7 +437,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
'export default () => {}'
|
||||
)
|
||||
|
||||
// Orca's status plugin is a sibling, not a replacement.
|
||||
const orcaPluginPath = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js')
|
||||
expect(existsSync(orcaPluginPath)).toBe(true)
|
||||
expect(readFileSync(orcaPluginPath, 'utf8')).toContain('OrcaOpenCodeStatusPlugin')
|
||||
@@ -471,35 +447,31 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'mirrors top-level entries via symlinks so plugins/ is a real directory',
|
||||
() => {
|
||||
// Why: only plugins/ needs per-entry mirroring (Orca drops a sibling); other entries are single symlinks so user edits propagate live.
|
||||
// Only plugins needs per-entry links; other links preserve live user edits.
|
||||
const service = new OpenCodeHookService()
|
||||
const env = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
const overlay = env.OPENCODE_CONFIG_DIR!
|
||||
expect(lstatSync(join(overlay, 'opencode.json')).isSymbolicLink()).toBe(true)
|
||||
expect(lstatSync(join(overlay, 'auth.json')).isSymbolicLink()).toBe(true)
|
||||
// plugins/ must be a real dir in the overlay so Orca can drop its sibling plugin.
|
||||
expect(lstatSync(join(overlay, 'plugins')).isDirectory()).toBe(true)
|
||||
expect(lstatSync(join(overlay, 'plugins')).isSymbolicLink()).toBe(false)
|
||||
// user-plugin.js inside plugins/ is mirrored entry-by-entry.
|
||||
expect(lstatSync(join(overlay, 'plugins', 'user-plugin.js')).isSymbolicLink()).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it("does not overwrite a user plugin file with the same filename as Orca's plugin", () => {
|
||||
// Why: a user plugin named orca-opencode-status.js must not be symlinked into the overlay, or writeFileSync would clobber it.
|
||||
// Mirroring this filename would let plugin installation overwrite the user’s file.
|
||||
const userOrcaSentinel = 'USER OWNED ORCA-NAMED PLUGIN — DO NOT CLOBBER'
|
||||
writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), userOrcaSentinel)
|
||||
|
||||
const service = new OpenCodeHookService()
|
||||
const env = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
// User's source file must be untouched.
|
||||
expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
|
||||
userOrcaSentinel
|
||||
)
|
||||
|
||||
// Overlay copy is Orca's real plugin source, not the user's file.
|
||||
const overlayPlugin = readFileSync(
|
||||
join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'),
|
||||
'utf8'
|
||||
@@ -530,27 +502,22 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not write through a symlinked plugins/ directory into the user filesystem',
|
||||
() => {
|
||||
// Why: writing Orca's plugin through a symlinked plugins/ would leak into the user's fs (docs/opencode-config-dir-collision.md).
|
||||
// Writing through a symlinked plugins directory would modify user-owned files.
|
||||
const realPluginsDir = mkdtempSync(join(tmpdir(), 'orca-real-plugins-'))
|
||||
try {
|
||||
writeFileSync(join(realPluginsDir, 'real-plugin.js'), 'REAL USER PLUGIN')
|
||||
|
||||
// Swap plugins/ for a symlink to the external "real" dir (dotfiles pattern).
|
||||
rmSync(join(userConfigDir, 'plugins'), { recursive: true, force: true })
|
||||
symlinkSync(realPluginsDir, join(userConfigDir, 'plugins'), 'dir')
|
||||
|
||||
const service = new OpenCodeHookService()
|
||||
const env = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
// The user's real filesystem must NOT receive Orca's status plugin.
|
||||
expect(existsSync(join(realPluginsDir, 'orca-opencode-status.js'))).toBe(false)
|
||||
// Overlay's plugins/ must be a real dir, else writes leak into the user's filesystem.
|
||||
expect(lstatSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins')).isSymbolicLink()).toBe(false)
|
||||
// Orca's status plugin lands in the overlay only.
|
||||
expect(
|
||||
existsSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'))
|
||||
).toBe(true)
|
||||
// User's real plugin is reachable via the overlay (plugins mirrored entry-by-entry after resolving the symlink target).
|
||||
expect(
|
||||
readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'real-plugin.js'), 'utf8')
|
||||
).toBe('REAL USER PLUGIN')
|
||||
@@ -561,16 +528,14 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
)
|
||||
|
||||
it("preserves the user's OPENCODE_CONFIG_DIR when the path does not exist", () => {
|
||||
// Why: leave a nonexistent user path alone so OpenCode surfaces the typo instead of Orca silently hiding it.
|
||||
// Preserve typo paths so OpenCode reports them instead of silently hiding user config.
|
||||
const service = new OpenCodeHookService()
|
||||
const missingPath = join(tmpdir(), `orca-opencode-nope-${Date.now()}`)
|
||||
expect(existsSync(missingPath)).toBe(false)
|
||||
|
||||
const env = service.buildPtyEnv(ptyId, missingPath)
|
||||
expect(env).toEqual({ OPENCODE_CONFIG_DIR: missingPath })
|
||||
// No overlay was created at the typo path.
|
||||
expect(existsSync(missingPath)).toBe(false)
|
||||
// No overlay dir under userData either.
|
||||
expect(
|
||||
existsSync(
|
||||
join(userDataDir, 'opencode-config-overlays', toSafeDirName(`source:${missingPath}`))
|
||||
@@ -579,7 +544,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
})
|
||||
|
||||
it("preserves the user's OPENCODE_CONFIG_DIR when the mirror step fails", async () => {
|
||||
// Why: if mirroring fails (e.g. Windows EPERM), fall back to the user's OPENCODE_CONFIG_DIR so their config still loads.
|
||||
const overlayMirror = await import('../pty/overlay-mirror')
|
||||
const mirrorSpy = vi.spyOn(overlayMirror, 'mirrorEntry').mockImplementation(() => {
|
||||
throw new Error('simulated EPERM on symlink')
|
||||
@@ -588,7 +552,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
const service = new OpenCodeHookService()
|
||||
const env = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
expect(env).toEqual({ OPENCODE_CONFIG_DIR: userConfigDir })
|
||||
// Overlay cleanup is deliberately off the fallback path; it may hold OpenCode runtime files on Windows.
|
||||
// Fallback overlays may contain live Windows runtime files; defer cleanup.
|
||||
const overlayDir = join(
|
||||
userDataDir,
|
||||
'opencode-config-overlays',
|
||||
@@ -604,7 +568,7 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'clearPty leaves the source overlay off the teardown hot path',
|
||||
() => {
|
||||
// Why: OpenCode may fill the overlay with runtime files; PTY teardown must not recursively delete it on Electron main.
|
||||
// Runtime files can make recursive overlay deletion block Electron main.
|
||||
const service = new OpenCodeHookService()
|
||||
service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
@@ -618,15 +582,12 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
service.clearPty(ptyId)
|
||||
|
||||
expect(existsSync(overlayDir)).toBe(true)
|
||||
// User config must still exist with all original contents.
|
||||
expectUserConfigIntact()
|
||||
// The plugins/ dir under the user config is also intact.
|
||||
expect(readdirSync(join(userConfigDir, 'plugins'))).toEqual(['user-plugin.js'])
|
||||
}
|
||||
)
|
||||
|
||||
it('rebuilding the overlay for the same ptyId does not corrupt the user dir', () => {
|
||||
// Mirrors daemon cold-restore: rebuilding for the same sessionId must refresh the overlay without symlink-walking into user data.
|
||||
const service = new OpenCodeHookService()
|
||||
service.buildPtyEnv(ptyId, userConfigDir)
|
||||
service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
@@ -50,17 +50,16 @@ type OpenCodeHookVariant = {
|
||||
legacyHooksDir: string
|
||||
overlayDir: string
|
||||
pluginSource: () => string
|
||||
/** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */
|
||||
/** Install a TUI copy only for OpenCode variants that support it. */
|
||||
installsTuiPlugin?: boolean
|
||||
tuiOnlyDirectory?: string
|
||||
}
|
||||
|
||||
// Why: session IDs may contain path separators and are hashed downstream; cap pathological input.
|
||||
// IDs are hashed downstream, so separators are valid; cap pathological input.
|
||||
function isUsableId(id: string): boolean {
|
||||
return typeof id === 'string' && id.length > 0 && id.length <= 1024
|
||||
}
|
||||
|
||||
// Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini.
|
||||
export class OpenCodeHookService {
|
||||
private readonly pluginSource: () => string
|
||||
private readonly pluginFileName: string
|
||||
@@ -96,7 +95,7 @@ export class OpenCodeHookService {
|
||||
}
|
||||
|
||||
clearPty(_ptyId: string): void {
|
||||
// Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows.
|
||||
// Directories outlive individual PTYs; recursive cleanup here can freeze Windows main.
|
||||
}
|
||||
|
||||
installIntoSourceOverlay(
|
||||
@@ -127,7 +126,6 @@ export class OpenCodeHookService {
|
||||
defaultConfigDir?: string
|
||||
): Record<string, string> {
|
||||
if (!isUsableId(ptyId)) {
|
||||
// Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited.
|
||||
return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {}
|
||||
}
|
||||
|
||||
@@ -164,8 +162,7 @@ export class OpenCodeHookService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps
|
||||
// processes that load it later; a running OpenCode 2 service keeps its cached module until restarted.
|
||||
// Refresh pre-1.4.209 server-only plugins for future loads; running services may retain cached modules.
|
||||
refreshLegacySharedPlugin(): void {
|
||||
if (this.tuiOnlyDirectory) {
|
||||
return
|
||||
@@ -175,7 +172,7 @@ export class OpenCodeHookService {
|
||||
try {
|
||||
const source = this.pluginSource()
|
||||
const installed = readFileSync(pluginPath, 'utf8')
|
||||
// Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane.
|
||||
// Old-directory services must find the TUI copy or they report under the starter pane.
|
||||
writeLegacyOpenCodePluginWithAclRetry(
|
||||
join(pluginsDir, openCodeTuiPluginDirName(this.pluginFileName), 'tui.js'),
|
||||
() => this.writeTuiPlugin(pluginsDir, source)
|
||||
@@ -193,8 +190,7 @@ export class OpenCodeHookService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a running OpenCode 2 service reloads a changed plugin file, so refreshing Orca's existing
|
||||
// installs at app start upgrades it without waiting for the next pane. Never creates an install.
|
||||
// Refresh existing installs at startup so OpenCode 2 reloads upgrades before the next pane opens.
|
||||
refreshInstalledPlugins(): void {
|
||||
this.refreshLegacySharedPlugin()
|
||||
const overlayRoot = this.getOverlayRoot()
|
||||
@@ -233,10 +229,10 @@ export class OpenCodeHookService {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file.
|
||||
// Link config for live user edits; keep plugins real so Orca can install a sibling.
|
||||
private mirrorUserConfig(sourceDir: string, overlayDir: string): void {
|
||||
const previousManifest = readOpenCodeOverlayManifest(overlayDir)
|
||||
// Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive.
|
||||
// Remove only mirrored paths; persistent overlays may contain OpenCode runtime directories.
|
||||
clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName)
|
||||
|
||||
const nextManifest: OpenCodeOverlayManifest = {
|
||||
@@ -249,25 +245,25 @@ export class OpenCodeHookService {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
|
||||
if (entry.name === 'plugins') {
|
||||
// Why: check isSymbolicLink before isDirectory — a Windows junction reports both, and the symlink branch must win.
|
||||
// Windows junctions report both flags; symlink handling must win.
|
||||
const isSymlink = entry.isSymbolicLink()
|
||||
let isLinkPointingToDir = false
|
||||
if (isSymlink) {
|
||||
try {
|
||||
isLinkPointingToDir = statSync(sourcePath).isDirectory()
|
||||
} catch {
|
||||
// Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it.
|
||||
// Preserve broken/inaccessible symlinks verbatim.
|
||||
isLinkPointingToDir = false
|
||||
}
|
||||
}
|
||||
|
||||
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
|
||||
// Why: resolve a symlinked plugins/ to its real target so <overlay>/plugins stays a real dir and writePluginIntoOverlay can't write through the user's link.
|
||||
// Resolve user plugin links while keeping the overlay directory real to contain writes.
|
||||
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
|
||||
const overlayPluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(overlayPluginsDir, { recursive: true })
|
||||
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
|
||||
// Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file.
|
||||
// Never mirror Orca’s filename: installation would overwrite the user’s target.
|
||||
if (
|
||||
pluginEntry.name === this.pluginFileName ||
|
||||
pluginEntry.name === this.tuiOnlyDirectory ||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'
|
||||
export const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks'
|
||||
export const OPENCODE2_LEGACY_HOOKS_DIR = 'opencode2-hooks'
|
||||
|
||||
// Why: before 1.4.209 Orca pointed OPENCODE_CONFIG_DIR at this dir; shells and OpenCode 2 background services from then can still load it.
|
||||
// Pre-1.4.209 shells and background services may still load this inherited directory.
|
||||
export function getOpenCodeLegacySharedConfigDir(
|
||||
userDataPath: string,
|
||||
legacyHooksDir: string
|
||||
|
||||
@@ -5,13 +5,8 @@ import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir'
|
||||
const ORCA_ENV_PREFIXES = ['ORCA_OPENCODE_', 'ORCA_DATA_ACCOUNT_']
|
||||
const ACCOUNT_ENV_KEYS = ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'OPENCODE_DB', 'OPENCODE_AUTH_CONTENT']
|
||||
|
||||
/**
|
||||
* A structured chat's OpenCode reports through the chat, never through Orca's terminal status
|
||||
* plugin. Restores the user's own `OPENCODE_CONFIG_DIR` over the overlay a terminal pane would have
|
||||
* (dropping it when none was recorded, or when it is the retired shared plugin directory), and drops
|
||||
* every Orca OpenCode and data-account variable. Runs after the account is applied. Returns
|
||||
* the keys the child must not inherit.
|
||||
*/
|
||||
/** Run after account setup: restore user config and remove terminal-hook/account inheritance.
|
||||
* Return keys the child must not inherit; structured chats report status through chat. */
|
||||
export function scrubOpenCodeAcpEnvironment(
|
||||
env: Record<string, string>,
|
||||
inherited: NodeJS.ProcessEnv,
|
||||
@@ -45,7 +40,7 @@ export function scrubOpenCodeAcpEnvironment(
|
||||
} else {
|
||||
env.OPENCODE_CONFIG_DIR = view.OPENCODE_CONFIG_DIR
|
||||
}
|
||||
// The launch environment decides these; one it leaves unset must not come from Orca's overlay.
|
||||
// Account keys left unset by the child environment must not leak from the terminal overlay.
|
||||
for (const key of ACCOUNT_ENV_KEYS) {
|
||||
if (env[key] === undefined) {
|
||||
removed.push(key)
|
||||
|
||||
@@ -4,40 +4,22 @@ import {
|
||||
type WindowsProcessRow as NativeWindowsProcessRow
|
||||
} from '../windows/windows-process-table'
|
||||
|
||||
/**
|
||||
* Host-wide sweep locating live OpenCode client processes for the
|
||||
* session→pane binder (#21359).
|
||||
*
|
||||
* Why a dedicated sweep instead of reusing the memory collector's: that
|
||||
* index carries pid/ppid/cpu/rss but no argv or start times, and importing
|
||||
* the memory subsystem here would drag its Electron app-metrics dependency
|
||||
* into the hook path. On Windows the table is read only through the native
|
||||
* reader (`windows-process-table.ts`); on macOS/Linux through one `ps` call.
|
||||
* The invocation pattern (5 s timeout, 10 MB cap, fail-open []) mirrors
|
||||
* `windows-process-resource-collector.ts`.
|
||||
*/
|
||||
// Keep argv/start-time discovery independent of Electron’s memory collector for hook-process use.
|
||||
|
||||
/** One process identity row from a host sweep. */
|
||||
export type ProcessIdentityRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
/** ms epoch the process started. */
|
||||
startedAtMs: number
|
||||
/**
|
||||
* Kernel-reported executable name (`comm=` on POSIX, `name` on Windows).
|
||||
* Unlike `args=`, this is not a reconstructed string, so an install path
|
||||
* containing spaces cannot split it. Empty when the sweep could not read it;
|
||||
* classification then falls back to argv[0].
|
||||
*/
|
||||
/** Kernel executable name preserves spaces lost by reconstructed args; empty falls back to argv[0]. */
|
||||
executable: string
|
||||
/** argv approximation; see parsePsArgsLine. */
|
||||
/** Reconstructed command-line approximation, not exact argument boundaries. */
|
||||
argv: string[]
|
||||
}
|
||||
|
||||
const SWEEP_TIMEOUT_MS = 5_000
|
||||
const SWEEP_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** `[[dd-]hh:]mm:ss` → elapsed ms, or null when the shape is unknown. */
|
||||
/** Convert ps elapsed time ([[dd-]hh:]mm:ss) to epoch start milliseconds; null for unknown formats. */
|
||||
export function parsePsElapsedToMs(etime: string, nowMs: number): number | null {
|
||||
const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/)
|
||||
if (!match) {
|
||||
@@ -53,15 +35,8 @@ export function parsePsElapsedToMs(etime: string, nowMs: number): number | null
|
||||
return nowMs - ((days * 24 + hours) * 3_600 + minutes * 60 + seconds) * 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a command line into argv, grouping `"..."` so a quoted executable
|
||||
* path survives as argv[0]. Covers the shapes that matter here (a quoted
|
||||
* install path plus plain flags); it is not a full shell parser — an escaped
|
||||
* quote inside a quoted span still splits. Downstream only flag-adjacent
|
||||
* values (`--session <id>`) are read from this argv; classification uses the
|
||||
* kernel executable name, because `ps` `args=` cannot preserve argv
|
||||
* boundaries for unquoted paths.
|
||||
*/
|
||||
/** Group quoted paths; escaped quotes and unquoted spaces cannot preserve argv boundaries.
|
||||
* Use kernel executable names for classification; argv is for flag-adjacent values. */
|
||||
export function splitCommandLineArgv(commandLine: string): string[] {
|
||||
const argv: string[] = []
|
||||
const pattern = /"([^"]*)"|(\S+)/g
|
||||
@@ -72,17 +47,9 @@ export function splitCommandLineArgv(commandLine: string): string[] {
|
||||
return argv.filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `ps -eo pid=,ppid=,etime=,args=` line. `args` is a reconstructed
|
||||
* command-and-arguments string: argv boundaries are lost, so a path with an
|
||||
* unquoted space (e.g. `/opt/Open Code/opencode`) splits argv[0] in two.
|
||||
* The executable name therefore comes from a separate `comm=` sweep;
|
||||
* this parser records the flags it can still read reliably (`--session`
|
||||
* values never contain spaces) and leaves `executable` empty for the join.
|
||||
*/
|
||||
/** Parse reconstructed args, leaving executable empty for the comm join. */
|
||||
export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow | null {
|
||||
// Why a regex instead of split-with-limit: split discards everything past
|
||||
// the limit, which would truncate argv to its first token.
|
||||
// A split limit discards the remaining arguments.
|
||||
const match = line.trim().match(/^(\S+)\s+(\S+)\s+(\S+)\s+([\s\S]*\S)\s*$/)
|
||||
if (!match) {
|
||||
return null
|
||||
@@ -106,11 +73,7 @@ export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow
|
||||
return { pid, ppid, startedAtMs, executable: '', argv }
|
||||
}
|
||||
|
||||
/**
|
||||
* One `ps -eo pid=,comm=` line. `comm` is the kernel's executable name as a
|
||||
* trailing field, so it may itself contain spaces — everything past the pid
|
||||
* is the name. Empty names are dropped; the join then falls back to argv[0].
|
||||
*/
|
||||
/** Keep all text after pid: kernel executable names can contain spaces. */
|
||||
export function parsePsCommLine(line: string): { pid: number; executable: string } | null {
|
||||
const match = line.trim().match(/^(\S+)\s+([\s\S]*\S)\s*$/)
|
||||
if (!match) {
|
||||
@@ -124,10 +87,7 @@ export function parsePsCommLine(line: string): { pid: number; executable: string
|
||||
return { pid, executable }
|
||||
}
|
||||
|
||||
/**
|
||||
* One native Windows process-table row. Rows without a kernel creation time
|
||||
* cannot bracket a session creation, so they are skipped rather than guessed.
|
||||
*/
|
||||
/** Skip rows without kernel creation times because session bracketing requires them. */
|
||||
export function nativeWindowsRowToIdentity(
|
||||
row: NativeWindowsProcessRow
|
||||
): ProcessIdentityRow | null {
|
||||
@@ -159,17 +119,11 @@ function argvZeroBase(argv: readonly string[]): string {
|
||||
return executableBaseName(argv[0] ?? '')
|
||||
}
|
||||
|
||||
/** True for an OpenCode TUI/CLI client process (not the `serve` daemon). */
|
||||
export function isOpenCodeClientArgv(argv: readonly string[]): boolean {
|
||||
return isOpenCodeClientProcess({ executable: '', argv })
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an OpenCode TUI/CLI client process (not the `serve` daemon).
|
||||
* The kernel-reported executable wins when present: `ps` `args=` cannot
|
||||
* preserve argv boundaries, so a truncated argv[0] must not veto a matching
|
||||
* executable. With no executable recorded this degrades to argv[0] matching.
|
||||
*/
|
||||
/** Prefer kernel executable names: reconstructed argv[0] may truncate paths containing spaces. */
|
||||
export function isOpenCodeClientProcess(row: {
|
||||
executable: string
|
||||
argv: readonly string[]
|
||||
@@ -181,12 +135,11 @@ export function isOpenCodeClientProcess(row: {
|
||||
if (classified !== 'opencode') {
|
||||
return false
|
||||
}
|
||||
// Why exclude: the shared server's posts are the ones being reattributed;
|
||||
// mistaking the daemon for a pane client would bind sessions to its pane.
|
||||
// Binding the shared daemon as a pane client would misattribute the posts being corrected.
|
||||
return !row.argv.some((part) => part === 'serve' || part === '--service')
|
||||
}
|
||||
|
||||
/** Every process identity row on this host; fail-open [] like the memory sweeps. */
|
||||
/** Fail open to an empty process list. */
|
||||
export async function sweepProcessIdentities(
|
||||
deps: {
|
||||
platform?: NodeJS.Platform
|
||||
@@ -200,10 +153,7 @@ export async function sweepProcessIdentities(
|
||||
const nowMs = deps.nowMs ?? Date.now()
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
// Why the native table and nothing else: it is the only sanctioned
|
||||
// Windows process-table reader (see windows-process-enumeration.md);
|
||||
// forking powershell.exe for a whole-table CIM scan is exactly the
|
||||
// pattern it retired.
|
||||
// Use the native Windows process reader; whole-table PowerShell/CIM scans are retired.
|
||||
const readTable = deps.readWindowsTable ?? readWindowsProcessTable
|
||||
const rows = await readTable()
|
||||
return rows
|
||||
@@ -215,10 +165,7 @@ export async function sweepProcessIdentities(
|
||||
.split('\n')
|
||||
.map((line) => parsePsArgsLine(line, nowMs))
|
||||
.filter((row): row is ProcessIdentityRow => row !== null)
|
||||
// Why a second sweep: `args=` is one reconstructed string, so the
|
||||
// executable name for classification comes from `comm=` instead. A
|
||||
// failed comm sweep degrades to argv[0] matching rather than dropping
|
||||
// the whole round.
|
||||
// Read comm separately to preserve executable spaces; failures fall back to argv[0].
|
||||
try {
|
||||
const commOut = await execFileText(run, 'ps', ['-eo', 'pid=,comm='])
|
||||
const executables = new Map<number, string>()
|
||||
@@ -244,7 +191,6 @@ export async function sweepProcessIdentities(
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one child process to text, throwing on timeout or nonzero exit. */
|
||||
async function execFileText(
|
||||
run: typeof runProcess,
|
||||
program: string,
|
||||
|
||||
@@ -60,7 +60,6 @@ function close() {
|
||||
child.emit('exit', 0, null)
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
/** How a supervisor ends: its stop signal re-raised once the group is gone, or an exit code. */
|
||||
function supervisorExits(code: number | null, signal: NodeJS.Signals | null) {
|
||||
child.exitCode = code
|
||||
child.signalCode = signal
|
||||
@@ -165,10 +164,10 @@ describe('OpenCode model probe termination evidence', () => {
|
||||
'--port',
|
||||
'0'
|
||||
])
|
||||
// Its stdin ends at once, so a session lifetime would stop the server a second later.
|
||||
// Session lifetime would stop this probe after its stdin closes.
|
||||
expect(decodedSupervisorSpec(spec.env)).toMatchObject({ lifetime: 'one-shot', cwd: directory })
|
||||
expect(child.kill.mock.calls).toEqual([['SIGTERM']])
|
||||
// Signalling the supervisor's own group, or SIGKILLing it, would orphan the server's group.
|
||||
// Killing the supervisor’s group can orphan the server’s detached group.
|
||||
expect(signalProcessTree).not.toHaveBeenCalled()
|
||||
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
|
||||
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
|
||||
@@ -177,7 +176,7 @@ describe('OpenCode model probe termination evidence', () => {
|
||||
it('forces a POSIX supervisor tree only after its full stop time, and trusts no forced stop', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
child.kill.mockReturnValue(true)
|
||||
// A teardown that found no descendants to check proves nothing about the server's group.
|
||||
// Supervisor teardown alone cannot prove the detached server group is gone.
|
||||
vi.mocked(terminateProviderProcessTree).mockImplementation(async () => {
|
||||
supervisorExits(null, 'SIGKILL')
|
||||
return null
|
||||
@@ -198,7 +197,6 @@ describe('OpenCode model probe termination evidence', () => {
|
||||
it('trusts no forced POSIX stop, even one whose teardown reports the tree gone', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
child.kill.mockReturnValue(true)
|
||||
// 'exited' covers the supervisor's tree only; the server leads its own detached group.
|
||||
vi.mocked(terminateProviderProcessTree).mockImplementation(async () => {
|
||||
supervisorExits(null, 'SIGKILL')
|
||||
return 'exited'
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
} from '../codex/supervised-probe-owner.test-fixture'
|
||||
import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context'
|
||||
|
||||
// Like `opencode serve`, it never exits on stdin end. It starts listening only after the session
|
||||
// stdin-end grace, so a supervisor that treated the probe's stdin end as a stop would kill it first.
|
||||
// Delay beyond stdin grace like a slow opencode serve startup; stdin EOF must not stop it.
|
||||
const SERVE = String.raw`
|
||||
setTimeout(() => {
|
||||
const server = require('node:http').createServer((request, response) => {
|
||||
@@ -69,11 +68,9 @@ describe.runIf(process.platform !== 'win32')('supervised OpenCode launch-context
|
||||
})
|
||||
expect(existsSync(rig.signalFile) && readFileSync(rig.signalFile, 'utf8')).toBe('SIGTERM')
|
||||
expect(alive(pids.provider)).toBe(false)
|
||||
// A descendant that ignores SIGTERM holds none of the probe's pipes, so a closed pipe never
|
||||
// proved it gone; only the supervisor's group SIGKILL ends it.
|
||||
// Closed pipes cannot prove this SIGTERM-ignoring descendant died; group SIGKILL must reap it.
|
||||
expect(alive(pids.grandchild)).toBe(false)
|
||||
// Once the server exits on its SIGTERM, the rest of its group is killed at once, not after the
|
||||
// SIGTERM grace; every launch waits on this preflight.
|
||||
// Reap descendants immediately after server exit so every launch avoids the remaining grace.
|
||||
expect(stoppedAt - (rig.readEvents().SIGTERM ?? 0)).toBeLessThan(1_000)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { stopSupervisedChildProcess } from '../provider-process/supervised-child
|
||||
import { guardSupervisedProviderGroup } from '../provider-process/provider-group-orphan-guard'
|
||||
|
||||
const OPENCODE_PREFLIGHT_KILL_SITE = 'opencode-launch-model-preflight'
|
||||
// A supervisor's pipes are its own, so they close as it exits.
|
||||
const SUPERVISOR_PIPE_CLOSE_MS = 1_000
|
||||
|
||||
const model = z.object({ id: z.string(), providerID: z.string() })
|
||||
@@ -117,7 +116,7 @@ export async function probeOpenCodeLaunchModelContext(options: {
|
||||
OPENCODE_SERVER_PASSWORD: password
|
||||
},
|
||||
process.platform,
|
||||
// `opencode serve` never exits on stdin end, so on POSIX only its supervisor stops it with Orca.
|
||||
// opencode serve ignores stdin EOF; POSIX supervision must stop it with Orca.
|
||||
{ lifetime: 'one-shot' }
|
||||
)
|
||||
const child = spawnProcess({
|
||||
@@ -236,17 +235,13 @@ export async function probeOpenCodeLaunchModelContext(options: {
|
||||
return stopped ? context : null
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the server's group is proven gone: the supervisor exited on its own stop or
|
||||
* relayed the server's exit. A forced stop proves nothing: its teardown reaches the supervisor's
|
||||
* group, not the server's own detached group. Exit 1 can be a group reap that failed.
|
||||
*/
|
||||
/** Only the supervisor’s normal stop/relayed exit proves its detached server group is gone.
|
||||
* Forced supervisor teardown and failed group reap (exit 1) prove nothing. */
|
||||
async function stopSupervisedPreflight(
|
||||
child: ChildProcessHandle,
|
||||
closed: Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
if (!child.pid) {
|
||||
// Never started, so there is nothing to stop.
|
||||
return true
|
||||
}
|
||||
const { root, tree } = await stopSupervisedChildProcess(child, {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fakeTui } from './opencode-tui-session-fixture'
|
||||
|
||||
export type LegacyTuiEvent = { type: string; properties: Record<string, unknown> }
|
||||
|
||||
/** The 1.18.30 public TUI API backed by the existing session-data fixture. */
|
||||
export function fakeLegacyTui() {
|
||||
const tui = fakeTui('1.18.30')
|
||||
const data = tui.ctx.data.session
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseOpenCodeModelListing } from './opencode-model-catalog-listing'
|
||||
|
||||
// What `opencode models --verbose` prints: each id, then `JSON.stringify(model, null, 2)`.
|
||||
// Match opencode models --verbose: ID followed by pretty-printed model JSON.
|
||||
function listing(models: Record<string, unknown>): string {
|
||||
return Object.entries(models)
|
||||
.map(([id, model]) => `${id}\n${JSON.stringify(model, null, 2)}\n`)
|
||||
@@ -36,7 +36,7 @@ describe('OpenCode verbose model listing', () => {
|
||||
{ value: 'max', label: 'Max' },
|
||||
{ value: 'default', label: 'Default' }
|
||||
],
|
||||
// No `default` variant: a new session runs the first one, as OpenCode's own ACP does.
|
||||
// With no default variant, OpenCode ACP selects the first variant.
|
||||
defaultEffort: 'high'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// OpenCode 1.x's session-free model listing: `opencode models --verbose` prints each
|
||||
// `provider/model` id on its own line, followed by that model's metadata as indented JSON closed by
|
||||
// a bare `}` line.
|
||||
// OpenCode 1.x models --verbose emits each provider/model ID followed by indented JSON.
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { AgentSessionModelOption } from '../../shared/agent-session-wire'
|
||||
@@ -8,7 +6,7 @@ import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-stru
|
||||
|
||||
export const OPENCODE_MODEL_LISTING_ARGS = ['models', '--verbose'] as const
|
||||
|
||||
// OpenCode's effort sentinel for "no variant override", always offered beside a model's variants.
|
||||
// The default sentinel means no variant override.
|
||||
const DEFAULT_VARIANT = 'default'
|
||||
const MODEL_JSON_LIMITS = { structuralTokens: 64 * 1024, nestingDepth: 32 } as const
|
||||
|
||||
@@ -17,7 +15,6 @@ const modelMetaSchema = z.looseObject({
|
||||
variants: z.record(z.string(), z.unknown()).optional()
|
||||
})
|
||||
|
||||
/** Effort labels as OpenCode's own effort option spells them: `high-thinking` → `High Thinking`. */
|
||||
function variantLabel(variant: string): string {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
@@ -33,7 +30,7 @@ function modelOption(id: string, metadata: unknown): AgentSessionModelOption {
|
||||
return {
|
||||
id,
|
||||
label: name ? `${providerId}/${name}` : id,
|
||||
// The listing names the account's models, not which one its config picks.
|
||||
// Listing proves availability, not the configured default.
|
||||
isDefault: false,
|
||||
...(variants.length > 0
|
||||
? {
|
||||
@@ -41,7 +38,7 @@ function modelOption(id: string, metadata: unknown): AgentSessionModelOption {
|
||||
value,
|
||||
label: variantLabel(value)
|
||||
})),
|
||||
// What a new OpenCode session runs: its `default` variant, else the first it lists.
|
||||
// New OpenCode sessions use default when offered, otherwise the first variant.
|
||||
defaultEffort: variants.includes(DEFAULT_VARIANT) ? DEFAULT_VARIANT : variants[0]
|
||||
}
|
||||
: { efforts: [] })
|
||||
@@ -53,11 +50,11 @@ function isModelIdLine(line: string): boolean {
|
||||
return slash > 0 && slash < line.length - 1 && !/\s/.test(line) && !line.startsWith('{')
|
||||
}
|
||||
|
||||
/** Every listed model; one whose metadata does not parse keeps its id with no effort menu. */
|
||||
/** Retain model IDs even when malformed metadata prevents an effort menu. */
|
||||
export function parseOpenCodeModelListing(stdout: string): AgentSessionModelOption[] {
|
||||
const lines = stdout.split(/\r?\n/)
|
||||
const models = new Map<string, AgentSessionModelOption>()
|
||||
// A pretty-printed object closes on an unindented `}`; an empty one is `{}` on one line.
|
||||
// Pretty-printed objects end at a bare closing brace; empty objects fit on one line.
|
||||
const closesObject = (line: string, first: boolean): boolean =>
|
||||
line.trimEnd() === '}' || (first && line.trimEnd() === '{}')
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
|
||||
@@ -170,7 +170,6 @@ export async function buildExecutionHostAgentStartupPlan(
|
||||
return plan
|
||||
}
|
||||
|
||||
/** `buildExecutionHostAgentStartupPlan` for a caller that delivers an uncarried prompt itself. */
|
||||
export async function planExecutionHostStartupWithPromptCandidate(
|
||||
options: StartupScope & {
|
||||
prompt: string
|
||||
|
||||
@@ -155,9 +155,7 @@ describe('applyBinderOwnerships', () => {
|
||||
|
||||
it('takes the newest row worktree when a pane remints', () => {
|
||||
const state = createHookListenerState()
|
||||
// Registry insertion order puts the stale row first; the live remint row
|
||||
// carries a different worktree and must win, matching the round's
|
||||
// newest-wins pane dedupe.
|
||||
// Insert stale first so a reminted pane’s newer worktree must win.
|
||||
const applied = applyBinderOwnerships(
|
||||
state,
|
||||
[
|
||||
|
||||
@@ -19,27 +19,16 @@ import { listRegisteredPtys } from '../memory/pty-registry'
|
||||
import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep'
|
||||
import type { HookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
|
||||
/**
|
||||
* Main-process binder feeding the session→pane registry (#21359), for OpenCode 1
|
||||
* `serve` + `attach` only (see isOpenCodeSharedServerPost).
|
||||
*
|
||||
* Each round: read new sessions from the shared server's SQLite store,
|
||||
* snapshot panes, sweep for live clients, correlate, bind. Everything the
|
||||
* round needs is injected so the decision core stays unit-testable; the
|
||||
* store read runs on the foreign SQLite reader worker
|
||||
* (foreign-sqlite-readers/readers/opencode-binder-sessions.ts).
|
||||
*/
|
||||
// Bind shared OpenCode 1 serve/attach sessions to panes; database reads run in a foreign-SQLite worker.
|
||||
|
||||
/** One pane snapshot feeding a binder round. */
|
||||
export type BinderPaneSnapshot = {
|
||||
paneKey: string
|
||||
/** Worktree root backing the pane (null when unknown); sessions beneath it are candidates. */
|
||||
/** Sessions beneath this root are candidates; null means unknown. */
|
||||
directory: string | null
|
||||
worktreeId: string | null
|
||||
shellPid: number | null
|
||||
}
|
||||
|
||||
/** Everything one binder round needs, injected for tests. */
|
||||
export type BinderRoundDeps = {
|
||||
nowMs: number
|
||||
sessions: readonly BinderSessionRow[]
|
||||
@@ -49,25 +38,17 @@ export type BinderRoundDeps = {
|
||||
parentBySessionId: ReadonlyMap<string, string | null>
|
||||
}
|
||||
|
||||
/** Ownership decisions from one binder round. */
|
||||
export type BinderRoundResult = {
|
||||
ownerships: SessionOwnership[]
|
||||
}
|
||||
|
||||
/** Cursor before anything was ever read. */
|
||||
export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' }
|
||||
|
||||
/** Order cursors the way the store lists rows: oldest first, id as tiebreak. */
|
||||
function compareSessionRows(left: OpenCodeSessionCursor, right: OpenCodeSessionCursor): number {
|
||||
return left.ms - right.ms || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the store cursor past handled rows only. `fresh` arrives in store
|
||||
* order; handling is prefix-closed (the unbound-cap break only ever skips the
|
||||
* tail), so the first unhandled row freezes the cursor and every row at or
|
||||
* past it is re-listed next round instead of silently dropped.
|
||||
*/
|
||||
/** Freeze at the first unhandled row so capped tails are listed again next round. */
|
||||
export function advanceBinderCursor(args: {
|
||||
fresh: readonly BinderSessionRow[]
|
||||
isHandled: (sessionId: string) => boolean
|
||||
@@ -86,7 +67,6 @@ export function advanceBinderCursor(args: {
|
||||
return cursor
|
||||
}
|
||||
|
||||
/** pid→ppid index for one sweep; first row wins on duplicate pids. */
|
||||
function childrenIndex(processes: readonly ProcessIdentityRow[]): Map<number, number> {
|
||||
const ppidByPid = new Map<number, number>()
|
||||
for (const row of processes) {
|
||||
@@ -97,7 +77,7 @@ function childrenIndex(processes: readonly ProcessIdentityRow[]): Map<number, nu
|
||||
return ppidByPid
|
||||
}
|
||||
|
||||
/** Nearest pane shell at or above this pid; external terminals stay unattributed. */
|
||||
/** Leave external terminals unattributed. */
|
||||
function owningPane(
|
||||
ppidByPid: Map<number, number>,
|
||||
shellPidByPid: Map<number, string>,
|
||||
@@ -116,7 +96,6 @@ function owningPane(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Attribute opencode client rows to panes via shell-subtree walks. */
|
||||
function toCorrelatedClients(
|
||||
processes: readonly ProcessIdentityRow[],
|
||||
panes: readonly BinderPaneSnapshot[],
|
||||
@@ -138,20 +117,15 @@ function toCorrelatedClients(
|
||||
if (!paneKey) {
|
||||
continue
|
||||
}
|
||||
// Why lastSeenAlive = now: the sweep just observed it. Bracketing uses
|
||||
// startedAt for the lower bound and this observation for the upper.
|
||||
// Bracket session creation between process start and this live observation.
|
||||
clients.push({ paneKey, startedAtMs: row.startedAtMs, lastSeenAliveMs: nowMs, argv: row.argv })
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
/** Pure round core: correlate unbound sessions against panes and clients. */
|
||||
export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult {
|
||||
// Why dedupe by key, newest wins: remints and reattachments can leave a
|
||||
// stale registry row beside the live one; counting rows instead of panes
|
||||
// would turn every same-pane tie into a false ambiguous and nothing would
|
||||
// ever bind, while the oldest row would point the candidate set at a dead
|
||||
// worktree.
|
||||
// Remints can leave stale rows: deduplicate by pane with newest ownership to avoid
|
||||
// false ambiguity or attribution to an old worktree.
|
||||
const paneByKey = new Map<string, CorrelatedPane>()
|
||||
for (const pane of deps.panes) {
|
||||
paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory })
|
||||
@@ -164,11 +138,8 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult
|
||||
createdAtMs: row.createdAtMs,
|
||||
parentId: row.parentId
|
||||
}))
|
||||
// Why resolve inheritance here: the SQLite round only carries new rows, so
|
||||
// an old root is invisible to the correlator; the binder's parent map walks
|
||||
// the chain and the registry supplies the known root owner. Resolved heirs
|
||||
// are emitted as binds (the registry lacks them) and fed back as known so
|
||||
// the correlator skips what is already decided.
|
||||
// New-row-only reads omit older roots; resolve known ancestor ownership before correlation
|
||||
// and emit inherited binds so the registry also learns them.
|
||||
const knownOwners = new Map(deps.knownOwners)
|
||||
const inherited: SessionOwnership[] = []
|
||||
for (const session of sessions) {
|
||||
@@ -200,17 +171,11 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult
|
||||
return { ownerships }
|
||||
}
|
||||
|
||||
/** Default database path for the local shared server. */
|
||||
export function defaultOpenCodeDbPath(): string {
|
||||
return `${resolveOpenCodeDataDirectory()}/opencode.db`
|
||||
}
|
||||
|
||||
/**
|
||||
* Live local panes from the PTY registry: pane key, worktree root and shell
|
||||
* pid. Panes without a key or pid (hydrated gaps, remote panes) cannot own a
|
||||
* client subtree, so they are skipped — their sessions stay unbound rather
|
||||
* than guessed.
|
||||
*/
|
||||
/** Skip panes without keys or shell PIDs; guessing would misattribute remote/hydration gaps. */
|
||||
export function listBinderPaneSnapshots(): BinderPaneSnapshot[] {
|
||||
const snapshots: BinderPaneSnapshot[] = []
|
||||
for (const pty of listRegisteredPtys()) {
|
||||
@@ -228,7 +193,6 @@ export function listBinderPaneSnapshots(): BinderPaneSnapshot[] {
|
||||
return snapshots
|
||||
}
|
||||
|
||||
/** Apply one round's decisions to the listener registry. */
|
||||
export function applyBinderOwnerships(
|
||||
state: HookListenerState,
|
||||
panes: readonly BinderPaneSnapshot[],
|
||||
@@ -237,8 +201,7 @@ export function applyBinderOwnerships(
|
||||
): number {
|
||||
const worktreeByPane = new Map<string, string | null>()
|
||||
for (const pane of panes) {
|
||||
// Why overwrite: matching the round's newest-wins pane dedupe, so a
|
||||
// remint's live row wins over a stale row with a different worktree.
|
||||
// Match the round’s newest-wins deduplication for reminted panes.
|
||||
worktreeByPane.set(pane.paneKey, pane.worktreeId)
|
||||
}
|
||||
let applied = 0
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
type StructuredAgentSessionHistory
|
||||
} from '../native-chat/structured-agent-cli-conversations'
|
||||
|
||||
/** OpenCode 2.x lists a conversation as an `opencode2` row, 1.x as `opencode`, both under the
|
||||
* `ses_` id the chat's ACP session carries. */
|
||||
export const OPENCODE_SESSION_HISTORY: StructuredAgentSessionHistory = {
|
||||
rowAgents: ['opencode', 'opencode2'],
|
||||
rowSessionId: (link) => link.handle.nativeId,
|
||||
|
||||
@@ -10,7 +10,6 @@ type PromptClaim = {
|
||||
expiry: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Authorizes one startup operation against current execution-owner facts. */
|
||||
export class OpenCodeStartupPromptClaims {
|
||||
private readonly pending = new Map<string, PromptClaim>()
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hoo
|
||||
import type { ManagedHookDetectionSettings } from '../agent-hooks/managed-hook-detection-commands'
|
||||
import { openCode2HookService, openCodeHookService } from './hook-service'
|
||||
|
||||
// Why: an Orca upgrade must reach an OpenCode 2 service that is already running, which reloads a
|
||||
// changed plugin file, instead of waiting for the next pane spawn to rewrite it.
|
||||
// Refresh running OpenCode 2 services on upgrade without waiting for a pane to rewrite the plugin.
|
||||
export function refreshInstalledOpenCodeStatusPlugins(
|
||||
settings: ManagedHookDetectionSettings
|
||||
): void {
|
||||
|
||||
@@ -38,7 +38,6 @@ function definedEnvironment(
|
||||
return defined
|
||||
}
|
||||
|
||||
/** Resolve the current selection on the execution host when creating a chat. */
|
||||
export function resolveStructuredOpenCodeAccountHome(input: {
|
||||
managedAccounts: AccountReader
|
||||
}): OpenCodeAgentSessionAccountHome {
|
||||
@@ -50,7 +49,7 @@ export function resolveStructuredOpenCodeAccountHome(input: {
|
||||
return { kind: 'opencode', locator: { kind: 'unmanaged' } }
|
||||
}
|
||||
|
||||
/** Resolve a saved binding against the current host, then apply it after inherited overlays. */
|
||||
/** Resolve on the current host and apply after inherited overlays. */
|
||||
export function environmentForStructuredOpenCodeAccountHome(
|
||||
binding: OpenCodeAgentSessionAccountHome,
|
||||
input: {
|
||||
@@ -60,7 +59,6 @@ export function environmentForStructuredOpenCodeAccountHome(
|
||||
): Record<string, string> {
|
||||
const environment = restoredEnvironment(input.baseEnvironment, input.managedAccounts)
|
||||
const locator = binding.locator
|
||||
// Unmanaged: the user's own environment reaches OpenCode as-is.
|
||||
if (locator.kind === 'unmanaged') {
|
||||
return definedEnvironment(environment)
|
||||
}
|
||||
@@ -74,7 +72,6 @@ export function environmentForStructuredOpenCodeAccountHome(
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenCode's account over ACP: a managed profile, or the user's own environment. */
|
||||
export function openCodeAcpAccountBinding(
|
||||
managedAccounts: () => AccountReader = getManagedDataAccountService
|
||||
): AcpAccountBinding {
|
||||
|
||||
@@ -31,7 +31,7 @@ function accountDatabase(
|
||||
}
|
||||
|
||||
export const OPENCODE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = {
|
||||
// The account's store is its exact database, which is the only one its chat can load from.
|
||||
// Restrict imports to the exact database the selected account’s chat can load.
|
||||
conversationStore: ({ accountHome, launchEnv }) => accountDatabase(accountHome, launchEnv),
|
||||
admitTranscriptPath: async ({ store: dbPath, transcriptPath }) => {
|
||||
const [expected, actual] = await Promise.all([
|
||||
@@ -40,7 +40,6 @@ export const OPENCODE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = {
|
||||
])
|
||||
return expected && actual && expected.path === actual.path ? actual.path : null
|
||||
},
|
||||
// The selected account, then the unmanaged environment this chat would launch with.
|
||||
accountHomeCandidates: ({ selectedAccountHome }) => [
|
||||
selectedAccountHome,
|
||||
{ kind: 'opencode', locator: { kind: 'unmanaged' } }
|
||||
|
||||
@@ -11,7 +11,6 @@ function toBlocker(value: unknown): Blocker {
|
||||
return { ...record, id, sessionID }
|
||||
}
|
||||
|
||||
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
|
||||
export function fakeTui(version = '2.0.14') {
|
||||
const listeners = new Set<(event: { details: BusEvent }) => void>()
|
||||
const sessions = new Map<
|
||||
@@ -22,10 +21,9 @@ export function fakeTui(version = '2.0.14') {
|
||||
const running = new Set<string>()
|
||||
const permissions = new Map<string, Blocker[]>()
|
||||
const forms = new Map<string, Blocker[]>()
|
||||
// What the server answers when the TUI re-fetches a permission list (reconnect).
|
||||
const serverPermissions = new Map<string, Blocker[]>()
|
||||
let permissionFetch: Promise<void> = Promise.resolve()
|
||||
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
|
||||
// OpenCode keeps storage.memory across hot reloads within the same TUI.
|
||||
const memories = new Map<string, unknown>()
|
||||
let route: { type: string; sessionID?: string } = { type: 'home' }
|
||||
const rootOf = (id: string): string => {
|
||||
@@ -87,11 +85,9 @@ export function fakeTui(version = '2.0.14') {
|
||||
navigate(sessionID: string) {
|
||||
route = { type: 'session', sessionID }
|
||||
},
|
||||
// The session data stops reporting a run without this TUI seeing its end event.
|
||||
loseEnd(sessionID: string) {
|
||||
running.delete(sessionID)
|
||||
},
|
||||
// The session data reports a run whose start this TUI never saw.
|
||||
loseStart(sessionID: string) {
|
||||
running.add(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
@@ -99,7 +95,6 @@ export function fakeTui(version = '2.0.14') {
|
||||
session.outcome = undefined
|
||||
}
|
||||
},
|
||||
// Holds the next permission list fetch until the returned release is called.
|
||||
holdPermissionFetch(): () => void {
|
||||
let release = (): void => {}
|
||||
permissionFetch = new Promise((resolve) => {
|
||||
@@ -107,7 +102,7 @@ export function fakeTui(version = '2.0.14') {
|
||||
})
|
||||
return release
|
||||
},
|
||||
// Ending events reach listeners before the session snapshot catches up.
|
||||
// Deliver events before updating the snapshot, matching OpenCode’s end-event ordering.
|
||||
emit(event: BusEvent) {
|
||||
const sessionID = String(event.data.sessionID)
|
||||
let notified = false
|
||||
|
||||
@@ -2,8 +2,7 @@ export function getStatusPluginDeliverySource(): string[] {
|
||||
return [
|
||||
'',
|
||||
'function enqueueLifecycle(task) {',
|
||||
' // Why: OpenCode intentionally fire-and-forgets hook promises, so async',
|
||||
' // lookups and posts need their own FIFO to preserve event order.',
|
||||
' // OpenCode does not await hook promises; serialize lookups/posts to preserve event order.',
|
||||
' const run = lifecycleQueue.then(async () => {',
|
||||
' try {',
|
||||
' await task();',
|
||||
@@ -159,7 +158,7 @@ export function getStatusPluginDeliverySource(): string[] {
|
||||
|
||||
export function getTuiStatusDeliverySource(): string[] {
|
||||
return String.raw`
|
||||
// Why queued: levels derived before disposal still post, in order, before the identity retires.
|
||||
// Drain previously derived levels before retiring their factory identity.
|
||||
function releaseTuiStatusDelivery(factoryID, forgetUndelivered) {
|
||||
return enqueueLifecycle(async () => {
|
||||
disposingFactoryIDs.add(factoryID);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
export function getStatusPluginEndpointSource(): string[] {
|
||||
return [
|
||||
'// Why: process-lifetime guard so a recurring parse error on a malformed',
|
||||
"// endpoint file does not spam OpenCode's stderr once per hook post.",
|
||||
'// This guard lives inside the plugin source because the plugin runs in',
|
||||
"// OpenCode's Node process (not Orca's) and has no access to server.ts's",
|
||||
'// equivalent warnedVersions / warnedEnvs Sets.',
|
||||
"// Warn once per plugin process; Orca's own warning guards live in a different process.",
|
||||
'let warnedBadEndpoint = false;',
|
||||
'',
|
||||
'// Why: message.part.updated can fire many times per second during a',
|
||||
'// streaming assistant reply, and each post() calls resolveHookCoords()',
|
||||
'// which reads the endpoint file. The file only changes on Orca restart',
|
||||
'// (rare), so a stat+mtime check is substantially cheaper than a full',
|
||||
'// readFileSync+parse on every streamed part. On stat error we fall',
|
||||
'// through to parse so the fail-open behavior is preserved.',
|
||||
'// Stat before parsing each post: streaming parts are frequent, endpoint rewrites are rare.',
|
||||
'let cachedEndpointKey = "";',
|
||||
'let cachedEndpointValues = null;',
|
||||
'',
|
||||
@@ -23,11 +14,7 @@ export function getStatusPluginEndpointSource(): string[] {
|
||||
' const fs = require("fs");',
|
||||
' try {',
|
||||
' const stat = fs.statSync(path);',
|
||||
' // Why: cache key combines mtime + size + inode. renameSync (used by',
|
||||
' // writeEndpointFile on the Orca side) allocates a fresh inode on',
|
||||
' // POSIX and a new Windows file ID on NTFS, so ino changes on every',
|
||||
' // legitimate rewrite even when mtimeMs resolution is coarse and size',
|
||||
' // happens to match.',
|
||||
' // Include inode so atomic rewrites invalidate even with coarse mtime and unchanged size.',
|
||||
' const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;',
|
||||
' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {',
|
||||
' return cachedEndpointValues;',
|
||||
@@ -35,12 +22,7 @@ export function getStatusPluginEndpointSource(): string[] {
|
||||
' const contents = fs.readFileSync(path, "utf8");',
|
||||
' const out = {};',
|
||||
' for (const line of contents.split(/\\r?\\n/)) {',
|
||||
' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env',
|
||||
' // uses `KEY=VALUE`. Making `set ` optional lets the same parser',
|
||||
' // handle both without platform detection in the plugin. Allow',
|
||||
' // digits in the key for forward-compat with future ORCA_AGENT_HOOK_*',
|
||||
' // names that may contain numerics, and strip a trailing CR so',
|
||||
' // mixed-EOL files with lone `\\r` do not leak CR into the value.',
|
||||
' // Accept Windows set prefixes and CR endings alongside Unix endpoint files.',
|
||||
' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/);',
|
||||
' if (m) out[m[1]] = m[2].replace(/\\r$/, "");',
|
||||
' }',
|
||||
@@ -48,20 +30,13 @@ export function getStatusPluginEndpointSource(): string[] {
|
||||
' cachedEndpointValues = out;',
|
||||
' return out;',
|
||||
' } catch (ioErr) {',
|
||||
' // Why: any stat or read failure (file yanked mid-read, permission',
|
||||
' // race, unlink between stat and readFileSync) must invalidate the',
|
||||
' // cache so a transient failure does not lock in a stale parse for',
|
||||
' // the remaining process lifetime; rethrow to the outer catch.',
|
||||
' // Invalidate on read/stat failure so transient errors cannot pin stale coordinates.',
|
||||
' cachedEndpointKey = "";',
|
||||
' cachedEndpointValues = null;',
|
||||
' throw ioErr;',
|
||||
' }',
|
||||
' } catch (err) {',
|
||||
' // Why: warn once per process if the file exists but is unreadable or',
|
||||
' // malformed — a persistent, silently-swallowed parse error would',
|
||||
' // otherwise leave the plugin falling back to stale process.env on',
|
||||
' // every post with no signal. ENOENT / missing env var is the normal',
|
||||
' // pre-install case; stay silent for it.',
|
||||
' // Warn on malformed/unreadable files; absence is normal before installation.',
|
||||
' if (err && err.code !== "ENOENT" && !warnedBadEndpoint) {',
|
||||
' warnedBadEndpoint = true;',
|
||||
' console.warn("[orca-hook] failed to parse endpoint file:", err.message);',
|
||||
@@ -71,12 +46,7 @@ export function getStatusPluginEndpointSource(): string[] {
|
||||
'}',
|
||||
'',
|
||||
'function resolveHookCoords() {',
|
||||
' // Why: prefer the on-disk endpoint file over process.env because env was',
|
||||
' // frozen when OpenCode was fork()ed — stale after an Orca restart. The',
|
||||
' // file is rewritten on every Orca start(), so sourcing it per post lets',
|
||||
' // a long-running OpenCode session reach the current server. Falls back',
|
||||
' // to process.env when the file is absent (first-run / pre-endpoint-file / Orca',
|
||||
' // never started writing the file).',
|
||||
' // Prefer endpoint files: inherited environment coordinates become stale after Orca restarts.',
|
||||
' const fileEnv = readEndpointFile() || {};',
|
||||
' return {',
|
||||
' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,',
|
||||
|
||||
@@ -9,17 +9,11 @@ export function getStatusPluginFactorySource(options: {
|
||||
expectedAgent?: 'opencode' | 'opencode2'
|
||||
}): string[] {
|
||||
const expectedAgent = options.expectedAgent ?? (options.emitNextEvents ? 'opencode2' : 'opencode')
|
||||
// Why: opencode and opencode2 share one config dir, so both plugin files load in
|
||||
// either binary. Distinct ids keep the loader from reporting a duplicate-id
|
||||
// collision as an 'orca-opencode-status' plugin failure.
|
||||
// Both variants share a config directory; duplicate plugin IDs would disable one.
|
||||
const pluginID = expectedAgent === 'opencode2' ? 'orca-opencode2-status' : 'orca-opencode-status'
|
||||
return [
|
||||
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
|
||||
'// Why: accept the factory argument as an optional opaque parameter instead',
|
||||
'// of destructuring (`async ({ client }) => …`). OpenCode can invoke the',
|
||||
'// plugin factory with undefined during startup, which makes the',
|
||||
'// destructuring form throw synchronously and crash OpenCode with an opaque',
|
||||
'// UnknownError before any event is ever dispatched.',
|
||||
'// OpenCode may call the factory without context; destructuring would crash startup.',
|
||||
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
|
||||
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
|
||||
' const client = _ctx?.client;',
|
||||
@@ -34,13 +28,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
' const authorityRevision = stateArrivalRevision;',
|
||||
' const statusType = getStatusType(event);',
|
||||
'',
|
||||
' // Why: cache the message role BEFORE the async isChildSession check.',
|
||||
' // OpenCode fires message.updated (user) and message.part.updated (text)',
|
||||
' // back-to-back; if we awaited isChildSession first, the part.updated',
|
||||
' // handler could reach messageRoleById.get(...) while the user message.updated',
|
||||
' // is still suspended on that await — so the part would see an empty cache',
|
||||
' // and drop the user prompt. Caching is a cheap Map.set with bounded size,',
|
||||
' // safe to run even for child sessions (the part POST still filters them).',
|
||||
' // Cache roles before awaiting lineage: back-to-back part events otherwise see no role.',
|
||||
' if (event.type === "message.updated") {',
|
||||
' const info = event.properties && event.properties.info;',
|
||||
' rememberMessageRole(info && info.id, info && info.role);',
|
||||
@@ -51,9 +39,6 @@ export function getStatusPluginFactorySource(options: {
|
||||
...(options.emitNextEvents
|
||||
? [
|
||||
'',
|
||||
' // OpenCode 2 publishes the next-generation event family through the',
|
||||
' // same plugin event hook. Convert those events into the existing',
|
||||
' // bounded Orca lifecycle and preview posts.',
|
||||
' if (event.type === "session.next.prompt.admitted") {',
|
||||
' if (!sessionID) return;',
|
||||
' if ((await isChildSession(client, sessionID)) !== false) return;',
|
||||
@@ -120,8 +105,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
' ) {',
|
||||
' await enqueueLifecycle(async () => {',
|
||||
' if (disposed) return;',
|
||||
' // Why: stored ownership clears child questions without waiting on',
|
||||
' // ancestry lookup even though ordinary child message parts stay hidden.',
|
||||
' // Stored ownership retires child questions even when ancestry lookup is unavailable.',
|
||||
' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);',
|
||||
' if (!rootSessionID) return;',
|
||||
' await publishOwnershipChange(factoryID, rootSessionID);',
|
||||
@@ -169,36 +153,19 @@ export function getStatusPluginFactorySource(options: {
|
||||
' if (desiredStatus === "waiting") return;',
|
||||
'',
|
||||
' if (event.type === "message.updated") {',
|
||||
' // Why: role is already cached above the isChildSession await so the',
|
||||
' // back-to-back message.part.updated for the same messageID is not',
|
||||
' // racing against this handler. Nothing more to do here — return to',
|
||||
' // avoid falling through to the part/session handlers below.',
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
' if (event.type === "message.part.updated") {',
|
||||
' // Why: a TextPart carries the actual user prompt or assistant reply',
|
||||
' // text. Skip non-text parts (tool, reasoning, file, …) so we only',
|
||||
' // forward what the dashboard renders. Role came from the earlier',
|
||||
' // message.updated event; if we never saw one (e.g. plugin loaded',
|
||||
' // mid-turn) the role is unknown, and mislabeling the part — a user',
|
||||
' // prompt displayed as the assistant reply, or vice versa — is worse',
|
||||
' // than silently dropping a single in-flight text chunk. The next',
|
||||
' // message.updated event will re-seed the role cache, so subsequent',
|
||||
' // parts in the same session flow normally.',
|
||||
' // Unknown roles can mislabel user prompts as replies; skip until message.updated seeds the cache.',
|
||||
' const part = event.properties && event.properties.part;',
|
||||
' if (!part || part.type !== "text" || !part.text) return;',
|
||||
' // Why: OpenCode injects a finished background task back into the',
|
||||
' // parent turn as a synthetic `<task id=…>` text part. It is machinery,',
|
||||
' // not what the human typed or the agent replied — OpenCode hides it',
|
||||
' // from its own prompt too — so it must not replace the pane preview.',
|
||||
' // OpenCode hides synthetic background-task text; it must not replace human/assistant previews.',
|
||||
' if (part.synthetic === true) return;',
|
||||
' const role = messageRoleById.get(part.messageID);',
|
||||
' if (!role) return;',
|
||||
' if (role === "user") {',
|
||||
' // Why: user prompts arrive as a single event, not a stream — post',
|
||||
' // immediately (still capped) so the throttle slot stays free for',
|
||||
' // the assistant reply that follows within the same window.',
|
||||
' // User prompts are single events; post immediately without consuming the assistant throttle slot.',
|
||||
' await postMessagePart(',
|
||||
' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },',
|
||||
' factoryID',
|
||||
@@ -223,8 +190,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
...(options.emitNextEvents ? [' nextTextByMessageID.clear();'] : []),
|
||||
' disposingFactoryIDs.add(factoryID);',
|
||||
' await enqueueLifecycle(async () => {',
|
||||
' // An older MessagePart must settle before disposal publishes the',
|
||||
' // replacement state, or its late Working update could win.',
|
||||
' // Drain old previews before disposal so late Working updates cannot overwrite its verdict.',
|
||||
' while (messagePartPostInFlight) await messagePartPostInFlight;',
|
||||
' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {',
|
||||
' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);',
|
||||
@@ -244,8 +210,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
' pendingAssistantPart = null;',
|
||||
' }',
|
||||
' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;',
|
||||
' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final',
|
||||
' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.',
|
||||
' // OpenCode 1 teardown cancels all runs; OpenCode 2 hot reloads preserve runs and publish no Idle.',
|
||||
' if (sessionsOutliveDispose) {',
|
||||
' if (desiredFactoryID === factoryID) {',
|
||||
' clearStatusRetry();',
|
||||
@@ -254,8 +219,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
' } else if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {',
|
||||
' clearStatusRetry();',
|
||||
' statusRevision += 1;',
|
||||
' // A MessagePart may have changed the listener to Working after the',
|
||||
' // same lifecycle key was delivered; force that key to be reasserted.',
|
||||
' // Reassert status after MessagePart may have changed the listener to Working.',
|
||||
' statusDeliveryDirty = ownsDeliveredMessagePart;',
|
||||
' busyRecoveryUsed = false;',
|
||||
' busyRecoveryEndpointKey = "";',
|
||||
@@ -289,7 +253,6 @@ export function getStatusPluginFactorySource(options: {
|
||||
'};',
|
||||
...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []),
|
||||
'',
|
||||
'// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.',
|
||||
'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {',
|
||||
` id: "${pluginID}",`,
|
||||
' server: OrcaOpenCodeStatusPlugin,',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** Adapts the genuine 1.x TUI API to the existing structural pane reporter. */
|
||||
export function getLegacyOpenCodeTuiSource(): string[] {
|
||||
return String.raw`
|
||||
async function setupLegacyOpenCodeTui(api) {
|
||||
|
||||
@@ -8,8 +8,7 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' event.type === "question.replied" ||',
|
||||
' event.type === "question.rejected";',
|
||||
' if (isResolutionEvent) {',
|
||||
' // Why: the stored owner already identifies the root, so replies clear',
|
||||
' // immediately even when OpenCode session lookup is slow or unavailable.',
|
||||
' // Stored owners let replies clear blockers without waiting for SDK lookup.',
|
||||
' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);',
|
||||
' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);',
|
||||
' return;',
|
||||
@@ -17,8 +16,7 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' const isAttentionEvent =',
|
||||
' event.type === "permission.asked" ||',
|
||||
' event.type === "question.asked";',
|
||||
" // Why: attention without OpenCode's required sessionID cannot be",
|
||||
' // correlated to a later reply/Idle, so it must not become UI authority.',
|
||||
' // Without a session ID, attention cannot be retired by a matching reply/Idle.',
|
||||
' if (isAttentionEvent && !sessionID) return;',
|
||||
' const canFailOpen =',
|
||||
' statusType === "busy" || statusType === "retry" || isAttentionEvent;',
|
||||
@@ -33,8 +31,7 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' busyRootOwnerBySessionID.delete(sessionID);',
|
||||
' busyRootOwnerBySessionID.set(sessionID, factoryID);',
|
||||
' }',
|
||||
' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,',
|
||||
' // but preserve blockers that still require the pane owner to respond.',
|
||||
' // Roll child blockers up to the pane while ignoring non-authoritative child lifecycle events.',
|
||||
' if (childState === true && !isAttentionEvent) {',
|
||||
' let attentionRootSessionID = null;',
|
||||
' let childBusyChanged = false;',
|
||||
@@ -51,13 +48,11 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' }',
|
||||
' if (childState === null && !canFailOpen) {',
|
||||
' if (isIdleEvent) {',
|
||||
' // Why: recorded ownership can safely retire a blocker during an SDK',
|
||||
' // outage without granting unknown child Idle authority over root state.',
|
||||
' // Stored ownership permits exact retirement during SDK outages without trusting unknown Idle.',
|
||||
' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);',
|
||||
' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);',
|
||||
' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);',
|
||||
' // Why: exact-session cleanup, so a child whose finishing Idle lands',
|
||||
' // during an SDK outage cannot pin the pane Busy forever.',
|
||||
' // Retire exact child activity during SDK outages so Busy cannot remain pinned.',
|
||||
' const clearedBusyChild = clearBusyChild(sessionID, factoryID);',
|
||||
' if (',
|
||||
' attentionRootSessionID ||',
|
||||
@@ -71,8 +66,7 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' return;',
|
||||
' }',
|
||||
' if (childState === null && (statusType === "busy" || statusType === "retry")) {',
|
||||
' // Unknown lineage may be child work, so keep exact provisional ownership',
|
||||
' // only until matching Idle/disposal; other blockers still take priority.',
|
||||
' // Keep unresolved Busy ownership provisional until matching Idle/disposal; blockers outrank it.',
|
||||
' clearAttentionForSession(sessionID, factoryID);',
|
||||
' rememberProvisionalBusy(sessionID, factoryID);',
|
||||
' await publishAggregateStatus(factoryID, sessionID);',
|
||||
@@ -80,17 +74,14 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' }',
|
||||
' if (event.type === "permission.asked" || event.type === "question.asked") {',
|
||||
' stateArrivalRevision += 1;',
|
||||
' // Why: attention must share the lifecycle FIFO and retry target so a',
|
||||
' // delayed Busy post cannot overwrite a newer human blocker.',
|
||||
' // Queue attention with lifecycle delivery so delayed Busy cannot overwrite newer blockers.',
|
||||
' const hookEventName =',
|
||||
' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";',
|
||||
' // Why: show the blocker on the root turn while retaining its real child',
|
||||
' // owner for exact reply, tool-completion, and disposal cleanup.',
|
||||
' // Display on the root while retaining child ownership for exact cleanup.',
|
||||
' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };',
|
||||
' const requestID = properties.id || sessionID || "";',
|
||||
' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);',
|
||||
' // Why: unresolved blockers are live UI authority and cannot be evicted;',
|
||||
' // reply, exact Idle, tool completion, or factory disposal retires them.',
|
||||
' // Unresolved blockers are live authority; retire explicitly, never by cache eviction.',
|
||||
' pendingAttentionByKey.set(key, {',
|
||||
' hookEventName,',
|
||||
' properties,',
|
||||
@@ -103,16 +94,14 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' if (isIdleEvent) {',
|
||||
' stateArrivalRevision += 1;',
|
||||
' const idleKey = "idle:" + (sessionID || "");',
|
||||
' // Why: current OpenCode emits canonical idle followed by deprecated',
|
||||
' // session.idle; a failed canonical post should keep its backoff.',
|
||||
' // Deprecated idle follows canonical idle; preserve failed-post backoff.',
|
||||
' if (',
|
||||
' event.type === "session.idle" &&',
|
||||
' desiredStatusKey === idleKey &&',
|
||||
' statusDeliveryDirty &&',
|
||||
' statusRetryTimer',
|
||||
' ) return;',
|
||||
' // Why: flush the coalesced final reply snapshot before the idle',
|
||||
' // transition so the done-state preview shows the completed message.',
|
||||
' // Flush final reply previews before Idle so Done includes the completed text.',
|
||||
' await flushPendingAssistantPart(true);',
|
||||
' clearAttentionForTurnEnd(sessionID, factoryID);',
|
||||
' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {',
|
||||
@@ -121,8 +110,7 @@ export function getStatusPluginLifecycleSource(): string[] {
|
||||
' await publishAggregateStatus(factoryID, sessionID);',
|
||||
' return;',
|
||||
' }',
|
||||
' // Why: recoverable compaction failures emit session.error and continue;',
|
||||
' // canonical session.status is the authority for actual completion.',
|
||||
' // Recoverable compaction errors can continue; canonical session.status decides completion.',
|
||||
' if (event.type === "session.error") return;',
|
||||
' if (statusType === "busy" || statusType === "retry") {',
|
||||
' clearAttentionForSession(sessionID, factoryID);',
|
||||
|
||||
@@ -22,8 +22,7 @@ export function getStatusPluginMessagePreviewSource(): string[] {
|
||||
' clearTimeout(assistantPartFlushTimer);',
|
||||
' assistantPartFlushTimer = null;',
|
||||
' }',
|
||||
' // Why: an idle/waiting transition must wait for every older preview;',
|
||||
' // keep one post in flight while later snapshots coalesce in memory.',
|
||||
' // Keep one preview in flight; later status transitions must wait while snapshots coalesce.',
|
||||
' while (messagePartPostInFlight) await messagePartPostInFlight;',
|
||||
' const pending = pendingAssistantPart;',
|
||||
' pendingAssistantPart = null;',
|
||||
@@ -43,8 +42,7 @@ export function getStatusPluginMessagePreviewSource(): string[] {
|
||||
'}',
|
||||
'',
|
||||
'function queueAssistantPart(part) {',
|
||||
' // Why: keep only the latest snapshot — each event already contains the',
|
||||
' // full accumulated text, so intermediate snapshots are pure waste.',
|
||||
' // Each event carries full accumulated text; retain only the latest snapshot.',
|
||||
' pendingAssistantPart = part;',
|
||||
' const sinceLastPost = Date.now() - lastAssistantPartPostAt;',
|
||||
' if (sinceLastPost >= MESSAGE_PART_THROTTLE_MS) {',
|
||||
@@ -59,11 +57,7 @@ export function getStatusPluginMessagePreviewSource(): string[] {
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'// Why: message.part.updated fires for every Part (text, tool, reasoning)',
|
||||
'// but does not include the message role — that lives on the parent',
|
||||
'// message.updated event. Cache the role per messageID so the plugin can',
|
||||
'// tag a TextPart as user vs assistant when POSTing. Capped at 128 entries',
|
||||
'// so long-running sessions do not grow this map unboundedly.',
|
||||
'// Part events omit role; cache bounded message.updated roles to distinguish prompts from replies.',
|
||||
'const messageRoleById = new Map();',
|
||||
'function rememberMessageRole(messageID, role) {',
|
||||
' if (!messageID || !role) return;',
|
||||
|
||||
@@ -32,7 +32,6 @@ export function getOpenCodeFamilyPluginSource(
|
||||
expectedAgent?: 'opencode' | 'opencode2'
|
||||
}
|
||||
): string {
|
||||
// Why: the plugin posts PTY environment data from OpenCode to the shared hooks server.
|
||||
return [
|
||||
...getStatusPluginEndpointSource(),
|
||||
...getStatusPluginRuntimeStateSource(),
|
||||
|
||||
@@ -11,8 +11,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
'}',
|
||||
'',
|
||||
'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {',
|
||||
' // Why: custom plugins may reuse request IDs across sessions or factories;',
|
||||
' // JSON tuple identity prevents one owner from hiding another blocker.',
|
||||
" // Session/factory-scoped tuples prevent reused request IDs from hiding another owner's blocker.",
|
||||
' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);',
|
||||
'}',
|
||||
'',
|
||||
@@ -27,12 +26,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
' return rootSessionID;',
|
||||
'}',
|
||||
'',
|
||||
'// Why a second, wider clear: a descendant blocker is stored under its own',
|
||||
'// sourceSessionID but displayed on the root it rolled up to, so the root turn',
|
||||
'// ending can never retire it through the source match alone — a subagent question',
|
||||
'// outlives the turn that raised it and pins the pane (#22371). Scoped to turn end',
|
||||
'// on purpose: a live blocker must still outrank the root going Busy, because a',
|
||||
'// subagent can be waiting on the user while the root keeps working.',
|
||||
'// Root turn-end must retire rolled-up child blockers; root Busy alone must not clear them.',
|
||||
'function clearAttentionForTurnEnd(sessionID, factoryID) {',
|
||||
' let rootSessionID = null;',
|
||||
' for (const [key, attention] of pendingAttentionByKey) {',
|
||||
@@ -87,8 +81,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
'',
|
||||
'function rememberProvisionalBusy(sessionID, factoryID) {',
|
||||
' const key = provisionalBusyKey(factoryID, sessionID);',
|
||||
' // Why: active ownership cannot be LRU-evicted without allowing false',
|
||||
' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.',
|
||||
' // Live ownership cannot be LRU-evicted; only exact Idle or factory disposal retires it.',
|
||||
' provisionalBusyByKey.delete(key);',
|
||||
' provisionalBusyByKey.set(key, { sessionID, factoryID });',
|
||||
'}',
|
||||
@@ -103,8 +96,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
'',
|
||||
'function rememberBusyChild(sessionID, rootSessionID, factoryID) {',
|
||||
' const key = busyChildKey(factoryID, sessionID);',
|
||||
' // Why: repeated Busy for the same child is not a state change; report one',
|
||||
' // only when the pane actually gains a running descendant.',
|
||||
' // Repeated child Busy does not change pane activity.',
|
||||
' if (busyChildRootByKey.get(key)?.sessionID === rootSessionID) return false;',
|
||||
' busyChildRootByKey.delete(key);',
|
||||
' busyChildRootByKey.set(key, { sessionID: rootSessionID, factoryID });',
|
||||
@@ -123,8 +115,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
'',
|
||||
'function latestBusyOwner() {',
|
||||
' let latest = null;',
|
||||
' // Why: a running descendant is reported under its ROOT id, and ranks',
|
||||
' // below a directly busy root so an ongoing turn keeps labelling the pane.',
|
||||
' // Attribute child work to its root, below directly busy roots that should label the pane.',
|
||||
' for (const busyChild of busyChildRootByKey.values()) {',
|
||||
' latest = busyChild;',
|
||||
' }',
|
||||
@@ -158,8 +149,7 @@ export function getStatusPluginOwnershipSource(): string[] {
|
||||
'',
|
||||
'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {',
|
||||
' stateArrivalRevision += 1;',
|
||||
' // Why: exact blocker/Busy retirement is authoritative even if ancestry',
|
||||
' // lookup failed; every older preview must settle before its replacement.',
|
||||
' // Exact retirement remains authoritative without ancestry; drain old previews before replacing status.',
|
||||
' await flushPendingAssistantPart(true);',
|
||||
' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);',
|
||||
'}',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export function getStatusPluginPostSource(hookPathname: string): string[] {
|
||||
return [
|
||||
'async function post(hookEventName, extraProperties) {',
|
||||
' // Why: resolve coords per post — the endpoint file may have been',
|
||||
' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs',
|
||||
' // stay on process.env because they are per-PTY (stable for the life of',
|
||||
' // the OpenCode process), not per-Orca-instance.',
|
||||
' // Refresh endpoint coordinates per post after Orca restarts; per-PTY identity stays in process.env.',
|
||||
' const coords = resolveHookCoords();',
|
||||
' const paneKey = process.env.ORCA_PANE_KEY;',
|
||||
' if (!coords.port || !coords.token || !paneKey) return false;',
|
||||
@@ -20,7 +17,7 @@ export function getStatusPluginPostSource(hookPathname: string): string[] {
|
||||
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
|
||||
' env: coords.env,',
|
||||
' version: coords.version,',
|
||||
// opencodeMajor is set only by the OpenCode 2 setup() path, never read from OpenCode's version.
|
||||
// Mark only recognized OpenCode 2 setup contexts, never infer from binary version.
|
||||
' ...(reportingOpenCodeMajor ? { opencodeMajor: reportingOpenCodeMajor } : {}),',
|
||||
' ...(reportingOpenCodeTui ? { opencodeTui: 1 } : {}),',
|
||||
' ...(reportingOpenCodeMajor < 2 && isOpenCodeCommandProcess("serve") && coords.openCodeTui === "1" ? { opencodeSharedServer: 1 } : {}),',
|
||||
@@ -41,8 +38,7 @@ export function getStatusPluginPostSource(hookPathname: string): string[] {
|
||||
' });',
|
||||
' return response.ok;',
|
||||
' } catch {',
|
||||
' // Why: OpenCode session events must never fail the agent run just',
|
||||
' // because Orca is unavailable or the local loopback request failed.',
|
||||
' // Orca/loopback failures must never fail the agent run.',
|
||||
' return false;',
|
||||
' } finally {',
|
||||
' clearTimeout(timeout);',
|
||||
|
||||
@@ -21,34 +21,22 @@ export function getStatusPluginRuntimeStateSource(): string[] {
|
||||
'let busyRecoveryUsed = false;',
|
||||
'let busyRecoveryEndpointKey = "";',
|
||||
'let stateArrivalRevision = 0;',
|
||||
'// Recognized OpenCode 2 contexts bypass the legacy binder.',
|
||||
'let reportingOpenCodeMajor = 0;',
|
||||
'let reportingOpenCodeTui = false;',
|
||||
'// Why: OpenCode can create directory-scoped factories and concurrent root',
|
||||
'// sessions in one pane; module ownership lets waiting/busy aggregate safely.',
|
||||
'// Module ownership aggregates concurrent root sessions and directory-scoped factories in one pane.',
|
||||
'let nextFactoryID = 0;',
|
||||
'const activeFactoryIDs = new Set();',
|
||||
'const disposingFactoryIDs = new Set();',
|
||||
'const busyRootOwnerBySessionID = new Map();',
|
||||
'// Why: a matching Idle must retire fail-open Busy even when the SDK client',
|
||||
'// is unavailable, without granting an unrelated unknown Idle authority.',
|
||||
'// Only matching provisional Idle may retire fail-open Busy without SDK ancestry.',
|
||||
'const provisionalBusyByKey = new Map();',
|
||||
'// Why: a background child outlives the root turn that spawned it, so the',
|
||||
'// pane must stay Busy on its behalf until its own exact Idle (or its',
|
||||
"// factory's disposal) retires it — otherwise the root Idle completes a task",
|
||||
'// tree that is still working.',
|
||||
'// Background children outlive root turns; retain Busy until exact child Idle/factory disposal.',
|
||||
'const busyChildRootByKey = new Map();',
|
||||
'const pendingAttentionByKey = new Map();',
|
||||
'const rootSessionById = new Map();',
|
||||
'const rootSessionLookupById = new Map();',
|
||||
'',
|
||||
'// Why: message.part.updated re-sends the FULL accumulated text of the part',
|
||||
'// after every streamed append, so posting each event forwards O(n^2) bytes',
|
||||
'// per turn through Orca (loopback HTTP -> main JSON parse -> status compare',
|
||||
'// -> IPC -> renderer store update -> React commit). On Windows that flood',
|
||||
'// saturated both event loops and froze the whole UI a few seconds into a',
|
||||
'// streaming reply. The dashboard only needs a bounded preview at a human',
|
||||
'// cadence: cap the text and trailing-edge coalesce assistant parts.',
|
||||
'// Full accumulated replies per delta cause quadratic traffic; cap and coalesce assistant previews.',
|
||||
'const MESSAGE_PART_THROTTLE_MS = 250;',
|
||||
'const MESSAGE_PART_MAX_CHARS = 4000;',
|
||||
'let pendingAssistantPart = null;',
|
||||
@@ -64,7 +52,7 @@ export function getStatusPluginRuntimeStateSource(): string[] {
|
||||
function getRunProcessSource(): string[] {
|
||||
return String.raw`
|
||||
function isOpenCodeCommandProcess(command) {
|
||||
// Why drop a leading path: a compiled binary reports its embedded entry script as argv[1].
|
||||
// Compiled binaries put their embedded entry-script path at argv[1].
|
||||
const args = process.argv.slice(1);
|
||||
if (args.length > 0 && /[\\/]/.test(args[0])) args.shift();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export function getStatusPluginSessionLineageSource(): string[] {
|
||||
return [
|
||||
'// Why: oh-my-opencode style tools spawn child sessions that emit their',
|
||||
'// own session.idle / message events. Those child completions must not',
|
||||
'// flip the root Orca pane to done or overwrite the parent turn preview.',
|
||||
'// Resolve the full parentID chain so descendant attention can be attributed',
|
||||
'// to the root while child completion and previews remain non-authoritative.',
|
||||
'// Resolve ancestry to roll descendant attention up without granting child completion/preview authority.',
|
||||
'async function resolveRootSessionID(client, sessionID) {',
|
||||
' if (!sessionID) return null;',
|
||||
' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);',
|
||||
@@ -60,8 +56,7 @@ export function getStatusPluginSessionLineageSource(): string[] {
|
||||
'async function walkSessionParents(client, sessionID, signal) {',
|
||||
' const lineage = [];',
|
||||
' let currentSessionID = sessionID;',
|
||||
' // Why: malformed or unexpectedly deep ancestry must not monopolize the',
|
||||
' // lifecycle FIFO even when every individual SDK lookup succeeds.',
|
||||
' // Bound total ancestry traversal so deep/malformed chains cannot monopolize lifecycle delivery.',
|
||||
' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {',
|
||||
' const cachedRoot = rootSessionById.get(currentSessionID);',
|
||||
' if (cachedRoot) {',
|
||||
@@ -84,9 +79,7 @@ export function getStatusPluginSessionLineageSource(): string[] {
|
||||
'}',
|
||||
'',
|
||||
'async function lookupSessionList(client, sessionID, signal) {',
|
||||
' // Why: point lookup avoids the SDK list page dropping older children;',
|
||||
' // current SDKs put AbortSignal in a second options argument, while legacy',
|
||||
' // generated clients accept one request-options object.',
|
||||
' // Point lookup finds unlisted older children; support current two-argument and legacy SDK calls.',
|
||||
' if (client?.session?.get) {',
|
||||
' const calls = client.session.get.length >= 2',
|
||||
' ? [',
|
||||
@@ -100,7 +93,7 @@ export function getStatusPluginSessionLineageSource(): string[] {
|
||||
' if (result?.data?.id === sessionID) return { data: [result.data] };',
|
||||
' } catch {',
|
||||
' if (signal.aborted) throw new Error("session lookup aborted");',
|
||||
' // Try the other supported SDK generation, then list fallback.',
|
||||
' // Try another SDK generation, then list fallback.',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
|
||||
Reference in New Issue
Block a user