| @@ -0,0 +1,808 @@ |
| 1 | + // @orca-managed-pi-extension |
| 2 | + // Why: no package-specific type import here. Pi and OMP expose the same |
| 3 | + // extension API, but publish their types under different package names. |
| 4 | + // Why: warn-once so a recurring parse error on a malformed endpoint |
| 5 | + // file does not spam stderr inside the pi TUI on every event. |
| 6 | + let warnedBadEndpoint = false |
| 7 | + // Why: Pi awaits extension handlers. Status delivery stays off that |
| 8 | + // critical path, and the latest-only pending slot prevents a stalled |
| 9 | + // Orca receiver from building an unbounded queue of obsolete snapshots. |
| 10 | + const HOOK_POST_TIMEOUT_MS = 1000 |
| 11 | + type HookPost = { hookEventName: string; extra: Record<string, unknown>; metadata: Record<string, unknown>; ompRuntime: boolean; revision: number; attempts: number; delivered: boolean } |
| 12 | + let activePost = false |
| 13 | + let pendingPost: HookPost | null = null |
| 14 | + let latestPost: HookPost | null = null |
| 15 | + // A newer snapshot or session boundary retires every older retry. |
| 16 | + let postRevision = 0 |
| 17 | + let retryTimer: ReturnType<typeof setTimeout> | null = null |
| 18 | + |
| 19 | + function cancelPostRetry(): void { |
| 20 | + if (retryTimer !== null) clearTimeout(retryTimer) |
| 21 | + retryTimer = null |
| 22 | + } |
| 23 | + |
| 24 | + function resetPostQueue(): void { |
| 25 | + cancelPostRetry() |
| 26 | + postRevision++ |
| 27 | + pendingPost = null |
| 28 | + latestPost = null |
| 29 | + } |
| 30 | + |
| 31 | + function drainPosts(): void { |
| 32 | + if (activePost || !pendingPost) return |
| 33 | + const next = pendingPost |
| 34 | + pendingPost = null |
| 35 | + activePost = true |
| 36 | + void postOnce(next.hookEventName, next.extra, next.metadata, next.ompRuntime) |
| 37 | + .then(() => { next.delivered = true }) |
| 38 | + .catch(() => { |
| 39 | + if (!next.ompRuntime || next.revision !== postRevision) return |
| 40 | + if (next.attempts >= 3) { |
| 41 | + console.warn('[orca-pi-status] hook delivery failed after retries:', next.hookEventName) |
| 42 | + return |
| 43 | + } |
| 44 | + const delay = 250 * 2 ** next.attempts++ |
| 45 | + retryTimer = setTimeout(() => { |
| 46 | + retryTimer = null |
| 47 | + if (next.revision !== postRevision) return |
| 48 | + pendingPost = next |
| 49 | + drainPosts() |
| 50 | + }, delay) |
| 51 | + if (typeof retryTimer.unref === 'function') retryTimer.unref() |
| 52 | + }) |
| 53 | + .finally(() => { |
| 54 | + activePost = false |
| 55 | + drainPosts() |
| 56 | + }) |
| 57 | + } |
| 58 | + |
| 59 | + let piUiPromptDepth = 0 |
| 60 | + let piTurnInFlight = false |
| 61 | + let modelMetadata: Record<string, unknown> = {} |
| 62 | + let ompModelSwitchSupported = false |
| 63 | + let modelSessionId: unknown = undefined |
| 64 | + |
| 65 | + // Why: OMP switches sessions in-process; a model belongs to the session that |
| 66 | + // reported it, so it must not leak onto the first posts of the next one. |
| 67 | + function trackModelSession(sessionId: unknown): void { |
| 68 | + if (sessionId === modelSessionId) return |
| 69 | + modelSessionId = sessionId |
| 70 | + modelMetadata = {} |
| 71 | + } |
| 72 | + |
| 73 | + // Why: pi and OMP expose the active model as { provider, id } on both the event |
| 74 | + // context and model_select events; the joined selector is what --model accepts. |
| 75 | + function updateModelMetadata(source: unknown): void { |
| 76 | + try { |
| 77 | + if (!source || typeof source !== 'object' || !('model' in source)) return |
| 78 | + const model = source.model |
| 79 | + if (!model || typeof model !== 'object') return |
| 80 | + const provider = 'provider' in model && typeof model.provider === 'string' ? model.provider : '' |
| 81 | + const id = 'id' in model && typeof model.id === 'string' ? model.id : '' |
| 82 | + if (provider && id) modelMetadata = { model: provider + '/' + id, ...(ompModelSwitchSupported ? { model_switch_command: 'orca-model' } : {}) } |
| 83 | + } catch { |
| 84 | + // Why: a throwing model getter must never break status delivery. |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + let sessionMetadata: Record<string, unknown> = {} |
| 89 | + let runtimeOmpSessionMetadata: Record<string, unknown> = {} |
| 90 | + |
| 91 | + function updateSessionMetadata(ctx: unknown): void { |
| 92 | + const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager |
| 93 | + const sessionId = sessionManager?.getSessionId?.() |
| 94 | + const sessionFile = sessionManager?.getSessionFile?.() |
| 95 | + sessionMetadata = typeof sessionId === 'string' && sessionId ? { |
| 96 | + session_id: sessionId, |
| 97 | + ...(typeof sessionFile === 'string' && sessionFile ? { session_file: sessionFile } : {}), |
| 98 | + } : {} |
| 99 | + } |
| 100 | + |
| 101 | + function updateRuntimeOmpSessionMetadata(ctx: unknown): void { |
| 102 | + if (!isOmpRuntime()) return |
| 103 | + const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager |
| 104 | + const sessionId = sessionManager?.getSessionId?.() |
| 105 | + const sessionFile = sessionManager?.getSessionFile?.() |
| 106 | + runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {} |
| 107 | + trackModelSession(runtimeOmpSessionMetadata.session_id) |
| 108 | + updateModelMetadata(ctx) |
| 109 | + } |
| 110 | + |
| 111 | + function getPostSessionMetadata(ompRuntime: boolean): Record<string, unknown> { |
| 112 | + return ompRuntime ? { ...runtimeOmpSessionMetadata, ...modelMetadata } : sessionMetadata |
| 113 | + } |
| 114 | + |
| 115 | + function getPersistedSessionMetadata(): Record<string, unknown> { |
| 116 | + const sessionFile = sessionMetadata.session_file |
| 117 | + if (typeof sessionFile !== 'string' || !sessionFile) return {} |
| 118 | + try { |
| 119 | + const fs = require('fs') |
| 120 | + // Why: Pi publishes its planned path before creating the transcript; |
| 121 | + // recheck on every post so the first completed turn becomes resumable. |
| 122 | + return fs.existsSync(sessionFile) ? sessionMetadata : {} |
| 123 | + } catch { |
| 124 | + return {} |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + |
| 129 | + // Why: re-reading the endpoint file on every event is cheap (small file, |
| 130 | + // rare changes) but stat+mtime caching avoids re-parsing on every event |
| 131 | + // during streaming tool execution. Mirrors the OpenCode plugin cache shape. |
| 132 | + let cachedEndpointKey = '' |
| 133 | + let cachedEndpointValues: Record<string, string> | null = null |
| 134 | + |
| 135 | + function readEndpointFile(): Record<string, string> | null { |
| 136 | + const path = process.env.ORCA_AGENT_HOOK_ENDPOINT |
| 137 | + if (!path) return null |
| 138 | + try { |
| 139 | + const fs = require('fs') |
| 140 | + try { |
| 141 | + const stat = fs.statSync(path) |
| 142 | + const cacheKey = stat.mtimeMs + ':' + stat.size + ':' + stat.ino |
| 143 | + if (cacheKey === cachedEndpointKey && cachedEndpointValues) { |
| 144 | + return cachedEndpointValues |
| 145 | + } |
| 146 | + const contents: string = fs.readFileSync(path, 'utf8') |
| 147 | + const out: Record<string, string> = {} |
| 148 | + for (const line of contents.split(/\r?\n/)) { |
| 149 | + // Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE` |
| 150 | + // (Windows endpoint.cmd) with one regex; strip a trailing CR so |
| 151 | + // mixed-EOL files do not leak \r into the value. |
| 152 | + const m = line.match(/^(?:set\s+)?([A-Z0-9_]+)=(.*)$/) |
| 153 | + if (m) out[m[1]] = m[2].replace(/\r$/, '') |
| 154 | + } |
| 155 | + cachedEndpointKey = cacheKey |
| 156 | + cachedEndpointValues = out |
| 157 | + return out |
| 158 | + } catch (ioErr) { |
| 159 | + cachedEndpointKey = '' |
| 160 | + cachedEndpointValues = null |
| 161 | + throw ioErr |
| 162 | + } |
| 163 | + } catch (err: unknown) { |
| 164 | + const code = (err as { code?: string } | null)?.code |
| 165 | + if (err && code !== 'ENOENT' && !warnedBadEndpoint) { |
| 166 | + warnedBadEndpoint = true |
| 167 | + console.warn('[orca-pi-status] failed to parse endpoint file:', (err as Error).message) |
| 168 | + } |
| 169 | + return null |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + function resolveHookCoords() { |
| 174 | + const fileEnv = readEndpointFile() || {} |
| 175 | + return { |
| 176 | + port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT, |
| 177 | + token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN, |
| 178 | + env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || '', |
| 179 | + version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || '', |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + function processName(value: unknown): string { |
| 184 | + return String(value || '').split(/[\\/]/).pop()?.toLowerCase() || '' |
| 185 | + } |
| 186 | + |
| 187 | + const CONFIGURED_HOOK_PATH = '/hook/pi' |
| 188 | + let cachedOmpRuntime: boolean | null = null |
| 189 | + |
| 190 | + function isOmpRuntime(): boolean { |
| 191 | + if (cachedOmpRuntime !== null) return cachedOmpRuntime |
| 192 | + if (CONFIGURED_HOOK_PATH === '/hook/omp') { |
| 193 | + cachedOmpRuntime = true |
| 194 | + return true |
| 195 | + } |
| 196 | + const executableNames = [ |
| 197 | + processName(process.title), |
| 198 | + processName(process.env._), |
| 199 | + processName(process.argv[1]), |
| 200 | + processName(process.argv[0]) |
| 201 | + ] |
| 202 | + cachedOmpRuntime = executableNames.some((name) => |
| 203 | + ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name) |
| 204 | + ) |
| 205 | + return cachedOmpRuntime |
| 206 | + } |
| 207 | + |
| 208 | + function resolveHookPath(ompRuntime: boolean): string { |
| 209 | + // Why: runtime detection keeps a bare-shell OMP launch from reporting as Pi. |
| 210 | + if (ompRuntime) return '/hook/omp' |
| 211 | + return CONFIGURED_HOOK_PATH |
| 212 | + } |
| 213 | + |
| 214 | + function post(hookEventName: string, extra: Record<string, unknown> = {}): void { |
| 215 | + const ompRuntime = isOmpRuntime() |
| 216 | + cancelPostRetry() |
| 217 | + const metadata = getPostSessionMetadata(ompRuntime) |
| 218 | + // Model changes must not erase an unacknowledged completion in the latest-only slot. |
| 219 | + const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id |
| 220 | + pendingPost = { |
| 221 | + revision: ++postRevision, |
| 222 | + attempts: 0, |
| 223 | + delivered: false, |
| 224 | + hookEventName: ompRuntime && hookEventName === 'model_select' && previousCompletion ? 'agent_end' : hookEventName, |
| 225 | + extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) }, |
| 226 | + metadata, |
| 227 | + ompRuntime, |
| 228 | + } |
| 229 | + latestPost = pendingPost |
| 230 | + drainPosts() |
| 231 | + } |
| 232 | + |
| 233 | + async function postOnce( |
| 234 | + hookEventName: string, |
| 235 | + extra: Record<string, unknown>, |
| 236 | + metadata: Record<string, unknown>, |
| 237 | + ompRuntime: boolean |
| 238 | + ): Promise<void> { |
| 239 | + const coords = resolveHookCoords() |
| 240 | + const paneKey = process.env.ORCA_PANE_KEY |
| 241 | + if (!coords.port || !coords.token || !paneKey) return |
| 242 | + const url = `http://127.0.0.1:${coords.port}${resolveHookPath(ompRuntime)}` |
| 243 | + const body = JSON.stringify({ |
| 244 | + paneKey, |
| 245 | + launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || '', |
| 246 | + tabId: process.env.ORCA_TAB_ID || '', |
| 247 | + worktreeId: process.env.ORCA_WORKTREE_ID || '', |
| 248 | + env: coords.env, |
| 249 | + version: coords.version, |
| 250 | + payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata()), ...extra }, |
| 251 | + }) |
| 252 | + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null |
| 253 | + let timeout: ReturnType<typeof setTimeout> | undefined |
| 254 | + const timeoutPromise = new Promise<never>((_resolve, reject) => { |
| 255 | + timeout = setTimeout(() => { |
| 256 | + controller?.abort() |
| 257 | + reject(new Error('Orca hook delivery timed out')) |
| 258 | + }, HOOK_POST_TIMEOUT_MS) |
| 259 | + if (typeof timeout.unref === 'function') timeout.unref() |
| 260 | + }) |
| 261 | + try { |
| 262 | + const response = await Promise.race([ |
| 263 | + fetch(url, { |
| 264 | + method: 'POST', |
| 265 | + headers: { |
| 266 | + 'Content-Type': 'application/json', |
| 267 | + 'X-Orca-Agent-Hook-Token': coords.token, |
| 268 | + }, |
| 269 | + body, |
| 270 | + ...(controller ? { signal: controller.signal } : {}), |
| 271 | + }), |
| 272 | + timeoutPromise, |
| 273 | + ]) |
| 274 | + if (!response.ok) throw new Error('Orca hook HTTP ' + response.status) |
| 275 | + } catch (error) { |
| 276 | + // Why: status reporting must never fail the pi run just because Orca |
| 277 | + // is unavailable or the loopback request failed (e.g. Orca restart). |
| 278 | + if (!isWslRuntime()) throw error |
| 279 | + await postViaWindowsCurl(body, ompRuntime) |
| 280 | + } finally { |
| 281 | + if (timeout) clearTimeout(timeout) |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + // Why: WSL-ness and curl.exe presence cannot change within a process |
| 286 | + // lifetime; re-probing /proc and /mnt/c on every failed event would add |
| 287 | + // filesystem work to the per-event path. |
| 288 | + let cachedIsWslRuntime: boolean | null = null |
| 289 | + let cachedWindowsCurlPath: string | null | undefined |
| 290 | + |
| 291 | + function isWslRuntime(): boolean { |
| 292 | + if (cachedIsWslRuntime !== null) return cachedIsWslRuntime |
| 293 | + cachedIsWslRuntime = detectWslRuntime() |
| 294 | + return cachedIsWslRuntime |
| 295 | + } |
| 296 | + |
| 297 | + function detectWslRuntime(): boolean { |
| 298 | + if (process.env.WSL_DISTRO_NAME) return true |
| 299 | + try { |
| 300 | + const fs = require('fs') |
| 301 | + for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) { |
| 302 | + try { |
| 303 | + const contents = String(fs.readFileSync(path, 'utf8')) |
| 304 | + if (/microsoft|wsl/i.test(contents)) return true |
| 305 | + } catch { |
| 306 | + // Why: probe the next runtime hint when a proc file is absent or unreadable. |
| 307 | + } |
| 308 | + } |
| 309 | + } catch { |
| 310 | + return false |
| 311 | + } |
| 312 | + return false |
| 313 | + } |
| 314 | + |
| 315 | + function resolveWindowsCurlPath(): string | null { |
| 316 | + if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath |
| 317 | + try { |
| 318 | + const fs = require('fs') |
| 319 | + const curlPath = '/mnt/c/Windows/System32/curl.exe' |
| 320 | + cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null |
| 321 | + } catch { |
| 322 | + cachedWindowsCurlPath = null |
| 323 | + } |
| 324 | + return cachedWindowsCurlPath |
| 325 | + } |
| 326 | + |
| 327 | + // Why: WSL loopback is not the Windows loopback, so use curl.exe on the host. |
| 328 | + async function postViaWindowsCurl(body: string, ompRuntime: boolean): Promise<void> { |
| 329 | + const curlPath = resolveWindowsCurlPath() |
| 330 | + const windowsPort = process.env.ORCA_AGENT_HOOK_PORT |
| 331 | + const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN |
| 332 | + if (!curlPath || !windowsPort || !windowsToken) throw new Error('Orca WSL hook bridge unavailable') |
| 333 | + // Why: a stale guest endpoint must fall back to current host coordinates. |
| 334 | + const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}` |
| 335 | + await new Promise<void>((resolve, reject) => { |
| 336 | + const { spawn } = require('child_process') |
| 337 | + const child = spawn( |
| 338 | + curlPath, |
| 339 | + [ |
| 340 | + '-sS', '--fail', |
| 341 | + // WSL interop needs a bounded, acknowledged delivery window. |
| 342 | + '--connect-timeout', '3', |
| 343 | + '--max-time', '10', |
| 344 | + '--noproxy', '127.0.0.1', |
| 345 | + '-o', 'NUL', |
| 346 | + '-X', 'POST', |
| 347 | + '-H', 'Content-Type: application/json', |
| 348 | + '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`, |
| 349 | + '--data-binary', '@-', |
| 350 | + windowsUrl |
| 351 | + ], |
| 352 | + { stdio: ['pipe', 'ignore', 'ignore'] } |
| 353 | + ) |
| 354 | + const timer = setTimeout(() => { |
| 355 | + try { child.kill() } catch {} |
| 356 | + reject(new Error('Orca WSL hook delivery timed out')) |
| 357 | + }, 11000) |
| 358 | + if (typeof timer.unref === 'function') timer.unref() |
| 359 | + const fail = (error: Error): void => { clearTimeout(timer); reject(error) } |
| 360 | + child.on('error', fail) |
| 361 | + child.stdin.on('error', fail) |
| 362 | + child.on('close', (code: number | null) => { |
| 363 | + clearTimeout(timer) |
| 364 | + if (code === 0) resolve(); else reject(new Error('Orca WSL hook exit ' + code)) |
| 365 | + }) |
| 366 | + child.stdin.end(body) |
| 367 | + }) |
| 368 | + } |
| 369 | + |
| 370 | + function statusInputReferencesCredentialPath(value: string): boolean { |
| 371 | + const path = value.replace(/\\/g, '/') |
| 372 | + return /(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.(?:ssh|ssh-mcp)(?=$|[^a-z0-9_.-])/i.test(path) || |
| 373 | + /\.mcp-secrets\.env(?=$|[^a-z0-9_.-])/i.test(path) || |
| 374 | + /(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.omp-backups-archive\/omp-bak-keyfile(?=$|[^a-z0-9_.-])/i.test(path) |
| 375 | + } |
| 376 | + |
| 377 | + function sanitizeStatusToolInput(input: unknown): unknown { |
| 378 | + const ancestors = new WeakSet<object>() |
| 379 | + let remainingNodes = 4096 |
| 380 | + let remainingChars = 262144 |
| 381 | + function checkText(text: string): void { |
| 382 | + remainingChars -= text.length |
| 383 | + if (remainingChars < 0 || statusInputReferencesCredentialPath(text)) throw new Error('redact') |
| 384 | + } |
| 385 | + function copy(value: unknown, depth: number): unknown { |
| 386 | + if (--remainingNodes < 0 || depth > 64) throw new Error('redact') |
| 387 | + if (typeof value === 'string') { checkText(value); return value } |
| 388 | + if (value === null || value === undefined || typeof value === 'boolean') return value |
| 389 | + if (typeof value === 'number' && Number.isFinite(value)) return value |
| 390 | + if (typeof value !== 'object' || ancestors.has(value)) throw new Error('redact') |
| 391 | + const array = Array.isArray(value) |
| 392 | + if (array) { |
| 393 | + const length = Object.getOwnPropertyDescriptor(value, 'length')?.value |
| 394 | + if (!Number.isSafeInteger(length) || length < 0 || length > remainingNodes) throw new Error('redact') |
| 395 | + remainingNodes -= length |
| 396 | + } |
| 397 | + const prototype = Object.getPrototypeOf(value) |
| 398 | + if (!array && prototype !== null) { |
| 399 | + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor') |
| 400 | + if (Object.getPrototypeOf(prototype) !== null || !constructor || !('value' in constructor) || |
| 401 | + typeof constructor.value !== 'function' || |
| 402 | + Object.getOwnPropertyDescriptor(constructor.value, 'name')?.value !== 'Object') { |
| 403 | + throw new Error('redact') |
| 404 | + } |
| 405 | + } |
| 406 | + ancestors.add(value) |
| 407 | + // Copy data descriptors only; never return an object that can run toJSON later. |
| 408 | + const result = array ? [] : Object.create(null) |
| 409 | + if (array) Object.setPrototypeOf(result, null) |
| 410 | + for (const key of Reflect.ownKeys(value)) { |
| 411 | + if (typeof key !== 'string') throw new Error('redact') |
| 412 | + checkText(key) |
| 413 | + const descriptor = Object.getOwnPropertyDescriptor(value, key) |
| 414 | + if (!descriptor || !('value' in descriptor)) throw new Error('redact') |
| 415 | + const copied = copy(descriptor.value, depth + 1) |
| 416 | + Object.defineProperty(result, key, { value: copied, enumerable: descriptor.enumerable, |
| 417 | + writable: true, configurable: key !== 'length' || !array }) |
| 418 | + } |
| 419 | + ancestors.delete(value) |
| 420 | + return result |
| 421 | + } |
| 422 | + try { return copy(input, 0) } catch { return { redacted: true } } |
| 423 | + } |
| 424 | + // Why: pi assistant messages carry content as an array of parts |
| 425 | + // ({ type: 'text', text } / tool_use / tool_result / reasoning). We only |
| 426 | + // surface the concatenated text parts as the visible 'last assistant |
| 427 | + // message' for the dashboard preview — tool_use / reasoning would be |
| 428 | + // noise (the dashboard already shows the active tool name + input). |
| 429 | + function extractAssistantText(message: unknown): string { |
| 430 | + if (!message || typeof message !== 'object') return '' |
| 431 | + const content = (message as { content?: unknown }).content |
| 432 | + if (typeof content === 'string') return content |
| 433 | + if (!Array.isArray(content)) return '' |
| 434 | + let out = '' |
| 435 | + for (const part of content) { |
| 436 | + if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') { |
| 437 | + const text = (part as { text?: unknown }).text |
| 438 | + if (typeof text === 'string') out += text |
| 439 | + } |
| 440 | + } |
| 441 | + return out |
| 442 | + } |
| 443 | + |
| 444 | + // Preserve ordinary preview data; credential references never leave the agent host. |
| 445 | + // Why: a restarted agent inherits the previous owner PID through env, so a |
| 446 | + // dead owner must be claimable or the pane goes silent for good. Only ESRCH |
| 447 | + // proves the owner is gone -- every other probe result keeps suppression, so |
| 448 | + // a live foreign owner still cannot double-report. Mirrors the tri-state in |
| 449 | + // main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot |
| 450 | + // import (the extension loads inside pi/omp with no Orca deps). |
| 451 | + function isStatusOwnerAlive(pid: string): boolean { |
| 452 | + const parsed = Number(pid) |
| 453 | + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false |
| 454 | + if (typeof process.kill !== 'function') return true |
| 455 | + try { |
| 456 | + process.kill(parsed, 0) |
| 457 | + return true |
| 458 | + } catch (err: unknown) { |
| 459 | + return (err as { code?: string } | null)?.code !== 'ESRCH' |
| 460 | + } |
| 461 | + } |
| 462 | + |
| 463 | + // Why: child agents inherit the lead's pane env; only its process may |
| 464 | + // register status hooks. PID identity keeps in-process reloads reporting. |
| 465 | + export default function (pi): void { |
| 466 | + const ownerPid = process.env.ORCA_PI_STATUS_OWNED |
| 467 | + const selfPid = String(process.pid) |
| 468 | + if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return |
| 469 | + process.env.ORCA_PI_STATUS_OWNED = selfPid |
| 470 | + resetPostQueue() |
| 471 | + const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events |
| 472 | + const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set<string>; exited?: Set<string>; waiting: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set<string>(), waiting: false } |
| 473 | + if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState |
| 474 | + if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) { |
| 475 | + const listener = (event: unknown) => lifecycleState.onEvent?.(event) |
| 476 | + lifecycleState.listener = listener |
| 477 | + piEventBus.on('task:subagent:lifecycle', listener) |
| 478 | + piEventBus.on('subagent:async-started', (event: unknown) => lifecycleState.onEvent?.(event, 'started')) |
| 479 | + piEventBus.on('subagent:async-complete', (event: unknown) => lifecycleState.onEvent?.(event, 'completed')) |
| 480 | + } |
| 481 | + if (piEventBus?.on && !lifecycleState.runnerExitListener) { |
| 482 | + const runnerExitListener = (event: unknown) => lifecycleState.onRunnerExit?.(event) |
| 483 | + lifecycleState.runnerExitListener = runnerExitListener |
| 484 | + piEventBus.on('subagent:process-terminal', runnerExitListener) |
| 485 | + } |
| 486 | + pi.on('session_switch', (_event, ctx) => { |
| 487 | + if (!isOmpRuntime()) return |
| 488 | + lifecycleState.active.clear() |
| 489 | + lifecycleState.exited?.clear() |
| 490 | + lifecycleState.waiting = false |
| 491 | + resetPostQueue() |
| 492 | + clearPendingAgentEndCheck() |
| 493 | + updateRuntimeOmpSessionMetadata(ctx) |
| 494 | + }) |
| 495 | + // SessionManager survives reload/new/resume; task children own a different instance. |
| 496 | + function sessionProvenance(ctx): { manager: unknown; id?: string; file?: string; parent?: string } | undefined { |
| 497 | + const manager = ctx?.sessionManager |
| 498 | + if (!manager || typeof manager !== 'object') return undefined |
| 499 | + const id = typeof manager.getSessionId === "function" ? manager.getSessionId() : undefined |
| 500 | + const file = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined |
| 501 | + const header = typeof manager.getHeader === "function" ? manager.getHeader() : undefined |
| 502 | + return { manager, id, file, parent: typeof header?.parentSession === "string" ? header.parentSession : undefined } |
| 503 | + } |
| 504 | + |
| 505 | + function normalizeSessionPath(file): string { |
| 506 | + const normalized = file.replace(/\\/g, "/") |
| 507 | + return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized |
| 508 | + } |
| 509 | + |
| 510 | + function isNestedTaskTranscript(parentFile, candidateFile): boolean { |
| 511 | + if (typeof parentFile !== "string" || typeof candidateFile !== "string") return false |
| 512 | + const parent = normalizeSessionPath(parentFile) |
| 513 | + const candidate = normalizeSessionPath(candidateFile) |
| 514 | + const root = parent.endsWith(".jsonl") ? parent.slice(0, -6) : parent |
| 515 | + return candidate.startsWith(`${root}/`) && candidate !== parent |
| 516 | + } |
| 517 | + |
| 518 | + function ownsSessionStatus(ctx): boolean { |
| 519 | + if (!isOmpRuntime()) return true |
| 520 | + // Newer OMP builds may expose this computed runtime provenance directly. |
| 521 | + if (ctx?.agentKind === "sub") return false |
| 522 | + const current = sessionProvenance(ctx) |
| 523 | + if (!current) return true |
| 524 | + // A task transcript is never the pane's resumable root, even when its |
| 525 | + // callback arrives before the root session reports on the shared hook. |
| 526 | + if (current.parent) return false |
| 527 | + // Keep ownership through module reload and shutdown while child sessions drain. |
| 528 | + const key = Symbol.for('orca.omp.status-session-owners') |
| 529 | + let owners = Reflect.get(globalThis, key) |
| 530 | + if (!(owners instanceof Map)) { |
| 531 | + owners = new Map() |
| 532 | + Reflect.set(globalThis, key, owners) |
| 533 | + } |
| 534 | + const pane = JSON.stringify([process.env.ORCA_PANE_KEY, process.env.ORCA_AGENT_LAUNCH_TOKEN]) |
| 535 | + const owner = owners.get(pane) |
| 536 | + if (owner) { |
| 537 | + if (owner.manager === current.manager) return true |
| 538 | + if (current.parent === owner.file || current.parent === owner.id) return false |
| 539 | + if (isNestedTaskTranscript(owner.file, current.file)) return false |
| 540 | + return false |
| 541 | + } |
| 542 | + owners.set(pane, current) |
| 543 | + return true |
| 544 | + } |
| 545 | + |
| 546 | + function onStatus(name, handler): void { |
| 547 | + pi.on(name, (event, ctx) => { |
| 548 | + if (!ownsSessionStatus(ctx)) return |
| 549 | + return handler(event, ctx) |
| 550 | + }) |
| 551 | + } |
| 552 | + |
| 553 | + onStatus('session_start', () => {}) |
| 554 | + |
| 555 | + if (isOmpRuntime() && typeof pi.registerCommand === 'function' && typeof pi.setModel === 'function') { |
| 556 | + pi.registerCommand('orca-model', { |
| 557 | + description: 'Switch the model selected in Orca', |
| 558 | + handler: async (selector, ctx) => { |
| 559 | + const models = ctx.modelRegistry.getAvailable() |
| 560 | + const model = models.find((candidate) => candidate.provider + '/' + candidate.id === selector.trim()) |
| 561 | + if (!model) { |
| 562 | + ctx.ui.notify('Model is no longer available. Refresh the Orca model picker.', 'error') |
| 563 | + return |
| 564 | + } |
| 565 | + if (!await pi.setModel(model)) { |
| 566 | + ctx.ui.notify('Could not switch model: no API key is available.', 'error') |
| 567 | + return |
| 568 | + } |
| 569 | + updateRuntimeOmpSessionMetadata(ctx) |
| 570 | + updateModelMetadata({ model }) |
| 571 | + post('model_select') |
| 572 | + } |
| 573 | + }) |
| 574 | + ompModelSwitchSupported = true |
| 575 | + } |
| 576 | + onStatus('session_start', (event, ctx) => { |
| 577 | + updateSessionMetadata(ctx) |
| 578 | + piUiPromptDepth = 0 |
| 579 | + // Why: /reload re-registers the active session, but it is not a |
| 580 | + // turn boundary and must not clear the visible status or unread state. |
| 581 | + if (event.reason === 'reload') return |
| 582 | + post('session_start') |
| 583 | + }) |
| 584 | + |
| 585 | + onStatus('before_agent_start', (event, ctx) => { |
| 586 | + updateRuntimeOmpSessionMetadata(ctx) |
| 587 | + post('before_agent_start', { prompt: event.prompt ?? '' }) |
| 588 | + }) |
| 589 | + |
| 590 | + onStatus('agent_start', (_event, ctx) => { |
| 591 | + updateRuntimeOmpSessionMetadata(ctx) |
| 592 | + clearPendingAgentEndCheck() |
| 593 | + lifecycleState.waiting = false |
| 594 | + runGeneration += 1 |
| 595 | + piUiPromptDepth = 0 |
| 596 | + piTurnInFlight = true |
| 597 | + post('agent_start') |
| 598 | + }) |
| 599 | + |
| 600 | + onStatus('tool_execution_start', (event, ctx) => { |
| 601 | + updateRuntimeOmpSessionMetadata(ctx) |
| 602 | + post('tool_execution_start', { |
| 603 | + tool_name: event.toolName, |
| 604 | + tool_input: sanitizeStatusToolInput(event.args), |
| 605 | + }) |
| 606 | + }) |
| 607 | + |
| 608 | + onStatus('tool_call', (event, ctx) => { |
| 609 | + updateRuntimeOmpSessionMetadata(ctx) |
| 610 | + post('tool_call', { |
| 611 | + tool_name: event.toolName, |
| 612 | + tool_input: sanitizeStatusToolInput(event.input), |
| 613 | + }) |
| 614 | + }) |
| 615 | + |
| 616 | + onStatus('tool_execution_end', (event, ctx) => { |
| 617 | + updateRuntimeOmpSessionMetadata(ctx) |
| 618 | + post('tool_execution_end', { |
| 619 | + tool_name: event.toolName, |
| 620 | + }) |
| 621 | + }) |
| 622 | + |
| 623 | + onStatus('tool_approval_requested', (event, ctx) => { |
| 624 | + updateRuntimeOmpSessionMetadata(ctx) |
| 625 | + if (!isOmpRuntime()) return |
| 626 | + post('tool_approval_requested', { |
| 627 | + tool_name: event.toolName, |
| 628 | + reason: event.reason, |
| 629 | + approval_mode: event.approvalMode, |
| 630 | + }) |
| 631 | + }) |
| 632 | + |
| 633 | + onStatus('tool_approval_resolved', (event, ctx) => { |
| 634 | + updateRuntimeOmpSessionMetadata(ctx) |
| 635 | + if (!isOmpRuntime()) return |
| 636 | + post('tool_approval_resolved', { |
| 637 | + tool_name: event.toolName, |
| 638 | + approved: event.approved, |
| 639 | + }) |
| 640 | + }) |
| 641 | + |
| 642 | + onStatus('ui_prompt_start', () => { |
| 643 | + // Idle utility dialogs are not agent work; do not create a completion boundary for them. |
| 644 | + if (isOmpRuntime() || !piTurnInFlight) return |
| 645 | + piUiPromptDepth++ |
| 646 | + if (piUiPromptDepth > 1) return |
| 647 | + post('ui_prompt_start') |
| 648 | + }) |
| 649 | + |
| 650 | + onStatus('ui_prompt_end', (_event, ctx) => { |
| 651 | + if (isOmpRuntime() || piUiPromptDepth === 0) return |
| 652 | + piUiPromptDepth-- |
| 653 | + if (piUiPromptDepth > 0) return |
| 654 | + // Why: ctx.isIdle throws outright once a session-switching modal invalidates the |
| 655 | + // runner (it calls assertActive), so local turn state is the floor, not a fallback: |
| 656 | + // with no turn in flight, no later event is coming to correct a working verdict, so |
| 657 | + // only consult ctx when this process believes work is running. |
| 658 | + let isIdle = !piTurnInFlight |
| 659 | + try { |
| 660 | + if (!isIdle && typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true |
| 661 | + } catch { |
| 662 | + // Why: a runner this very modal invalidated cannot answer; keep the local verdict. |
| 663 | + } |
| 664 | + post('ui_prompt_end', { is_idle: isIdle }) |
| 665 | + }) |
| 666 | + |
| 667 | + onStatus('session_shutdown', () => { |
| 668 | + resetPostQueue() |
| 669 | + clearPendingAgentEndCheck() |
| 670 | + if (isOmpRuntime()) return |
| 671 | + // Why: pi tears an open dialog down through resetExtensionUI without resolving its |
| 672 | + // promise, so a replaced session never emits the matching ui_prompt_end and the wait |
| 673 | + // would stick forever. Reset without posting: shutdown is not a turn boundary, and |
| 674 | + // the session_start that follows republishes the corrected state. |
| 675 | + piUiPromptDepth = 0 |
| 676 | + }) |
| 677 | + |
| 678 | + pi.on('model_select', (event, ctx) => { |
| 679 | + updateRuntimeOmpSessionMetadata(ctx) |
| 680 | + if (!isOmpRuntime()) return |
| 681 | + updateModelMetadata(event) |
| 682 | + post('model_select') |
| 683 | + }) |
| 684 | + |
| 685 | + // Why: capture the assistant's final text on each completed message |
| 686 | + // so the dashboard preview reflects the most recent reply even before |
| 687 | + // agent_end fires. message_end is the right hook because pi guarantees |
| 688 | + // it fires after the message is finalized (post-streaming). |
| 689 | + onStatus('message_end', (event, ctx) => { |
| 690 | + updateRuntimeOmpSessionMetadata(ctx) |
| 691 | + if (event.message?.role !== 'assistant') return |
| 692 | + const text = extractAssistantText(event.message) |
| 693 | + if (!text) return |
| 694 | + post('message_end', { role: 'assistant', text }) |
| 695 | + }) |
| 696 | + |
| 697 | + // Why: modern Pi stays non-idle across retry/compaction/follow-up work, |
| 698 | + // while legacy Pi becomes idle after its final agent_end handlers. |
| 699 | + // OMP instead marks non-terminal agent_end events with willContinue, so it |
| 700 | + // returns before the recheck timer is ever armed. |
| 701 | + const AGENT_END_IDLE_RECHECK_MS = 25 |
| 702 | + const AGENT_END_IDLE_RECHECK_MAX_MS = 250 |
| 703 | + let agentSettledSupported = false |
| 704 | + let runGeneration = 0 |
| 705 | + let endedRunGeneration = 0 |
| 706 | + let completionPostedGeneration = -1 |
| 707 | + let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS |
| 708 | + let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null |
| 709 | + let pendingAgentEndContext: { isIdle: () => boolean } | null = null |
| 710 | + |
| 711 | + function clearPendingAgentEndCheck(): void { |
| 712 | + if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck) |
| 713 | + pendingAgentEndCheck = null |
| 714 | + pendingAgentEndContext = null |
| 715 | + } |
| 716 | + const RUNNER_EXIT_GRACE_MS = 2000 |
| 717 | + let runnerExitCheck: ReturnType<typeof setTimeout> | null = null |
| 718 | + lifecycleState.onEvent = (event: unknown, forcedStatus?: string): void => { |
| 719 | + if (!event || typeof event !== 'object') return |
| 720 | + const record = event as { id?: unknown; runId?: unknown } |
| 721 | + const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : '' |
| 722 | + const status = forcedStatus ?? (event as { status?: unknown }).status |
| 723 | + if (!id) return |
| 724 | + if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return } |
| 725 | + if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return |
| 726 | + lifecycleState.active.delete(id) |
| 727 | + lifecycleState.exited?.delete(id) |
| 728 | + if (lifecycleState.waiting) postAgentEndOnce() |
| 729 | + } |
| 730 | + lifecycleState.onRunnerExit = (event: unknown): void => { |
| 731 | + const runId = event && typeof event === 'object' ? (event as { runId?: unknown }).runId : undefined |
| 732 | + if (typeof runId !== 'string' || !lifecycleState.active.has(runId)) return |
| 733 | + if (!lifecycleState.exited) lifecycleState.exited = new Set<string>() |
| 734 | + lifecycleState.exited.add(runId) |
| 735 | + if (!lifecycleState.waiting) return |
| 736 | + if (runnerExitCheck !== null) clearTimeout(runnerExitCheck) |
| 737 | + runnerExitCheck = setTimeout(() => { |
| 738 | + runnerExitCheck = null |
| 739 | + if (lifecycleState.waiting) postAgentEndOnce() |
| 740 | + }, RUNNER_EXIT_GRACE_MS) |
| 741 | + if (typeof runnerExitCheck.unref === 'function') runnerExitCheck.unref() |
| 742 | + } |
| 743 | + function postAgentEndOnce(): void { |
| 744 | + for (const id of lifecycleState.exited ?? []) lifecycleState.active.delete(id) |
| 745 | + lifecycleState.exited?.clear() |
| 746 | + if (lifecycleState.active.size > 0) { |
| 747 | + lifecycleState.waiting = true |
| 748 | + return |
| 749 | + } |
| 750 | + lifecycleState.waiting = false |
| 751 | + if (completionPostedGeneration === endedRunGeneration) return |
| 752 | + completionPostedGeneration = endedRunGeneration |
| 753 | + piTurnInFlight = false |
| 754 | + post('agent_end') |
| 755 | + } |
| 756 | + |
| 757 | + function checkPendingAgentEnd(): void { |
| 758 | + pendingAgentEndCheck = null |
| 759 | + const ctx = pendingAgentEndContext |
| 760 | + if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) { |
| 761 | + pendingAgentEndContext = null |
| 762 | + return |
| 763 | + } |
| 764 | + try { |
| 765 | + if (ctx.isIdle()) { |
| 766 | + pendingAgentEndContext = null |
| 767 | + postAgentEndOnce() |
| 768 | + return |
| 769 | + } |
| 770 | + } catch { |
| 771 | + pendingAgentEndContext = null |
| 772 | + return |
| 773 | + } |
| 774 | + pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs) |
| 775 | + if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref() |
| 776 | + agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS) |
| 777 | + } |
| 778 | + |
| 779 | + onStatus('agent_settled', (_event, ctx) => { |
| 780 | + updateRuntimeOmpSessionMetadata(ctx) |
| 781 | + agentSettledSupported = true |
| 782 | + clearPendingAgentEndCheck() |
| 783 | + postAgentEndOnce() |
| 784 | + }) |
| 785 | + |
| 786 | + onStatus('agent_end', (event, ctx) => { |
| 787 | + updateRuntimeOmpSessionMetadata(ctx) |
| 788 | + if (event?.willContinue === true) { |
| 789 | + clearPendingAgentEndCheck() |
| 790 | + return |
| 791 | + } |
| 792 | + endedRunGeneration = runGeneration |
| 793 | + if (isOmpRuntime()) { |
| 794 | + postAgentEndOnce() |
| 795 | + return |
| 796 | + } |
| 797 | + if (agentSettledSupported) return |
| 798 | + if (!ctx || typeof ctx.isIdle !== 'function') { |
| 799 | + postAgentEndOnce() |
| 800 | + return |
| 801 | + } |
| 802 | + clearPendingAgentEndCheck() |
| 803 | + agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS |
| 804 | + pendingAgentEndContext = ctx |
| 805 | + pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0) |
| 806 | + if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref() |
| 807 | + }) |
| 808 | + } |