diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index 3124f99..de3cd87 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -30,6 +30,7 @@ import { type TerminalMessageStatus, } from './finalize-message.js'; import { shouldFailOnMissingAgent } from './flow-runner-decisions.js'; +import { emitHook } from '../plugins/host.js'; interface InferenceRunner { enqueue: ( @@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): { publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason); } + // EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing + // is silently swallowed so it never blocks the dispatch flow. + function emitTurnEnd( + sessionId: string, + taskId: string, + state: string, + agent?: string | null, + model?: string | null, + outputSummary?: string, + ): void { + void emitHook('turn.end', { + sessionId, + turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary }, + }); + } + // F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal // state and publish the matching message_complete frame. Best-effort + idempotent // (the helper's `WHERE status='streaming'` guard) — a failure here must never mask @@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): { // Declared before try so the catch block can write it back on the task row. let chatId: string | null = null; + let sessionId: string | undefined; try { // Mark running @@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): { // Session setup: reuse a pre-created session (e.g. Q&A arena contestants // whose persona is stamped on the session via agent_id) or create a fresh one. const model = task.model ?? config.DEFAULT_MODEL; - let sessionId: string; if (task.session_id) { sessionId = task.session_id; } else { @@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): { SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId} `; + if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model); return; } @@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): { WHERE id = ${taskId} `; log.info({ taskId, costTokens }, 'dispatcher: task completed (native)'); + emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary); } else { const [msg] = await sql<{ content: string | null }[]>` SELECT content FROM messages WHERE id = ${assistantId} @@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): { WHERE id = ${taskId} `; log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)'); + emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): { SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId} WHERE id = ${taskId} `.catch(() => {}); + if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg); } } @@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); + emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model); await cleanupWorktree(projectPath, taskId); clearTaskCommands(taskId); return; @@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): { log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)'); // #10: external-agent turn completed cleanly. emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete'); + emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary); clearTaskCommands(taskId); } catch (err) { @@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): { // preceded its assignment — guard so the status publish never masks the real // error. if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed'); + if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg); // Best-effort cleanup await cleanupWorktree(projectPath, taskId); @@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); + emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model); clearTaskCommands(taskId); return; // worktree persists (no cleanup); backend stays warm } @@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): { result.ok ? 'idle' : 'error', result.ok ? 'turn_complete' : 'failed', ); + emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary); clearTaskCommands(taskId); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); + if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); + emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model); clearTaskCommands(taskId); return; // worktree persists (no cleanup); backend stays warm } @@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): { result.ok ? 'idle' : 'error', result.ok ? 'turn_complete' : 'failed', ); + emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary); clearTaskCommands(taskId); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); + emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); + emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model); clearTaskCommands(taskId); return; // worktree persists (no cleanup); backend stays warm } @@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): { result.ok ? 'idle' : 'error', result.ok ? 'turn_complete' : 'failed', ); + emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary); clearTaskCommands(taskId); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): { await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); + emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } diff --git a/apps/coder/src/services/frame-emitter.ts b/apps/coder/src/services/frame-emitter.ts index 5cf74d8..9a454ad 100644 --- a/apps/coder/src/services/frame-emitter.ts +++ b/apps/coder/src/services/frame-emitter.ts @@ -19,9 +19,10 @@ import type { Broker } from '@boocode/server/broker'; import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { AgentEvent } from './agent-backend.js'; -import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js'; +import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import type { DcpStreamStripper } from './dcp-strip.js'; +import { emitHook } from '../plugins/host.js'; export interface FrameEmitterOpts { broker?: Broker; @@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter { } break; case 'tool_call': + toolSnapshots.set(e.toolCall.toolCallId, e.toolCall); + if (canStream()) { + broker!.publishFrame(sessionId!, { + type: 'tool_call', + message_id: assistantId!, + chat_id: chatId!, + tool_call: snapshotToWireToolCall(e.toolCall), + } as WsFrame); + } + break; case 'tool_update': toolSnapshots.set(e.toolCall.toolCallId, e.toolCall); + { + const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput); + if (lifecycle === 'completed' || lifecycle === 'failed') { + void emitHook('tool.execute.after', { + toolName: e.toolCall.title, + args: e.toolCall.rawInput, + result: e.toolCall.rawOutput, + duration: undefined, + }); + } + } if (canStream()) { broker!.publishFrame(sessionId!, { type: 'tool_call', diff --git a/apps/coder/src/services/hashline/constants.ts b/apps/coder/src/services/hashline/constants.ts new file mode 100644 index 0000000..2eac4d9 --- /dev/null +++ b/apps/coder/src/services/hashline/constants.ts @@ -0,0 +1,10 @@ +export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH" + +export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => { + const high = i >>> 4 + const low = i & 0x0f + return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}` +}) + +export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/ +export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/ diff --git a/apps/coder/src/services/hashline/hash-computation.ts b/apps/coder/src/services/hashline/hash-computation.ts new file mode 100644 index 0000000..8f0fc8e --- /dev/null +++ b/apps/coder/src/services/hashline/hash-computation.ts @@ -0,0 +1,31 @@ +import { HASHLINE_DICT } from "./constants.js" +import { hashXxh32 } from "./xxhash32.js" + +const RE_SIGNIFICANT = /[\p{L}\p{N}]/u + +function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string { + const stripped = normalizedContent + const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber + const hash = hashXxh32(stripped, seed) + const index = hash % 256 + return HASHLINE_DICT[index]! +} + +export function computeLineHash(lineNumber: number, content: string): string { + return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd()) +} + +export function computeLegacyLineHash(lineNumber: number, content: string): string { + return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, "")) +} + +export function formatHashLine(lineNumber: number, content: string): string { + const hash = computeLineHash(lineNumber, content) + return `${lineNumber}#${hash}|${content}` +} + +export function formatHashLines(content: string): string { + if (!content) return "" + const lines = content.split("\n") + return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n") +} diff --git a/apps/coder/src/services/hashline/index.ts b/apps/coder/src/services/hashline/index.ts new file mode 100644 index 0000000..8e709eb --- /dev/null +++ b/apps/coder/src/services/hashline/index.ts @@ -0,0 +1,11 @@ +/** + * Hashline editing core — content-hash anchors for edit_file stale-patch detection. + * + * Ported from oh-my-openagent/packages/hashline-core/. + * Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback). + */ +export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js" +export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js" +export type { LineRef } from "./validation.js" +export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js" +export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js" diff --git a/apps/coder/src/services/hashline/types.ts b/apps/coder/src/services/hashline/types.ts new file mode 100644 index 0000000..e0fc848 --- /dev/null +++ b/apps/coder/src/services/hashline/types.ts @@ -0,0 +1,20 @@ +export interface ReplaceEdit { + op: "replace" + pos: string + end?: string + lines: string | string[] +} + +export interface AppendEdit { + op: "append" + pos?: string + lines: string | string[] +} + +export interface PrependEdit { + op: "prepend" + pos?: string + lines: string | string[] +} + +export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit diff --git a/apps/coder/src/services/hashline/validation.ts b/apps/coder/src/services/hashline/validation.ts new file mode 100644 index 0000000..de4ce58 --- /dev/null +++ b/apps/coder/src/services/hashline/validation.ts @@ -0,0 +1,192 @@ +import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js" +import { HASHLINE_REF_PATTERN } from "./constants.js" + +export interface LineRef { + line: number + hash: string +} + +interface HashMismatch { + line: number + expected: string +} + +const MISMATCH_CONTEXT = 2 + +const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/ + +function isCompatibleLineHash(line: number, content: string, hash: string): boolean { + return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash +} + +export function normalizeLineRef(ref: string): string { + const originalTrimmed = ref.trim() + let trimmed = originalTrimmed + trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "") + trimmed = trimmed.replace(/\s*#\s*/, "#") + trimmed = trimmed.replace(/\|.*$/, "") + trimmed = trimmed.trim() + + if (HASHLINE_REF_PATTERN.test(trimmed)) { + return trimmed + } + + const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN) + if (extracted) { + return extracted[1]! + } + + return originalTrimmed +} + +export function parseLineRef(ref: string): LineRef { + const normalized = normalizeLineRef(ref) + const match = normalized.match(HASHLINE_REF_PATTERN) + if (match) { + return { + line: Number.parseInt(match[1]!, 10), + hash: match[2]!, + } + } + const hashIdx = normalized.indexOf('#') + if (hashIdx > 0) { + const prefix = normalized.slice(0, hashIdx) + const suffix = normalized.slice(hashIdx + 1) + if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) { + throw new Error( + `Invalid line reference: "${ref}". "${prefix}" is not a line number. ` + + `Use the actual line number from the read output.` + ) + } + } + throw new Error( + `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"` + ) +} + +export function validateLineRef(lines: string[], ref: string): void { + const { line, hash } = parseLineRefWithHint(ref, lines) + + if (line < 1 || line > lines.length) { + throw new Error( + `Line number ${line} out of bounds. File has ${lines.length} lines.` + ) + } + + const content = lines[line - 1] + if (content === undefined) { + throw new Error( + `Line number ${line} out of bounds. File has ${lines.length} lines.` + ) + } + if (!isCompatibleLineHash(line, content, hash)) { + throw new HashlineMismatchError([{ line, expected: hash }], lines) + } +} + +export class HashlineMismatchError extends Error { + readonly remaps: ReadonlyMap + + constructor( + private readonly mismatches: HashMismatch[], + private readonly fileLines: string[] + ) { + super(HashlineMismatchError.formatMessage(mismatches, fileLines)) + this.name = "HashlineMismatchError" + const remaps = new Map() + for (const mismatch of mismatches) { + const content = fileLines[mismatch.line - 1] + const actualLine = content ?? "" + const actual = computeLineHash(mismatch.line, actualLine) + remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`) + } + this.remaps = remaps + } + + static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { + const mismatchByLine = new Map() + for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch) + + const displayLines = new Set() + for (const mismatch of mismatches) { + const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT) + const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT) + for (let line = low; line <= high; line++) displayLines.add(line) + } + + const sortedLines = [...displayLines].sort((a, b) => a - b) + const output: string[] = [] + output.push( + `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` + + "Use updated {line_number}#{hash_id} references below (>>> marks changed lines)." + ) + output.push("") + + let previousLine = -1 + for (const line of sortedLines) { + if (previousLine !== -1 && line > previousLine + 1) { + output.push(" ...") + } + previousLine = line + + const content = fileLines[line - 1] ?? "" + const hash = computeLineHash(line, content) + const prefix = `${line}#${hash}|${content}` + if (mismatchByLine.has(line)) { + output.push(`>>> ${prefix}`) + } else { + output.push(` ${prefix}`) + } + } + + return output.join("\n") + } +} + +function suggestLineForHash(ref: string, lines: string[]): string | null { + const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/) + if (!hashMatch) return null + const hash = hashMatch[1]! + for (let i = 0; i < lines.length; i++) { + if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) { + return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?` + } + } + return null +} + +function parseLineRefWithHint(ref: string, lines: string[]): LineRef { + try { + return parseLineRef(ref) + } catch (parseError) { + const hint = suggestLineForHash(ref, lines) + if (hint && parseError instanceof Error) { + throw new Error(`${parseError.message} ${hint}`) + } + throw parseError + } +} + +export function validateLineRefs(lines: string[], refs: string[]): void { + const mismatches: HashMismatch[] = [] + + for (const ref of refs) { + const { line, hash } = parseLineRefWithHint(ref, lines) + + if (line < 1 || line > lines.length) { + throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) + } + + const content = lines[line - 1] + if (content === undefined) { + throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) + } + if (!isCompatibleLineHash(line, content, hash)) { + mismatches.push({ line, expected: hash }) + } + } + + if (mismatches.length > 0) { + throw new HashlineMismatchError(mismatches, lines) + } +} diff --git a/apps/coder/src/services/hashline/xxhash32.ts b/apps/coder/src/services/hashline/xxhash32.ts new file mode 100644 index 0000000..26603f9 --- /dev/null +++ b/apps/coder/src/services/hashline/xxhash32.ts @@ -0,0 +1,90 @@ +type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } } + +const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime } +const encoder = new TextEncoder() + +const PRIME32_1 = 0x9e3779b1 +const PRIME32_2 = 0x85ebca77 +const PRIME32_3 = 0xc2b2ae3d +const PRIME32_4 = 0x27d4eb2f +const PRIME32_5 = 0x165667b1 + +function rotateLeft32(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0 +} + +function readUint32LittleEndian(input: Uint8Array, offset: number): number { + return ( + ((input[offset] ?? 0) | + ((input[offset + 1] ?? 0) << 8) | + ((input[offset + 2] ?? 0) << 16) | + ((input[offset + 3] ?? 0) << 24)) >>> + 0 + ) +} + +function round32(accumulator: number, value: number): number { + const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0 + return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0 +} + +function xxHash32Js(input: Uint8Array, seed: number): number { + let offset = 0 + const length = input.length + let hash: number + + if (length >= 16) { + const limit = length - 16 + let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0 + let value2 = (seed + PRIME32_2) >>> 0 + let value3 = seed >>> 0 + let value4 = (seed - PRIME32_1) >>> 0 + + while (offset <= limit) { + value1 = round32(value1, readUint32LittleEndian(input, offset)) + offset += 4 + value2 = round32(value2, readUint32LittleEndian(input, offset)) + offset += 4 + value3 = round32(value3, readUint32LittleEndian(input, offset)) + offset += 4 + value4 = round32(value4, readUint32LittleEndian(input, offset)) + offset += 4 + } + + hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0 + hash = (hash + rotateLeft32(value3, 12)) >>> 0 + hash = (hash + rotateLeft32(value4, 18)) >>> 0 + } else { + hash = (seed + PRIME32_5) >>> 0 + } + + hash = (hash + length) >>> 0 + + while (offset + 4 <= length) { + hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0 + offset += 4 + } + + while (offset < length) { + hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0 + offset += 1 + } + + hash = (hash ^ (hash >>> 15)) >>> 0 + hash = Math.imul(hash, PRIME32_2) >>> 0 + hash = (hash ^ (hash >>> 13)) >>> 0 + hash = Math.imul(hash, PRIME32_3) >>> 0 + + return (hash ^ (hash >>> 16)) >>> 0 +} + +export function hashXxh32(input: string, seed: number): number { + const bun = runtime.Bun + if (bun !== undefined) { + return bun.hash.xxHash32(input, seed) + } + + return xxHash32Js(encoder.encode(input), seed >>> 0) +}