From f13d3b356e4a5dda4585fd073e4fe3a470c6b373 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 17 Jun 2026 07:33:40 -0600 Subject: [PATCH 001/105] Fix agents quickstart runner registration --- .changeset/quickstart-runner-principal.md | 8 + package.json | 2 +- packages/agents-runtime/src/create-handler.ts | 30 +- .../agents-runtime/src/entity-timeline.ts | 77 +++- .../test/create-handler.test.ts | 17 + .../test/entity-timeline.test.ts | 140 ++++++ .../settings/pages/CredentialsPage.tsx | 4 +- packages/agents/src/server.ts | 32 +- .../builtin-pull-wake-registration.test.ts | 12 +- packages/electric-ax/package.json | 4 +- packages/electric-ax/src/entity-stream-db.ts | 11 +- packages/electric-ax/src/index.ts | 14 +- packages/electric-ax/src/observe-ui.tsx | 412 +++++++++++++++--- packages/electric-ax/src/start.ts | 40 +- packages/electric-ax/src/version.ts | 9 +- packages/electric-ax/test/cli.test.ts | 6 +- .../electric-ax/test/entity-stream-db.test.ts | 66 +++ packages/electric-ax/test/observe-ui.test.tsx | 67 +++ packages/electric-ax/test/start.test.ts | 43 +- packages/electric-ax/tsdown.config.ts | 25 +- packages/electric-ax/tsdown.desktop.config.ts | 25 +- pnpm-lock.yaml | 22 +- 22 files changed, 916 insertions(+), 150 deletions(-) create mode 100644 .changeset/quickstart-runner-principal.md create mode 100644 packages/electric-ax/test/entity-stream-db.test.ts diff --git a/.changeset/quickstart-runner-principal.md b/.changeset/quickstart-runner-principal.md new file mode 100644 index 0000000000..cbe7e23d19 --- /dev/null +++ b/.changeset/quickstart-runner-principal.md @@ -0,0 +1,8 @@ +--- +"electric-ax": patch +"@electric-ax/agents": patch +"@electric-ax/agents-runtime": patch +"@electric-ax/agents-server-ui": patch +--- + +Fix Electric Agents quickstart startup by authenticating the built-in pull-wake runner with the same principal it registers as, registering built-in agent types with the local runner as their default dispatch target, and aligning the CLI's default principal with the local quickstart user. Pin the CLI-launched agents-server Docker image to the matching released agents-server version, improve registration fetch errors so startup failures include the endpoint and underlying cause, avoid the CLI observe live-query Collection boundary, and clarify browser-only credentials settings copy. diff --git a/package.json b/package.json index 21995cbcf6..f60cbbcff1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "packageManager": "pnpm@10.12.1", "private": true, "scripts": { - "ci:publish": "pnpm '/^ci:publish:.+/' && pnpm exec changeset tag", + "ci:publish": "pnpm run -r --filter './packages/**' build && pnpm '/^ci:publish:.+/' && pnpm exec changeset tag", "ci:publish:hex-electric": "pnpm run --dir packages/sync-service publish:hex", "ci:publish:hex-electric-client": "pnpm run --dir packages/elixir-client publish:hex", "ci:publish:npm": "pnpm publish -r --filter './packages/**' --no-git-checks --access public", diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index 1aab7893ed..cff0ae937b 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -561,14 +561,23 @@ export function createRuntimeRouter( } } - const typeRes = await fetch( - appendPathToUrl(baseUrl, `/_electric/entity-types`), - { + const registrationUrl = appendPathToUrl( + baseUrl, + `/_electric/entity-types` + ) + let typeRes: Response + try { + typeRes = await fetch(registrationUrl, { method: `POST`, headers: await registrationHeaders(), body: JSON.stringify(body), - } - ) + }) + } catch (error) { + const message = `Failed to register type "${name}" at ${registrationUrl}: ${formatFetchError(error)}` + runtimeLog.error(`[agent-runtime]`, message) + failed.push(`${name} (${message})`) + return + } if (!typeRes.ok) { const err = await typeRes.text() @@ -750,6 +759,17 @@ function json(body: Record, status: number): Response { }) } +function formatFetchError(error: unknown): string { + if (error instanceof Error) { + const cause = + error.cause instanceof Error + ? `: ${error.cause.message || error.cause.name}` + : `` + return `${error.message || error.name || `Unknown error`}${cause}` + } + return String(error) || `Unknown error` +} + async function toFetchRequest(req: IncomingMessage): Promise { const body = await readBody(req) const host = diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 171c85338f..4f55881a5e 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -10,12 +10,13 @@ import { isNull, isUndefined, like, + min, localOnlyCollectionOptions, or, sum, toArray, } from '@durable-streams/state/db' -import { caseWhen } from '@tanstack/db' +import { BasicIndex, caseWhen } from '@tanstack/db' import type { Collection, InitialQueryBuilder, @@ -1316,6 +1317,47 @@ const getEntitySignalsCollection = cachedCollectionFactory( type EntityTimelineQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder +const indexedTimelineDbs = new WeakSet() + +type IndexableCollection = { + createIndex: ( + index: (row: any) => unknown, + config: { indexType: typeof BasicIndex } + ) => void +} + +function hasCreateIndex(value: unknown): value is IndexableCollection { + return ( + !!value && + typeof (value as { createIndex?: unknown }).createIndex === `function` + ) +} + +function createIndexIfAvailable( + collection: unknown, + index: (row: any) => unknown +): void { + if (hasCreateIndex(collection)) { + collection.createIndex(index, { indexType: BasicIndex }) + } +} + +export function ensureEntityTimelineIndexes(db: EntityStreamDB): void { + if (indexedTimelineDbs.has(db as object)) return + indexedTimelineDbs.add(db as object) + + createIndexIfAvailable(db.collections.texts, (row) => row.run_id) + createIndexIfAvailable(db.collections.textDeltas, (row) => row.text_id) + createIndexIfAvailable(db.collections.toolCalls, (row) => row.run_id) + createIndexIfAvailable(db.collections.reasoning, (row) => row.run_id) + createIndexIfAvailable( + db.collections.reasoningDeltas, + (row) => row.reasoning_id + ) + createIndexIfAvailable(db.collections.steps, (row) => row.run_id) + createIndexIfAvailable(db.collections.errors, (row) => row.run_id) +} + /** * Builds a live timeline query for an entity stream. * @@ -1339,6 +1381,7 @@ export function createEntityTimelineQuery( db: EntityStreamDB, opts: EntityTimelineQueryOptions = {} ): EntityTimelineQueryBuilder { + ensureEntityTimelineIndexes(db) return (q: InitialQueryBuilder) => buildEntityTimelineQuery(q, db, opts) } @@ -1423,6 +1466,14 @@ function buildEntityTimelineQuery( run_id: error.run_id, })) + const textFirstDeltaSource = q + .from({ textDelta: db.collections.textDeltas }) + .groupBy(({ textDelta }) => textDelta.text_id) + .select(({ textDelta }) => ({ + text_id: textDelta.text_id, + order: min(coalesce(textDelta._seq, -1)), + })) + // Union texts + tool calls into a single ordered stream. The // text-delta join lives at this level (vs. inside the consumer's // `items.select`) so the correlation key is `text.key` — a field @@ -1435,13 +1486,16 @@ function buildEntityTimelineQuery( text: db.collections.texts, toolCall: db.collections.toolCalls, }) - .select(({ text, toolCall }) => ({ - order: coalesce(text._timeline_order, toolCall._timeline_order, `~`), + .leftJoin({ firstDelta: textFirstDeltaSource }, ({ text, firstDelta }) => + eq(text.key, firstDelta.text_id) + ) + .select(({ text, toolCall, firstDelta }) => ({ + order: coalesce(firstDelta.order, text._seq, toolCall._seq, -1), run_id: coalesce(text.run_id, toolCall.run_id, ``), text: caseWhen(text.key, { key: text.key, run_id: text.run_id, - order: coalesce(text._timeline_order, `~`), + order: coalesce(firstDelta.order, text._seq, -1), status: text.status, }), textContent: concat( @@ -1449,17 +1503,14 @@ function buildEntityTimelineQuery( q .from({ textChunk: db.collections.textDeltas }) .where(({ textChunk }) => eq(textChunk.text_id, text.key)) - .orderBy(({ textChunk }) => - coalesce(textChunk._timeline_order, `~`) - ) - .orderBy(({ textChunk }) => textChunk.key) + .orderBy(({ textChunk }) => coalesce(textChunk._seq, -1)) .select(({ textChunk }) => textChunk.delta) ) ), toolCall: caseWhen(toolCall.key, { key: toolCall.key, run_id: toolCall.run_id, - order: coalesce(toolCall._timeline_order, `~`), + order: coalesce(toolCall._seq, -1), tool_call_id: toolCall.tool_call_id, tool_name: toolCall.tool_name, status: toolCall.status, @@ -1486,7 +1537,7 @@ function buildEntityTimelineQuery( .select(({ reasoning }) => ({ key: reasoning.key, run_id: reasoning.run_id, - order: coalesce(reasoning._timeline_order, `~`), + order: coalesce(reasoning._seq, -1), status: reasoning.status, summary_title: reasoning.summary_title, encrypted: reasoning.encrypted, @@ -1497,10 +1548,7 @@ function buildEntityTimelineQuery( .where(({ reasoningChunk }) => eq(reasoningChunk.reasoning_id, reasoning.key) ) - .orderBy(({ reasoningChunk }) => - coalesce(reasoningChunk._timeline_order, `~`) - ) - .orderBy(({ reasoningChunk }) => reasoningChunk.key) + .orderBy(({ reasoningChunk }) => coalesce(reasoningChunk._seq, -1)) .select(({ reasoningChunk }) => reasoningChunk.delta) ) ), @@ -1667,6 +1715,7 @@ type EntityQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder export function createEntityIncludesQuery( db: EntityStreamDB ): EntityQueryBuilder { + ensureEntityTimelineIndexes(db) const seedCollection = getTimelineSeedCollection(db) const runsCollection = getEntityRunsCollection(db) const inboxCollection = getEntityInboxCollection(db) diff --git a/packages/agents-runtime/test/create-handler.test.ts b/packages/agents-runtime/test/create-handler.test.ts index 90ab5fac42..9c10a34e7b 100644 --- a/packages/agents-runtime/test/create-handler.test.ts +++ b/packages/agents-runtime/test/create-handler.test.ts @@ -648,6 +648,23 @@ describe(`createRuntimeHandler`, () => { ) }) + it(`registerTypes includes URL and cause when registration fetch throws`, async () => { + defineEntity(`network-agent`, { handler: async () => {} }) + + vi.spyOn(globalThis, `fetch`).mockRejectedValue( + new Error(`fetch failed`, { cause: new Error(`ECONNREFUSED 127.0.0.1`) }) + ) + + const handler = createRuntimeHandler({ + baseUrl: `http://localhost:3000`, + handlerUrl: `http://localhost:4000/electric-agents`, + }) + + await expect(handler.registerTypes()).rejects.toThrow( + `Failed to register type "network-agent" at http://localhost:3000/_electric/entity-types: fetch failed: ECONNREFUSED 127.0.0.1` + ) + }) + it(`registers entity types with a webhook default dispatch policy`, async () => { defineEntity(`schema-agent`, { description: `Schema agent`, diff --git a/packages/agents-runtime/test/entity-timeline.test.ts b/packages/agents-runtime/test/entity-timeline.test.ts index d84696524c..994e9eea1b 100644 --- a/packages/agents-runtime/test/entity-timeline.test.ts +++ b/packages/agents-runtime/test/entity-timeline.test.ts @@ -1835,6 +1835,61 @@ describe(`entity includes query`, () => { }) }) + it(`interleaves top-level live timeline rows by runtime timeline order`, async () => { + const { collections, sync } = createEntityCollections() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.inbox.insert({ + key: `msg-1`, + _timeline_order: order(1), + from: `user`, + payload: `first`, + timestamp: `2026-04-15T18:00:00.000Z`, + status: `processed`, + }) + sync.runs.insert({ + key: `run-1`, + _timeline_order: order(2), + status: `started`, + }) + sync.texts.insert({ + key: `text-1`, + _timeline_order: order(3), + run_id: `run-1`, + status: `streaming`, + }) + sync.textDeltas.insert({ + key: `td-1`, + text_id: `text-1`, + run_id: `run-1`, + delta: `First response`, + }) + sync.inbox.insert({ + key: `msg-2`, + _timeline_order: order(4), + from: `user`, + payload: `second`, + timestamp: `2026-04-15T18:01:00.000Z`, + status: `processed`, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = getData(liveQuery) + expect( + rows.map((row) => + row.inbox + ? `inbox:${row.inbox.key}` + : row.run + ? `run:${row.run.key}` + : `other:${row.$key}` + ) + ).toEqual([`inbox:msg-1`, `run:run-1`, `inbox:msg-2`]) + }) + it(`reacts to toolCall updates`, async () => { const { collections, sync } = createEntityCollections() const queryFn = createEntityIncludesQuery({ collections } as any) @@ -2497,6 +2552,91 @@ describe(`entity includes query`, () => { expect(item.text?.content).toBe(`Hello world`) }) + it(`live items.text.content orders deltas by stream sequence, not key`, async () => { + // Production regression: delta keys like `msg-0:10` sort before + // `msg-0:2` lexicographically. The live query must use stream + // sequence order so streamed text stays in model output order. + const { collections, sync } = createTimelineCollections() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.runs.insert({ key: `run-0`, status: `started` }) + sync.texts.insert({ + key: `msg-0`, + run_id: `run-0`, + status: `streaming`, + }) + for (let i = 0; i <= 10; i++) { + sync.textDeltas.insert({ + key: `msg-0:${i}`, + text_id: `msg-0`, + run_id: `run-0`, + delta: `[${i}]`, + }) + } + sync.texts.update({ + key: `msg-0`, + run_id: `run-0`, + status: `completed`, + }) + sync.runs.update({ + key: `run-0`, + status: `completed`, + finish_reason: `stop`, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = getRows(liveQuery) + const runRow = rows.find((r) => r.run?.key === `run-0`) + expect(runRow).toBeTruthy() + const items = Array.from(runRow.run.items.toArray) as Array + expect(items).toHaveLength(1) + expect(items[0].text?.content).toBe(`[0][1][2][3][4][5][6][7][8][9][10]`) + }) + + it(`orders live run text items by first delta sequence, not text row creation`, async () => { + const { collections, sync } = createTimelineCollections() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.runs.insert({ key: `run-0`, status: `started` }) + sync.texts.insert({ + key: `msg-0`, + run_id: `run-0`, + status: `streaming`, + }) + sync.toolCalls.insert({ + key: `tc-0`, + run_id: `run-0`, + tool_call_id: `tc-0`, + tool_name: `bash`, + status: `completed`, + result: { content: [{ type: `text`, text: `/private/tmp\n` }] }, + }) + sync.textDeltas.insert({ + key: `msg-0:0`, + text_id: `msg-0`, + run_id: `run-0`, + delta: `We're in /private/tmp.`, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = getRows(liveQuery) + const runRow = rows.find((r) => r.run?.key === `run-0`) + expect(runRow).toBeTruthy() + const items = Array.from(runRow.run.items.toArray) as Array + expect(items.map((item) => item.toolCall?.key ?? item.text?.key)).toEqual( + [`tc-0`, `msg-0`] + ) + expect(items[1]?.text?.content).toBe(`We're in /private/tmp.`) + }) + it(`reasoning content survives multiple run-row updates in sequence`, async () => { // Even closer to production: the run row gets updated MULTIPLE // times (each delta + status flip), which may invalidate the diff --git a/packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx b/packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx index 81bb573bde..d2d72ce943 100644 --- a/packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx +++ b/packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx @@ -81,13 +81,13 @@ export function CredentialsPage(): React.ReactElement { description={ isDesktop ? `Configure model providers for connected local runtimes. Changes save automatically and apply on the next runtime restart.` - : `Model providers are configured by the agents-server you're connected to. The web build inherits whatever providers the server was started with.` + : `Model providers are configured by the runtime connected to this server. Restart that runtime with provider credentials to change the available models.` } > {!isDesktop ? ( - No editable provider keys in the web build. + Provider keys are not editable from the browser UI. ) : !status ? ( diff --git a/packages/agents/src/server.ts b/packages/agents/src/server.ts index a05c2fab94..1223b7e81a 100644 --- a/packages/agents/src/server.ts +++ b/packages/agents/src/server.ts @@ -309,6 +309,9 @@ export class BuiltinAgentsServer { baseSkillsDir: this.options.baseSkillsDir, enabledModelValues: this.options.enabledModelValues, serverHeaders: pullWake.headers, + defaultDispatchPolicyForType: () => ({ + targets: [{ type: `runner`, runnerId: pullWake.runnerId }], + }), }) if (!this.bootstrap) { throw new Error( @@ -428,9 +431,13 @@ export class BuiltinAgentsServer { ) headers.set(`content-type`, `application/json`) const profiles = this.bootstrap?.runtime.sandboxProfileDescriptors ?? [] - const response = await fetch( - appendPathToUrl(this.options.agentServerUrl, `/_electric/runners`), - { + const registrationUrl = appendPathToUrl( + this.options.agentServerUrl, + `/_electric/runners` + ) + let response: Response + try { + response = await fetch(registrationUrl, { method: `POST`, headers, body: JSON.stringify({ @@ -441,8 +448,12 @@ export class BuiltinAgentsServer { admin_status: `enabled`, sandbox_profiles: profiles, }), - } - ) + }) + } catch (error) { + throw new Error( + `Failed to register pull-wake runner ${pullWake.runnerId} at ${registrationUrl}: ${formatFetchError(error)}` + ) + } if (!response.ok) { throw new Error( `Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}` @@ -451,3 +462,14 @@ export class BuiltinAgentsServer { return (await response.json()) as { wake_stream_offset?: string } } } + +function formatFetchError(error: unknown): string { + if (error instanceof Error) { + const cause = + error.cause instanceof Error + ? `: ${error.cause.message || error.cause.name}` + : `` + return `${error.message || error.name || `Unknown error`}${cause}` + } + return String(error) || `Unknown error` +} diff --git a/packages/agents/test/builtin-pull-wake-registration.test.ts b/packages/agents/test/builtin-pull-wake-registration.test.ts index 411f06cf4c..524728ccd9 100644 --- a/packages/agents/test/builtin-pull-wake-registration.test.ts +++ b/packages/agents/test/builtin-pull-wake-registration.test.ts @@ -86,7 +86,7 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { agentsServer = null }) - it(`does not store the local pull-wake runner as a type default`, async () => { + it(`stores the local pull-wake runner as the built-in type default`, async () => { agentsServer = await startRecordingAgentsServer() builtinServer = new BuiltinAgentsServer({ agentServerUrl: agentsServer.url, @@ -97,11 +97,11 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { await builtinServer.start() expect(agentsServer.entityTypeBodies.length).toBeGreaterThan(0) - expect( - agentsServer.entityTypeBodies.some( - (body) => body.default_dispatch_policy !== undefined - ) - ).toBe(false) + for (const body of agentsServer.entityTypeBodies) { + expect(body.default_dispatch_policy).toEqual({ + targets: [{ type: `runner`, runnerId: `test-runner` }], + }) + } }) it(`grants all users default built-in entity type permissions`, async () => { diff --git a/packages/electric-ax/package.json b/packages/electric-ax/package.json index f87dd3736e..ca070fb88c 100644 --- a/packages/electric-ax/package.json +++ b/packages/electric-ax/package.json @@ -51,7 +51,7 @@ "@electric-ax/agents": "workspace:*", "@electric-ax/agents-runtime": "workspace:*", "@electric-sql/client": "^1.5.21", - "@tanstack/db": "^0.6.4", + "@tanstack/db": "^0.6.6", "@tanstack/react-db": "^0.1.85", "commander": "^13.1.0", "ink": "^6.8.0", @@ -66,7 +66,7 @@ "ink-testing-library": "^4.0.0", "tsdown": "^0.9.0", "tsx": "^4.19.2", - "typescript": "^5.7.2", + "typescript": "^5.9.3", "vitest": "^4.1.0" }, "files": [ diff --git a/packages/electric-ax/src/entity-stream-db.ts b/packages/electric-ax/src/entity-stream-db.ts index 8271129d04..80dbe67b81 100644 --- a/packages/electric-ax/src/entity-stream-db.ts +++ b/packages/electric-ax/src/entity-stream-db.ts @@ -7,8 +7,10 @@ * so we feed them directly to StreamDB without transformation. */ -import { createStreamDB } from '@durable-streams/state/db' -import { appendPathToUrl, entityStateSchema } from '@electric-ax/agents-runtime' +import { + appendPathToUrl, + createEntityStreamDB as createRuntimeEntityStreamDB, +} from '@electric-ax/agents-runtime' import { entityApiUrl } from './entity-api.js' import type { EntityStreamDB } from '@electric-ax/agents-runtime' @@ -57,14 +59,11 @@ export async function createEntityStreamDB(opts: { const streamPath = getMainStreamPath(entityUrl, entity) const streamUrl = appendPathToUrl(baseUrl, streamPath) - const db = createStreamDB({ + const db = createRuntimeEntityStreamDB(streamUrl, undefined, undefined, { streamOptions: { - url: streamUrl, headers: requestHeaders, - contentType: `application/json`, ...(initialOffset ? { offset: initialOffset } : {}), }, - state: entityStateSchema, }) await db.preload() diff --git a/packages/electric-ax/src/index.ts b/packages/electric-ax/src/index.ts index 8baa16ef62..363ebcaa09 100644 --- a/packages/electric-ax/src/index.ts +++ b/packages/electric-ax/src/index.ts @@ -134,8 +134,8 @@ function getDefaultElectricAgentsIdentity(): string { return `${userInfo().username}@${hostname()}` } -function getDefaultElectricAgentsPrincipal(): string { - return `system:cli-${userInfo().username}` +function defaultElectricAgentsPrincipalForIdentity(identity: string): string { + return `user:${identity}` } function parseElectricAgentsHeaders( @@ -173,13 +173,15 @@ export function getElectricCliEnv( const explicitIdentity = env.ELECTRIC_AGENTS_IDENTITY?.trim() const explicitPrincipal = env.ELECTRIC_AGENTS_PRINCIPAL?.trim() const headers = parseElectricAgentsHeaders(env.ELECTRIC_AGENTS_SERVER_HEADERS) + const electricAgentsIdentity = + explicitIdentity || getDefaultElectricAgentsIdentity() return { electricAgentsUrl: env.ELECTRIC_AGENTS_URL || DEFAULT_ELECTRIC_AGENTS_URL, - electricAgentsIdentity: - explicitIdentity || getDefaultElectricAgentsIdentity(), + electricAgentsIdentity, electricAgentsHeaders: mergeElectricPrincipalHeader( headers, - explicitPrincipal || getDefaultElectricAgentsPrincipal() + explicitPrincipal || + defaultElectricAgentsPrincipalForIdentity(electricAgentsIdentity) ), } } @@ -829,7 +831,7 @@ function getHelpText(commandName: string): string { Environment: ELECTRIC_AGENTS_URL Base URL of the server (default: ${DEFAULT_ELECTRIC_AGENTS_URL}) ELECTRIC_AGENTS_IDENTITY Sender identity for messages (default: ${getDefaultElectricAgentsIdentity()}) - ELECTRIC_AGENTS_PRINCIPAL Principal key sent as the Electric-Principal header (default: ${getDefaultElectricAgentsPrincipal()}) + ELECTRIC_AGENTS_PRINCIPAL Principal key sent as the Electric-Principal header (default: user:) ELECTRIC_AGENTS_SERVER_HEADERS Optional JSON object of additional server headers ANTHROPIC_API_KEY Required for '${agentsCommand} start-builtin' and '${agentsCommand} quickstart' diff --git a/packages/electric-ax/src/observe-ui.tsx b/packages/electric-ax/src/observe-ui.tsx index 34d9e97196..0ad145310d 100644 --- a/packages/electric-ax/src/observe-ui.tsx +++ b/packages/electric-ax/src/observe-ui.tsx @@ -1,18 +1,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Box, Newline, Text, render, useInput } from 'ink' -import { useLiveQuery } from '@tanstack/react-db' -import { createOptimisticAction } from '@durable-streams/state/db' +import { createOptimisticAction, useLiveQuery } from '@tanstack/react-db' import { - buildSections, - createEntityIncludesQuery, - normalizeEntityTimelineData, + createEntityTimelineQuery, + createPendingTimelineOrder, } from '@electric-ax/agents-runtime' import { entityApiUrl } from './entity-api.js' import { createEntityStreamDB } from './entity-stream-db' import type { - EntityStopped, EntityTimelineContentItem, - EntityTimelineData, + EntityTimelineQueryRow, + EntityTimelineRunItem, + EntityTimelineRunRow, EntityTimelineSection, MessageReceived, } from '@electric-ax/agents-runtime' @@ -45,6 +44,221 @@ export function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max - 3) + `...` : s } +export function createMessageSendBody( + text: string, + opts?: { key?: string } +): { key?: string; payload: { text: string } } { + return { ...(opts?.key ? { key: opts.key } : {}), payload: { text } } +} + +const OPTIMISTIC_INBOX_ORDER_START = Number.MAX_SAFE_INTEGER - 1_000_000 +const SEND_TXID_TIMEOUT_MS = 10_000 + +let optimisticInboxOrderIndex = OPTIMISTIC_INBOX_ORDER_START + +function nextOptimisticInboxOrderIndex(): number { + optimisticInboxOrderIndex += 1 + if (optimisticInboxOrderIndex >= Number.MAX_SAFE_INTEGER) { + optimisticInboxOrderIndex = OPTIMISTIC_INBOX_ORDER_START + } + return optimisticInboxOrderIndex +} + +function createOptimisticInboxKey(pendingOrderIndex: number): string { + return `optimistic-${Date.now()}-${pendingOrderIndex}` +} + +async function readSendTxid(res: Response): Promise { + const data = (await res.json().catch(() => null)) as { txid?: unknown } | null + const txid = data?.txid + if (typeof txid === `string` || typeof txid === `number`) { + return String(txid) + } + throw new Error(`Send response did not include txid`) +} + +function GutterLine({ + prefix = `│ `, + color, + dimColor, + children, +}: { + prefix?: string + color?: string + dimColor?: boolean + children: string +}): React.ReactElement { + return ( + + + {prefix} + + + + {children} + + + + ) +} + +function payloadText(payload: unknown): string { + if (typeof payload === `string`) return payload + if (typeof payload === `object` && payload !== null) { + const record = payload as Record + return typeof record.text === `string` + ? record.text + : JSON.stringify(payload) + } + return String(payload ?? ``) +} + +function resultText(result: unknown): string | undefined { + if (result === undefined) return undefined + return typeof result === `string` ? result : JSON.stringify(result) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === `object` && value !== null && !Array.isArray(value) +} + +type TimelineDisplayRow = + | { kind: `section`; key: string; section: EntityTimelineSection } + | { kind: `run`; key: string; run: EntityTimelineRunRow } + +function timelineRowsToDisplayRows( + rows: Array +): Array { + let userMessageCount = 0 + return rows.flatMap((row): Array => { + if (row.inbox) { + const section: EntityTimelineSection = { + kind: `user_message`, + from: row.inbox.from, + text: payloadText(row.inbox.payload), + timestamp: Date.parse(row.inbox.timestamp || ``) || Date.now(), + isInitial: userMessageCount === 0, + } + userMessageCount++ + return [{ kind: `section`, key: row.$key, section }] + } + + if (row.wake) { + const timestamp = Date.parse(row.wake.payload.timestamp) + return [ + { + kind: `section`, + key: row.$key, + section: { + kind: `wake`, + payload: row.wake.payload, + timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(), + }, + }, + ] + } + + if (row.run) { + return [{ kind: `run`, key: row.$key, run: row.run }] + } + + if (row.error) { + return [ + { + kind: `section`, + key: row.$key, + section: { + kind: `agent_response`, + items: [], + error: row.error.error_code + ? `${row.error.error_code}: ${row.error.message}` + : row.error.message, + }, + }, + ] + } + + return [] + }) +} + +function textContent(item: { content?: unknown } | null | undefined): string { + return typeof item?.content === `string` ? item.content : `` +} + +function compareTimelineOrderValues( + left: string | number, + right: string | number +): number { + if (typeof left === `number` && typeof right === `number`) { + return left - right + } + return String(left).localeCompare(String(right)) +} + +export function runItemKind(item: EntityTimelineRunItem): `text` | `toolCall` { + return item.text ? `text` : `toolCall` +} + +export function runItemKey(item: EntityTimelineRunItem): string { + return item.text?.key ?? item.toolCall?.key ?? `` +} + +export function compareRunItems( + left: EntityTimelineRunItem, + right: EntityTimelineRunItem +): number { + const orderCompare = compareTimelineOrderValues( + left.text?.order ?? left.toolCall?.order ?? `~`, + right.text?.order ?? right.toolCall?.order ?? `~` + ) + if (orderCompare !== 0) return orderCompare + + const kindCompare = runItemKind(left).localeCompare(runItemKind(right)) + if (kindCompare !== 0) return kindCompare + + return runItemKey(left).localeCompare(runItemKey(right)) +} + +export function runItemsToContentItems( + items: Array +): Array { + const contentItems: Array = [] + for (const item of items) { + if (item.text) { + const content = textContent(item.text) + if (content.trim().length > 0) { + contentItems.push({ kind: `text`, text: content }) + } + continue + } + + if (!item.toolCall) continue + contentItems.push({ + kind: `tool_call`, + toolCallId: item.toolCall.tool_call_id ?? item.toolCall.key, + toolName: item.toolCall.tool_name, + args: isPlainObject(item.toolCall.args) ? item.toolCall.args : {}, + status: item.toolCall.status, + result: resultText(item.toolCall.result), + error: item.toolCall.error, + isError: item.toolCall.status === `failed`, + }) + } + return contentItems +} + +function runErrorsText( + errors: EntityTimelineRunRow[`errors`][`toArray`] +): string | undefined { + const messages = errors + .map((error) => + error.error_code ? `${error.error_code}: ${error.message}` : error.message + ) + .filter(Boolean) + return messages.length > 0 ? messages.join(`; `) : undefined +} + // ============================================================================ // Ink components // ============================================================================ @@ -71,9 +285,9 @@ export function UserMessageView({ {time ? {` ${time}`} : null} {text.split(`\n`).map((line, i) => ( - - {`│ ${line}`} - + + {line} + ))} ) @@ -97,9 +311,9 @@ export function AgentTextView({ {`┌ ${label ?? `assistant`}`} {lines.map((line, i) => ( - - {`│ ${line}${i === lines.length - 1 ? cursor : ``}`} - + + {`${line}${i === lines.length - 1 ? cursor : ``}`} + ))} ) @@ -158,9 +372,9 @@ export function ToolResultView({ return ( {shown.map((line, i) => ( - - {` │ ${truncate(line, 100)}`} - + + {truncate(line, 100)} + ))} {remaining > 0 ? ( {` │ ... ${remaining} more lines`} @@ -193,23 +407,33 @@ export function MessageInput({ const sendAction = useMemo( () => - createOptimisticAction<{ text: string }>({ - onMutate: ({ text }) => { + createOptimisticAction<{ + text: string + key: string + pendingOrderIndex: number + }>({ + onMutate: ({ text, key, pendingOrderIndex }) => { + const now = new Date().toISOString() db.collections.inbox.insert({ - key: `optimistic-${Date.now()}`, + key, + _timeline_order: createPendingTimelineOrder(pendingOrderIndex), from: identity, + from_principal: identity, payload: { text }, - timestamp: new Date().toISOString(), + timestamp: now, + mode: `immediate`, + status: `processed`, + processed_at: now, } as any) }, - mutationFn: async ({ text }) => { + mutationFn: async ({ text, key }) => { const res = await fetch(entityApiUrl(baseUrl, entityUrl, `/send`), { method: `POST`, headers: { 'content-type': `application/json`, ...headers, }, - body: JSON.stringify({ from: identity, payload: { text } }), + body: JSON.stringify(createMessageSendBody(text, { key })), }) if (!res.ok) { const body = await res.text().catch(() => ``) @@ -232,6 +456,10 @@ export function MessageInput({ } throw new Error(message) } + await db.utils.awaitTxId( + await readSendTxid(res), + SEND_TXID_TIMEOUT_MS + ) }, }), [db, baseUrl, entityUrl, identity, headers] @@ -244,7 +472,12 @@ export function MessageInput({ if (key.return) { if (value.trim()) { setError(null) - const tx = sendAction({ text: value.trim() }) + const pendingOrderIndex = nextOptimisticInboxOrderIndex() + const tx = sendAction({ + text: value.trim(), + key: createOptimisticInboxKey(pendingOrderIndex), + pendingOrderIndex, + }) setValue(``) tx.isPersisted.promise.catch((err: Error) => { setError(err.message) @@ -289,7 +522,7 @@ function AgentResponseView({ isStreaming: boolean }): React.ReactElement { return ( - + {section.items.map((item, i) => { if (item.kind === `text`) { return ( @@ -323,6 +556,60 @@ function AgentResponseView({ ) } +function AgentRunView({ + run, + label, + isStreaming, +}: { + run: EntityTimelineRunRow + label: string + isStreaming: boolean +}): React.ReactElement { + const { data: items = [] } = useLiveQuery( + (q) => (run.items ? q.from({ item: run.items }) : undefined), + [run.items] + ) + const { data: errors = [] } = useLiveQuery( + (q) => (run.errors ? q.from({ error: run.errors }) : undefined), + [run.errors] + ) + + const sortedItems = useMemo( + () => [...(items as Array)].sort(compareRunItems), + [items] + ) + const runErrors = useMemo( + () => runErrorsText(errors as EntityTimelineRunRow[`errors`][`toArray`]), + [errors] + ) + const finishReason = + run.status === `failed` && run.finish_reason + ? `finish_reason=${run.finish_reason}` + : undefined + const section = useMemo< + Extract + >( + () => ({ + kind: `agent_response`, + items: runItemsToContentItems(sortedItems), + ...(run.status === `completed` && { done: true as const }), + ...(runErrors || finishReason + ? { error: runErrors || finishReason } + : {}), + ...(run.tokens && { tokens: run.tokens }), + }), + [finishReason, run.status, run.tokens, runErrors, sortedItems] + ) + + return ( + + ) +} + function wakeReason( section: Extract ): string { @@ -390,70 +677,69 @@ function ObserveView({ identity: string headers?: Record }): React.ReactElement { - const timelineQuery = useMemo( - () => createEntityIncludesQuery(db as any), - [db] - ) - + const timelineQuery = useMemo(() => createEntityTimelineQuery(db), [db]) const { data: timelineRows = [] } = useLiveQuery(timelineQuery as any, [ timelineQuery, ]) - const timelineData = normalizeEntityTimelineData( - (timelineRows as Array)[0] ?? { - runs: [], - inbox: [], - wakes: [], - signals: [], - contextInserted: [], - contextRemoved: [], - entities: [], - } - ) - - const typedRuns = timelineData.runs - const typedInbox = timelineData.inbox - const typedWakes = timelineData.wakes - - const timeline = useMemo( - () => buildSections(typedRuns, typedInbox, typedWakes), - [typedRuns, typedInbox, typedWakes] - ) - - const { data: stopped = [] } = useLiveQuery( - (q: any) => q.from({ entityStopped: db.collections.entityStopped as any }), - [db] + const displayRows = useMemo( + () => + timelineRowsToDisplayRows(timelineRows as Array), + [timelineRows] ) - const closed = (stopped as Array).length > 0 + const closed = db.collections.entityStopped.toArray.length > 0 const lastAgentIndex = useMemo(() => { - for (let i = timeline.length - 1; i >= 0; i--) { - if (timeline[i]!.kind === `agent_response`) return i + for (let i = displayRows.length - 1; i >= 0; i--) { + const row = displayRows[i]! + if ( + row.kind === `run` || + (row.kind === `section` && row.section.kind === `agent_response`) + ) { + return i + } } return -1 - }, [timeline]) + }, [displayRows]) return ( - + {`Observing ${entityUrl}${closed ? `` : ` (Ctrl+C to stop)`}`} - {timeline.length === 0 ? ( + {displayRows.length === 0 ? ( Waiting for events... ) : null} - {timeline.map((section, i) => { + {displayRows.map((row, i) => { + if (row.kind === `run`) { + return ( + + ) + } + + const { section } = row if (section.kind === `user_message`) { return ( + return } return ( 0 && PRINCIPAL_KEY_PREFIXES.has(identity.slice(0, colon))) { + return identity + } + return `user:${identity}` +} + +function defaultPullWakeOwnerPrincipal(): string { + return principalUrlFromConfig( + principalKeyFromIdentity(`${userInfo().username}@${hostname()}`) + ) +} + export function resolvePullWakeRunnerId( env: NodeJS.ProcessEnv = process.env, fileEnv: Record = readDotEnvFile() @@ -143,8 +158,9 @@ export function resolvePullWakeOwnerPrincipal( const principal = readConfigValue(env, fileEnv, [`ELECTRIC_AGENTS_PRINCIPAL`]) if (principal) return principalUrlFromConfig(principal) const identity = readConfigValue(env, fileEnv, [`ELECTRIC_AGENTS_IDENTITY`]) - if (identity) return principalUrlFromConfig(identity) - return DEFAULT_PULL_WAKE_OWNER_PRINCIPAL + if (identity) + return principalUrlFromConfig(principalKeyFromIdentity(identity)) + return defaultPullWakeOwnerPrincipal() } function parseAdditionalServerHeaders( @@ -189,6 +205,17 @@ function resolveServerHeaders( ) } +export function resolveBuiltinServerHeaders( + env: NodeJS.ProcessEnv, + fileEnv: Record, + ownerPrincipal: string +): Record | undefined { + return mergeHeaders( + resolveServerHeaders(env, fileEnv), + mergeElectricPrincipalHeader(undefined, ownerPrincipal) + ) +} + function mergeHeaders( ...sources: Array | undefined> ): Record | undefined { @@ -396,7 +423,11 @@ export async function startBuiltinAgentsServer( const anthropicApiKey = resolveAnthropicApiKey(options, env, fileEnv) const runnerId = resolvePullWakeRunnerId(env, fileEnv) const ownerPrincipal = resolvePullWakeOwnerPrincipal(env, fileEnv) - const serverHeaders = mergeHeaders(resolveServerHeaders(env, fileEnv)) + const serverHeaders = resolveBuiltinServerHeaders( + env, + fileEnv, + ownerPrincipal + ) const agentServerUrl = params.agentServerUrl ?? env.ELECTRIC_AGENTS_URL?.trim() ?? @@ -409,6 +440,7 @@ export async function startBuiltinAgentsServer( agentServerUrl, workingDirectory: cwd, loadProjectMcpConfig: true, + durableStreamsFetchCache: false, pullWake: { runnerId, ownerPrincipal, diff --git a/packages/electric-ax/src/version.ts b/packages/electric-ax/src/version.ts index 44da5b367d..50121e4a84 100644 --- a/packages/electric-ax/src/version.ts +++ b/packages/electric-ax/src/version.ts @@ -2,14 +2,19 @@ * Default Docker image tags used by the CLI when launching * the dev environment via docker compose. * - * For release builds these remain "latest". + * For release builds the agents-server image is pinned to the matching + * @electric-ax/agents-server version by the tsdown build. * The canary CI overwrites them to "canary" before building. */ const injectedCliVersion: string = `__ELECTRIC_AX_CLI_VERSION__` +const injectedAgentsServerImageTag: string = `__ELECTRIC_AGENTS_SERVER_IMAGE_TAG__` export const ELECTRIC_AX_CLI_VERSION = injectedCliVersion.startsWith( `__ELECTRIC_AX_` ) ? `0.0.0` : injectedCliVersion export const ELECTRIC_IMAGE_TAG = `latest` -export const ELECTRIC_AGENTS_SERVER_IMAGE_TAG = `latest` +export const ELECTRIC_AGENTS_SERVER_IMAGE_TAG = + injectedAgentsServerImageTag.startsWith(`__ELECTRIC_`) + ? `latest` + : injectedAgentsServerImageTag diff --git a/packages/electric-ax/test/cli.test.ts b/packages/electric-ax/test/cli.test.ts index ec7d89f40f..701070669f 100644 --- a/packages/electric-ax/test/cli.test.ts +++ b/packages/electric-ax/test/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { mkdtempSync } from 'node:fs' -import { tmpdir, userInfo } from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { createElectricCliHandlers, @@ -132,14 +132,14 @@ async function parse(argv: Array, handlers = createHandlers()) { } describe(`createElectricProgram`, () => { - it(`uses system cli username as the default principal header`, () => { + it(`uses the CLI identity as the default user principal header`, () => { const env = getElectricCliEnv({ ELECTRIC_AGENTS_URL: `https://agents.example.test`, ELECTRIC_AGENTS_IDENTITY: `tester@example.com`, }) expect(env.electricAgentsHeaders).toEqual({ - 'electric-principal': `system:cli-${userInfo().username}`, + 'electric-principal': `user:tester@example.com`, }) }) diff --git a/packages/electric-ax/test/entity-stream-db.test.ts b/packages/electric-ax/test/entity-stream-db.test.ts new file mode 100644 index 0000000000..6cc7a13fee --- /dev/null +++ b/packages/electric-ax/test/entity-stream-db.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const runtimeDb = { + preload: vi.fn(), + close: vi.fn(), +} +const createRuntimeEntityStreamDB = vi.fn(() => runtimeDb) + +vi.mock(`@electric-ax/agents-runtime`, () => ({ + appendPathToUrl: (baseUrl: string, path: string) => + `${baseUrl.replace(/\/$/, ``)}/${path.replace(/^\//, ``)}`, + createEntityStreamDB: createRuntimeEntityStreamDB, +})) + +describe(`createEntityStreamDB`, () => { + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it(`uses the agents-runtime StreamDB helper so row timeline ordering matches the UI`, async () => { + const fetchMock = vi.spyOn(globalThis, `fetch`).mockResolvedValue( + new Response(JSON.stringify({ streams: { main: `/horton/demo/main` } }), { + status: 200, + headers: { 'content-type': `application/json` }, + }) + ) + runtimeDb.preload.mockResolvedValue(undefined) + + const { createEntityStreamDB } = await import(`../src/entity-stream-db`) + const result = await createEntityStreamDB({ + baseUrl: `http://localhost:4437`, + entityUrl: `/horton/demo`, + initialOffset: `42`, + headers: { 'electric-principal': `user:kyle` }, + }) + + expect(fetchMock).toHaveBeenCalledWith( + `http://localhost:4437/_electric/entities/horton/demo`, + { + headers: { + 'content-type': `application/json`, + 'electric-principal': `user:kyle`, + }, + } + ) + expect(createRuntimeEntityStreamDB).toHaveBeenCalledWith( + `http://localhost:4437/horton/demo/main`, + undefined, + undefined, + { + streamOptions: { + headers: { + 'content-type': `application/json`, + 'electric-principal': `user:kyle`, + }, + offset: `42`, + }, + } + ) + expect(runtimeDb.preload).toHaveBeenCalledOnce() + + result.close() + expect(runtimeDb.close).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/electric-ax/test/observe-ui.test.tsx b/packages/electric-ax/test/observe-ui.test.tsx index 2c1f5c4eb0..7006d2bcc0 100644 --- a/packages/electric-ax/test/observe-ui.test.tsx +++ b/packages/electric-ax/test/observe-ui.test.tsx @@ -5,6 +5,9 @@ import { ObserveExitHotkey, ToolCallView, ToolResultView, + compareRunItems, + createMessageSendBody, + runItemsToContentItems, UserMessageView, formatTime, truncate, @@ -63,6 +66,70 @@ describe(`ObserveExitHotkey`, () => { }) }) +describe(`createMessageSendBody`, () => { + it(`omits legacy from attribution`, () => { + expect(createMessageSendBody(`hello`)).toEqual({ + payload: { text: `hello` }, + }) + expect(createMessageSendBody(`hello`)).not.toHaveProperty(`from`) + }) + + it(`includes a stable client key when provided`, () => { + expect(createMessageSendBody(`hello`, { key: `optimistic-1` })).toEqual({ + key: `optimistic-1`, + payload: { text: `hello` }, + }) + }) +}) + +describe(`run item rendering helpers`, () => { + it(`ignores transient missing text content`, () => { + expect( + runItemsToContentItems([ + { + text: { + key: `text-1`, + run_id: `run-1`, + order: `1`, + status: `streaming`, + content: undefined, + }, + toolCall: null, + } as any, + ]) + ).toEqual([]) + }) + + it(`sorts numeric timeline orders numerically`, () => { + const items = [ + { + text: { + key: `text-10`, + run_id: `run-1`, + order: 10, + status: `completed`, + content: `ten`, + }, + toolCall: null, + }, + { + text: { + key: `text-2`, + run_id: `run-1`, + order: 2, + status: `completed`, + content: `two`, + }, + toolCall: null, + }, + ] as any + + expect( + [...items].sort(compareRunItems).map((item) => item.text.key) + ).toEqual([`text-2`, `text-10`]) + }) +}) + // ============================================================================ // Ink component rendering // ============================================================================ diff --git a/packages/electric-ax/test/start.test.ts b/packages/electric-ax/test/start.test.ts index 06fd6a8c93..05c3b591d7 100644 --- a/packages/electric-ax/test/start.test.ts +++ b/packages/electric-ax/test/start.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' +import { hostname, userInfo } from 'node:os' import { fileURLToPath } from 'node:url' import { readDotEnvFile, resolveAnthropicApiKey, + resolveBuiltinServerHeaders, resolveComposeProjectName, resolveElectricAgentsPort, resolvePullWakeOwnerPrincipal, @@ -114,6 +116,15 @@ describe(`resolvePullWakeOwnerPrincipal`, () => { }) it(`uses the agents identity when present`, () => { + expect( + resolvePullWakeOwnerPrincipal( + { ELECTRIC_AGENTS_IDENTITY: `a@example.com` }, + {} + ) + ).toBe(`/principal/user%3Aa%40example.com`) + }) + + it(`accepts a principal key as the agents identity`, () => { expect( resolvePullWakeOwnerPrincipal( { ELECTRIC_AGENTS_IDENTITY: `user:a@example.com` }, @@ -122,13 +133,41 @@ describe(`resolvePullWakeOwnerPrincipal`, () => { ).toBe(`/principal/user%3Aa%40example.com`) }) - it(`falls back to the local builtin owner`, () => { + it(`falls back to the local CLI user owner`, () => { expect(resolvePullWakeOwnerPrincipal({}, {})).toBe( - `/principal/system%3Abuiltin-agents` + `/principal/${encodeURIComponent(`user:${userInfo().username}@${hostname()}`)}` ) }) }) +describe(`resolveBuiltinServerHeaders`, () => { + it(`authenticates as the resolved default runner owner`, () => { + expect( + resolveBuiltinServerHeaders({}, {}, `/principal/system%3Abuiltin-agents`) + ).toEqual({ + 'electric-principal': `/principal/system%3Abuiltin-agents`, + }) + }) + + it(`keeps additional headers while forcing the runner owner principal`, () => { + expect( + resolveBuiltinServerHeaders( + { + ELECTRIC_AGENTS_SERVER_HEADERS: JSON.stringify({ + authorization: `Bearer token`, + 'electric-principal': `user:wrong`, + }), + }, + {}, + `/principal/service%3Asvc-test` + ) + ).toEqual({ + authorization: `Bearer token`, + 'electric-principal': `/principal/service%3Asvc-test`, + }) + }) +}) + describe(`resolveComposeProjectName`, () => { it(`uses the explicit override when provided`, () => { expect( diff --git a/packages/electric-ax/tsdown.config.ts b/packages/electric-ax/tsdown.config.ts index ac0d086430..f2a2c05991 100644 --- a/packages/electric-ax/tsdown.config.ts +++ b/packages/electric-ax/tsdown.config.ts @@ -1,7 +1,10 @@ import type { Options } from 'tsdown' import { readFileSync } from 'node:fs' -const packageVersion = readPackageVersion() +const packageVersion = readPackageVersion(`./package.json`) +const agentsServerPackageVersion = readPackageVersion( + `../agents-server/package.json` +) type VersionPlugin = { name: string transform: (code: string, id: string) => string | null @@ -21,27 +24,35 @@ const config: Options = { platform: `node`, dts: true, clean: true, - plugins: [packageVersionPlugin(packageVersion)], + plugins: [packageVersionPlugin(packageVersion, agentsServerPackageVersion)], external: [/^@durable-streams\//, /^@electric-ax\//, /^omelette$/], } export default config -function readPackageVersion(): string { - const raw = readFileSync(new URL(`./package.json`, import.meta.url), `utf8`) +function readPackageVersion(packagePath: string): string { + const raw = readFileSync(new URL(packagePath, import.meta.url), `utf8`) const parsed = JSON.parse(raw) as { version?: unknown } if (typeof parsed.version !== `string` || !parsed.version.trim()) { - throw new Error(`packages/electric-ax/package.json is missing a version`) + throw new Error(`${packagePath} is missing a version`) } return parsed.version.trim() } -function packageVersionPlugin(version: string): VersionPlugin { +function packageVersionPlugin( + version: string, + agentsServerImageTag: string +): VersionPlugin { return { name: `electric-ax-package-version`, transform(code: string, id: string) { if (!id.endsWith(`/src/version.ts`)) return null - return code.replaceAll(`__ELECTRIC_AX_CLI_VERSION__`, version) + return code + .replaceAll(`__ELECTRIC_AX_CLI_VERSION__`, version) + .replaceAll( + `__ELECTRIC_AGENTS_SERVER_IMAGE_TAG__`, + agentsServerImageTag + ) }, } } diff --git a/packages/electric-ax/tsdown.desktop.config.ts b/packages/electric-ax/tsdown.desktop.config.ts index 8d137417bf..00c2b6533d 100644 --- a/packages/electric-ax/tsdown.desktop.config.ts +++ b/packages/electric-ax/tsdown.desktop.config.ts @@ -1,7 +1,10 @@ import type { Options } from 'tsdown' import { readFileSync } from 'node:fs' -const packageVersion = readPackageVersion() +const packageVersion = readPackageVersion(`./package.json`) +const agentsServerPackageVersion = readPackageVersion( + `../agents-server/package.json` +) type VersionPlugin = { name: string transform: (code: string, id: string) => string | null @@ -21,7 +24,7 @@ const config: Options = { dts: false, clean: true, outDir: `dist-desktop`, - plugins: [packageVersionPlugin(packageVersion)], + plugins: [packageVersionPlugin(packageVersion, agentsServerPackageVersion)], noExternal: [ /^@durable-streams\//, /^@electric-ax\//, @@ -37,21 +40,29 @@ const config: Options = { export default config -function readPackageVersion(): string { - const raw = readFileSync(new URL(`./package.json`, import.meta.url), `utf8`) +function readPackageVersion(packagePath: string): string { + const raw = readFileSync(new URL(packagePath, import.meta.url), `utf8`) const parsed = JSON.parse(raw) as { version?: unknown } if (typeof parsed.version !== `string` || !parsed.version.trim()) { - throw new Error(`packages/electric-ax/package.json is missing a version`) + throw new Error(`${packagePath} is missing a version`) } return parsed.version.trim() } -function packageVersionPlugin(version: string): VersionPlugin { +function packageVersionPlugin( + version: string, + agentsServerImageTag: string +): VersionPlugin { return { name: `electric-ax-package-version`, transform(code: string, id: string) { if (!id.endsWith(`/src/version.ts`)) return null - return code.replaceAll(`__ELECTRIC_AX_CLI_VERSION__`, version) + return code + .replaceAll(`__ELECTRIC_AX_CLI_VERSION__`, version) + .replaceAll( + `__ELECTRIC_AGENTS_SERVER_IMAGE_TAG__`, + agentsServerImageTag + ) }, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71eea6119..e410d13649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2161,7 +2161,7 @@ importers: version: 0.2.6 '@durable-streams/state': specifier: ^0.3.1 - version: 0.3.1(@tanstack/db@0.6.7(typescript@5.8.3)) + version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) '@electric-ax/agents': specifier: workspace:* version: link:../agents @@ -2172,11 +2172,11 @@ importers: specifier: ^1.5.21 version: link:../typescript-client '@tanstack/db': - specifier: ^0.6.4 - version: 0.6.7(typescript@5.8.3) + specifier: ^0.6.6 + version: 0.6.7(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.85 - version: 0.1.85(react@19.2.0)(typescript@5.8.3) + version: 0.1.85(react@19.2.0)(typescript@5.9.3) commander: specifier: ^13.1.0 version: 13.1.0 @@ -2207,13 +2207,13 @@ importers: version: 4.0.0(@types/react@19.2.14) tsdown: specifier: ^0.9.0 - version: 0.9.9(typescript@5.8.3) + version: 0.9.9(typescript@5.9.3) tsx: specifier: ^4.19.2 version: 4.20.3 typescript: - specifier: ^5.7.2 - version: 5.8.3 + specifier: ^5.9.3 + version: 5.9.3 vitest: specifier: ^4.1.0 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -31339,14 +31339,6 @@ snapshots: transitivePeerDependencies: - typescript - '@tanstack/react-db@0.1.85(react@19.2.0)(typescript@5.8.3)': - dependencies: - '@tanstack/db': 0.6.7(typescript@5.8.3) - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) - transitivePeerDependencies: - - typescript - '@tanstack/react-db@0.1.85(react@19.2.0)(typescript@5.9.3)': dependencies: '@tanstack/db': 0.6.7(typescript@5.9.3) From e43c98b05f2c3b4b218015d28be042fb303dc419 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 15:49:29 +0200 Subject: [PATCH 002/105] feat(agents): context-window usage gauge (compaction phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for context compaction: measure and surface how full the model's context window is, with no behavior change yet. - pi-adapter: capture cache-INCLUSIVE prompt size (input + cacheRead + cacheWrite) and the model context window per step. The existing input_tokens deliberately excludes cache reads for budget accounting, but cached tokens still occupy the window, so a fullness gauge needs the inclusive total. - persist context_input_tokens + context_window on the step row (optional/additive) via the outbound bridge. - token-accountant: single source of truth for usage ratio, severity level, and the compaction thresholds (85% background / 95% ceiling). - UI: ContextUsageIndicator renders "NN% used" in the composer footer from the same helper, coloured at the 85/95 thresholds. Observational only — nothing compacts yet; this validates the token accounting before later phases act on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/client.ts | 12 +++ packages/agents-runtime/src/entity-schema.ts | 11 +++ packages/agents-runtime/src/index.ts | 13 +++ .../agents-runtime/src/outbound-bridge.ts | 14 +++ packages/agents-runtime/src/pi-adapter.ts | 22 +++++ .../agents-runtime/src/token-accountant.ts | 89 ++++++++++++++++++ .../test/outbound-bridge.test.ts | 38 ++++++++ .../test/token-accountant.test.ts | 82 ++++++++++++++++ .../ContextUsageIndicator.module.css | 39 ++++++++ .../src/components/ContextUsageIndicator.tsx | 94 +++++++++++++++++++ .../src/components/MessageInput.tsx | 22 +++-- 11 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 packages/agents-runtime/src/token-accountant.ts create mode 100644 packages/agents-runtime/test/token-accountant.test.ts create mode 100644 packages/agents-server-ui/src/components/ContextUsageIndicator.module.css create mode 100644 packages/agents-server-ui/src/components/ContextUsageIndicator.tsx diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 55306cc03a..40bcec94e9 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -46,6 +46,18 @@ export { // drift from the runtime dispatcher. export { isGoalCommandText, parseGoalCommand } from './goal-command' export { formatTokenCount } from './token-budget' +export { + CONTEXT_USAGE_BACKGROUND_START, + CONTEXT_USAGE_HARD_CEILING, + computeContextUsage, + contextUsageLevel, + formatContextUsagePercent, +} from './token-accountant' +export type { + ContextUsage, + ContextUsageInput, + ContextUsageLevel, +} from './token-accountant' export type { GoalCommand } from './goal-command' export type { diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index d94b99eac9..48dacb32e6 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -165,6 +165,17 @@ type StepValue = { // would re-count the whole conversation on every step. input_tokens?: number output_tokens?: number + // Cache-inclusive prompt size for this step (`input + cacheRead + + // cacheWrite`) — i.e. how many tokens of the model's context window the + // request actually occupied. Unlike `input_tokens` (which excludes cache + // reads so budget accounting doesn't re-count the conversation every warm + // turn) this is the right number for a context-fullness gauge. Optional and + // additive, so older events without it stay valid. + context_input_tokens?: number + // The model's context window at the time of this step. Persisted alongside + // the usage so consumers can render a fraction without resolving the model + // registry themselves. + context_window?: number } type TextValue = { key?: string diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index 61093c87b4..6a89a64de6 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -360,6 +360,19 @@ export { export type { GoalCommand, GoalDispatchResult } from './goal-command' export { assembleContext } from './context-assembly' export { approxTokens, formatTokenCount, sliceChars } from './token-budget' +export { + CONTEXT_USAGE_AWARENESS_THRESHOLDS, + CONTEXT_USAGE_BACKGROUND_START, + CONTEXT_USAGE_HARD_CEILING, + computeContextUsage, + contextUsageLevel, + formatContextUsagePercent, +} from './token-accountant' +export type { + ContextUsage, + ContextUsageInput, + ContextUsageLevel, +} from './token-accountant' export { createContextTools } from './tools/context-tools' export { completeWithLowCostModel, diff --git a/packages/agents-runtime/src/outbound-bridge.ts b/packages/agents-runtime/src/outbound-bridge.ts index 87c902df93..9d589f34f9 100644 --- a/packages/agents-runtime/src/outbound-bridge.ts +++ b/packages/agents-runtime/src/outbound-bridge.ts @@ -156,6 +156,12 @@ export interface OutboundBridge { // persisted to the step row — forwarded to hooks for budget accounting. tokenInputUncached?: number tokenOutput?: number + // Cache-inclusive prompt size (`input + cacheRead + cacheWrite`), + // persisted to the step as `context_input_tokens` for the context-usage + // gauge. Distinct from `tokenInput`, which excludes cache reads. + tokenContext?: number + // Model context window for this step, persisted as `context_window`. + contextWindow?: number durationMs?: number }) => void onTextStart: () => void @@ -323,6 +329,8 @@ export function createOutboundBridge( tokenInput?: number tokenInputUncached?: number tokenOutput?: number + tokenContext?: number + contextWindow?: number durationMs?: number }) { if (!currentStepKey) return @@ -343,6 +351,12 @@ export function createOutboundBridge( ...(opts?.tokenOutput !== undefined && { output_tokens: opts.tokenOutput, }), + ...(opts?.tokenContext !== undefined && { + context_input_tokens: opts.tokenContext, + }), + ...(opts?.contextWindow !== undefined && { + context_window: opts.contextWindow, + }), } as never, }) as ChangeEvent ) diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 8b8d59eff0..18857c6815 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -531,6 +531,24 @@ export function createPiAgentAdapter( : typeof usage?.outputTokens === `number` ? usage.outputTokens : undefined + // Cache-INCLUSIVE prompt size: every token the request put in + // the context window, including prompt-cache reads. This is + // what a "% of context used" gauge needs — cached tokens still + // occupy the window even though `usageInput` excludes them for + // budget accounting. + const usageContext = + sumPresentNumbers([ + usage?.input, + usage?.cacheWrite, + usage?.cacheRead, + ]) ?? + (typeof usage?.inputTokens === `number` + ? usage.inputTokens + : undefined) + const contextWindow = + typeof model.contextWindow === `number` + ? model.contextWindow + : undefined bridge.onStepEnd({ finishReason, durationMs: Date.now() - stepStartTime, @@ -541,6 +559,10 @@ export function createPiAgentAdapter( ...(usageOutput !== undefined && { tokenOutput: usageOutput, }), + ...(usageContext !== undefined && { + tokenContext: usageContext, + }), + ...(contextWindow !== undefined && { contextWindow }), }) if (isError) { diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts new file mode 100644 index 0000000000..42127e1bcc --- /dev/null +++ b/packages/agents-runtime/src/token-accountant.ts @@ -0,0 +1,89 @@ +/** + * Token accounting for context-window usage. + * + * This is the single source of truth for turning a step's reported token usage + * into a "% of the context window used" figure. Both the runtime (telemetry) + * and the UI (the composer usage gauge) compute usage through here, so the + * number the user sees is exactly the number later compaction phases will act + * on. If this drifts, everything downstream drifts with it. + * + * Phase 0 only reads it for display; later phases drive budget notices, + * background compaction, and the hard ceiling off the same thresholds below. + */ + +/** + * Fractions of the context window at which the compaction system changes + * behaviour. Kept here (not scattered across call sites) so the UI gauge and + * the runtime triggers share one set of numbers. + * + * - `AWARENESS` (25/50/75%): inject a budget notice so the model can pace + * itself (Phase 1). + * - `BACKGROUND_START` (85%): kick off background compaction (Phase 3). + * - `HARD_CEILING` (95%): usable ceiling; compact/truncate synchronously + * before the next model call (Phase 2). + */ +export const CONTEXT_USAGE_AWARENESS_THRESHOLDS = [0.25, 0.5, 0.75] as const +export const CONTEXT_USAGE_BACKGROUND_START = 0.85 +export const CONTEXT_USAGE_HARD_CEILING = 0.95 + +export interface ContextUsageInput { + /** + * Cache-inclusive prompt size of the most recent request — every token the + * request occupied in the context window (`input + cacheRead + cacheWrite`). + * Persisted as `context_input_tokens` on the step. + */ + contextInputTokens: number + /** + * Output tokens of that same step. They re-enter the prompt on the next turn, + * so counting them makes the gauge reflect how full the window will be going + * into the next request rather than lagging a turn behind. + */ + outputTokens?: number + /** The model's context window (`context_window` on the step). */ + contextWindow: number +} + +export interface ContextUsage { + /** Estimated tokens occupying the window now (input + output of last step). */ + usedTokens: number + contextWindow: number + /** `usedTokens / contextWindow`, clamped to [0, 1]. */ + ratio: number +} + +/** + * Compute context-window usage from a step's reported tokens. Returns `null` + * when the context window is unknown or non-positive (e.g. a provider that + * didn't report it), so callers can hide the gauge rather than divide by zero. + */ +export function computeContextUsage( + input: ContextUsageInput +): ContextUsage | null { + if (!Number.isFinite(input.contextWindow) || input.contextWindow <= 0) { + return null + } + const usedTokens = Math.max( + 0, + input.contextInputTokens + (input.outputTokens ?? 0) + ) + const ratio = Math.min(1, usedTokens / input.contextWindow) + return { usedTokens, contextWindow: input.contextWindow, ratio } +} + +export type ContextUsageLevel = `normal` | `warning` | `critical` + +/** + * Severity bucket for a usage ratio, aligned to the compaction thresholds: + * `warning` once background compaction would start (85%), `critical` at the + * hard ceiling (95%). + */ +export function contextUsageLevel(ratio: number): ContextUsageLevel { + if (ratio >= CONTEXT_USAGE_HARD_CEILING) return `critical` + if (ratio >= CONTEXT_USAGE_BACKGROUND_START) return `warning` + return `normal` +} + +/** Render a usage ratio as a whole-percent label, e.g. `42%`. */ +export function formatContextUsagePercent(ratio: number): string { + return `${Math.round(ratio * 100)}%` +} diff --git a/packages/agents-runtime/test/outbound-bridge.test.ts b/packages/agents-runtime/test/outbound-bridge.test.ts index 62fb5d75b3..a620869475 100644 --- a/packages/agents-runtime/test/outbound-bridge.test.ts +++ b/packages/agents-runtime/test/outbound-bridge.test.ts @@ -320,6 +320,44 @@ describe(`createOutboundBridge`, () => { expect(value.output_tokens).toBe(567) }) + it(`onStepEnd surfaces context usage and window for the gauge`, () => { + const writes: Array = [] + const bridge = createOutboundBridge([], (e) => { + writes.push(e) + }) + + bridge.onRunStart() + bridge.onStepStart({ modelId: `gpt-4` }) + bridge.onStepEnd({ + finishReason: `stop`, + tokenInput: 1234, + tokenOutput: 567, + // Cache-inclusive prompt size — larger than `tokenInput`, which + // excludes cache reads. + tokenContext: 90_000, + contextWindow: 128_000, + }) + + const value = writes[writes.length - 1]!.value as Record + expect(value.context_input_tokens).toBe(90_000) + expect(value.context_window).toBe(128_000) + }) + + it(`onStepEnd omits context columns when not reported`, () => { + const writes: Array = [] + const bridge = createOutboundBridge([], (e) => { + writes.push(e) + }) + + bridge.onRunStart() + bridge.onStepStart({ modelId: `gpt-4` }) + bridge.onStepEnd({ finishReason: `stop`, tokenInput: 42 }) + + const value = writes[writes.length - 1]!.value as Record + expect(`context_input_tokens` in value).toBe(false) + expect(`context_window` in value).toBe(false) + }) + it(`onStepEnd omits token columns when a side is undefined`, () => { const writes: Array = [] const bridge = createOutboundBridge([], (e) => { diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts new file mode 100644 index 0000000000..cab22c8381 --- /dev/null +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + CONTEXT_USAGE_BACKGROUND_START, + CONTEXT_USAGE_HARD_CEILING, + computeContextUsage, + contextUsageLevel, + formatContextUsagePercent, +} from '../src/token-accountant' + +describe(`computeContextUsage`, () => { + it(`sums input + output against the window`, () => { + const usage = computeContextUsage({ + contextInputTokens: 40_000, + outputTokens: 10_000, + contextWindow: 100_000, + }) + expect(usage).toEqual({ + usedTokens: 50_000, + contextWindow: 100_000, + ratio: 0.5, + }) + }) + + it(`treats output as optional`, () => { + const usage = computeContextUsage({ + contextInputTokens: 25_000, + contextWindow: 100_000, + }) + expect(usage?.usedTokens).toBe(25_000) + expect(usage?.ratio).toBe(0.25) + }) + + it(`clamps the ratio to 1 when over the window`, () => { + const usage = computeContextUsage({ + contextInputTokens: 120_000, + outputTokens: 20_000, + contextWindow: 100_000, + }) + expect(usage?.ratio).toBe(1) + // usedTokens is the true (un-clamped) count for the tooltip. + expect(usage?.usedTokens).toBe(140_000) + }) + + it(`returns null for an unknown or non-positive window`, () => { + expect( + computeContextUsage({ contextInputTokens: 10, contextWindow: 0 }) + ).toBeNull() + expect( + computeContextUsage({ + contextInputTokens: 10, + contextWindow: Number.NaN, + }) + ).toBeNull() + }) +}) + +describe(`contextUsageLevel`, () => { + it(`is normal below the background-compaction threshold`, () => { + expect(contextUsageLevel(0)).toBe(`normal`) + expect(contextUsageLevel(CONTEXT_USAGE_BACKGROUND_START - 0.01)).toBe( + `normal` + ) + }) + + it(`is warning between background start and the hard ceiling`, () => { + expect(contextUsageLevel(CONTEXT_USAGE_BACKGROUND_START)).toBe(`warning`) + expect(contextUsageLevel(CONTEXT_USAGE_HARD_CEILING - 0.01)).toBe(`warning`) + }) + + it(`is critical at or above the hard ceiling`, () => { + expect(contextUsageLevel(CONTEXT_USAGE_HARD_CEILING)).toBe(`critical`) + expect(contextUsageLevel(1)).toBe(`critical`) + }) +}) + +describe(`formatContextUsagePercent`, () => { + it(`renders a whole-percent label`, () => { + expect(formatContextUsagePercent(0)).toBe(`0%`) + expect(formatContextUsagePercent(0.426)).toBe(`43%`) + expect(formatContextUsagePercent(1)).toBe(`100%`) + }) +}) diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css new file mode 100644 index 0000000000..acb6fb4ec3 --- /dev/null +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css @@ -0,0 +1,39 @@ +.indicator { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0 0.25rem; + font-variant-numeric: tabular-nums; + color: var(--text-muted, rgba(0, 0, 0, 0.55)); + user-select: none; + cursor: default; +} + +.percent { + font-size: 0.72rem; + line-height: 1; + color: inherit; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.6; + flex-shrink: 0; +} + +/* Levels mirror the compaction thresholds: warning once background + compaction would start (85%), critical at the hard ceiling (95%). */ +.normal { + color: var(--text-muted, rgba(0, 0, 0, 0.55)); +} + +.warning { + color: var(--warning-text, #b26a00); +} + +.critical { + color: var(--danger-text, #a02020); +} diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx new file mode 100644 index 0000000000..666f786b5c --- /dev/null +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import { + computeContextUsage, + contextUsageLevel, + formatContextUsagePercent, + formatTokenCount, +} from '@electric-ax/agents-runtime/client' +import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' +import { Tooltip } from '../ui/Tooltip' +import styles from './ContextUsageIndicator.module.css' + +/** + * Phase-0 context-window gauge for the composer footer (à la Claude Code's + * "X% used"). It reads the *same* numbers the runtime persists for compaction + * — the latest step's cache-inclusive prompt size and the model's context + * window — and divides them through `computeContextUsage`, the shared source of + * truth. Nothing acts on the ratio yet; this is purely observational and lets + * us eyeball-validate the token accounting before later phases trigger off it. + */ + +interface StepRow { + _seq?: number + context_input_tokens?: number + context_window?: number + output_tokens?: number + model_id?: string +} + +interface ContextUsageIndicatorProps { + db: EntityStreamDBWithActions | null +} + +export function ContextUsageIndicator({ + db, +}: ContextUsageIndicatorProps): React.ReactElement | null { + const { data: steps = [] } = useLiveQuery( + (q) => + db && db.collections.steps + ? q.from({ step: db.collections.steps as any }) + : undefined, + [db] + ) + + const usage = useMemo(() => { + // The most-recently-started step that reported context usage holds the + // freshest, fullest prompt size (the last step of the latest run carries + // the whole conversation). `_seq` is the collection's monotonic insertion + // order, so the max among completed steps is the latest. + let latest: StepRow | null = null + for (const row of steps as Array) { + if ( + typeof row.context_window !== `number` || + row.context_window <= 0 || + typeof row.context_input_tokens !== `number` + ) { + continue + } + if (!latest || (row._seq ?? 0) > (latest._seq ?? 0)) { + latest = row + } + } + if (!latest) return null + const computed = computeContextUsage({ + contextInputTokens: latest.context_input_tokens as number, + outputTokens: latest.output_tokens, + contextWindow: latest.context_window as number, + }) + return computed ? { ...computed, modelId: latest.model_id } : null + }, [steps]) + + if (!usage) return null + + const level = contextUsageLevel(usage.ratio) + const percent = formatContextUsagePercent(usage.ratio) + const tokensLabel = `${formatTokenCount(usage.usedTokens)} / ${formatTokenCount( + usage.contextWindow + )} tokens` + const tooltip = usage.modelId + ? `${tokensLabel} · ${usage.modelId}` + : tokensLabel + + return ( + + + + + ) +} diff --git a/packages/agents-server-ui/src/components/MessageInput.tsx b/packages/agents-server-ui/src/components/MessageInput.tsx index 906e4e890b..d71a6edd7d 100644 --- a/packages/agents-server-ui/src/components/MessageInput.tsx +++ b/packages/agents-server-ui/src/components/MessageInput.tsx @@ -20,6 +20,7 @@ import { } from '@electric-ax/agents-runtime/client' import { ComposerEditor } from './ComposerEditor' import { ComposerShell } from './ComposerShell' +import { ContextUsageIndicator } from './ContextUsageIndicator' import { Icon, Stack, Text, Tooltip } from '../ui' import { AttachmentActionMenu, @@ -472,15 +473,18 @@ export function MessageInput({ ) : null } controls={ - imageAttachmentsEnabled && !isCommentMode ? ( - - ) : null + <> + {imageAttachmentsEnabled && !isCommentMode ? ( + + ) : null} + + } send={ From b35d1384cde5a7c4cf0c88328006cb1aea257ff6 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 15:56:25 +0200 Subject: [PATCH 003/105] test(agents): cover cache-inclusive context_input_tokens + context_window in pi-adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge persistence and the usage-ratio helper were covered, but the adapter's cache-INCLUSIVE total (input + cacheRead + cacheWrite) — the accuracy premise of the context gauge — was not. Assert it equals 1350 where the uncached input_tokens is 150, and that context_window is emitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/test/pi-adapter.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index f8767f86c4..4398df2d2b 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -1088,5 +1088,30 @@ describe(`toAgentHistory`, () => { expect(stepValue?.input_tokens).toBe(100) expect(`output_tokens` in (stepValue ?? {})).toBe(false) }) + + it(`emits the cache-INCLUSIVE prompt size as context_input_tokens`, async () => { + // The context-usage gauge needs every token occupying the window, + // including prompt-cache reads — unlike `input_tokens`, which + // excludes cacheRead for budget accounting. Same split as the test + // above: 50 + 1200 + 100 = 1350, vs the uncached input_tokens of 150. + const events = await runOnce( + makeCompletedMessage({ + input: 50, + cacheRead: 1200, + cacheWrite: 100, + output: 80, + }) + ) + const stepValue = findStepUpdate(events) + expect(stepValue?.context_input_tokens).toBe(1350) + expect(stepValue?.input_tokens).toBe(150) + }) + + it(`emits the model context window on the step`, async () => { + const events = await runOnce(makeCompletedMessage({ input: 100 })) + const stepValue = findStepUpdate(events) + expect(typeof stepValue?.context_window).toBe(`number`) + expect(stepValue?.context_window as number).toBeGreaterThan(0) + }) }) }) From 72d6a19ae63456dbdbd7bc3af86780d030dec2bf Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:12:50 +0200 Subject: [PATCH 004/105] feat(agents): context budget notice to the model (compaction phase 1) Once context usage reaches 25%, inject a notice into the model's messages stating remaining tokens + percent, so the model can pace itself. Recomputed each call from the latest step's persisted usage, so it is always current. - token-accountant: selectLatestContextUsage (latest step with usage), shouldSurfaceContextBudget (gate at 25%), formatContextBudgetNotice, and withContextBudgetNotice (inject just before the final message). - context-factory/runAgent: synthesize and inject before the model call. Synthesized (not persisted) on purpose: a self-superseding context row would leave load_context_history tombstones, which are misleading for an ephemeral budget hint. Synthesis stays deterministic (pure function of persisted steps) so replay/fork reproduce it, with no row churn. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/context-factory.ts | 29 ++++- packages/agents-runtime/src/index.ts | 6 + .../agents-runtime/src/token-accountant.ts | 113 +++++++++++++++++- .../test/token-accountant.test.ts | 107 +++++++++++++++++ 4 files changed, 244 insertions(+), 11 deletions(-) diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index bd2812e2fb..9c4606d9cf 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -17,6 +17,11 @@ import { import { getCronStreamPath } from './cron-utils' import { runtimeLog } from './log' import { sliceChars } from './token-budget' +import { + selectLatestContextUsage, + withContextBudgetNotice, + type ContextUsageStep, +} from './token-accountant' import { createContextTools } from './tools/context-tools' import { CACHE_TIERS } from './types' import { composeToolsWithProviders } from './tool-providers' @@ -773,6 +778,20 @@ export function createHandlerContext( const composedTools = (await composeToolsWithProviders( activeAgentConfig.tools )) as Array + + // Phase 1: surface the remaining context budget to the model once usage + // reaches the first awareness threshold (25%). Synthesized fresh each + // call from the latest persisted step — so it is always current, and we + // deliberately do NOT persist it as a context row (a self-superseding + // entry would leave `load_context_history(...)` tombstones, which are + // meaningless breadcrumbs for an ephemeral budget hint). The notice is + // injected just before the final message so the closing turn — and the + // runInput detection below, which reads `.at(-1)` — is unchanged. + const budgetUsage = selectLatestContextUsage( + config.db.collections.steps.toArray as ReadonlyArray + ) + const outgoingMessages = withContextBudgetNotice(messages, budgetUsage) + const adapterFactory = createPiAgentAdapter({ systemPrompt: activeAgentConfig.systemPrompt, model: activeAgentConfig.model, @@ -797,12 +816,12 @@ export function createHandlerContext( const handle = adapterFactory({ entityUrl: config.entityUrl, epoch: config.epoch, - messages, + messages: outgoingMessages, outboundIdSeed: await loadOutboundIdSeed(config.db), writeEvent: config.writeEvent, }) - const latestMessageRole = messages.at(-1)?.role + const latestMessageRole = outgoingMessages.at(-1)?.role const runInput = input !== undefined || config.hydratedWebhookSourceWake != null || @@ -815,14 +834,14 @@ export function createHandlerContext( logPrefix, `agent.run starting provider=${agentModelProvider(activeAgentConfig)} ` + `model=${agentModelId(activeAgentConfig.model)} ` + - `messages=${messages.length} latestRole=${latestMessageRole ?? `none`} ` + + `messages=${outgoingMessages.length} latestRole=${latestMessageRole ?? `none`} ` + `wakeType=${config.wakeEvent.type} wakeOffset=${config.wakeOffset} ` + `triggerMessageLen=${messageText.length} ` + `runInputLen=${runInput?.length ?? 0} ` + `tools=${composedTools.length + extraTools.length}` ) - if (messages.length > 0) { - const tail = messages.slice(-3) + if (outgoingMessages.length > 0) { + const tail = outgoingMessages.slice(-3) runtimeLog.info( logPrefix, `agent.run last messages: ${tail diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index 6a89a64de6..dfa86c299e 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -367,11 +367,17 @@ export { computeContextUsage, contextUsageLevel, formatContextUsagePercent, + selectLatestContextUsage, + shouldSurfaceContextBudget, + formatContextBudgetNotice, + buildContextBudgetNotice, + withContextBudgetNotice, } from './token-accountant' export type { ContextUsage, ContextUsageInput, ContextUsageLevel, + ContextUsageStep, } from './token-accountant' export { createContextTools } from './tools/context-tools' export { diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index 42127e1bcc..b7daa153db 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -2,15 +2,20 @@ * Token accounting for context-window usage. * * This is the single source of truth for turning a step's reported token usage - * into a "% of the context window used" figure. Both the runtime (telemetry) - * and the UI (the composer usage gauge) compute usage through here, so the - * number the user sees is exactly the number later compaction phases will act - * on. If this drifts, everything downstream drifts with it. + * into a "% of the context window used" figure. Both the runtime (telemetry + + * the model-facing budget notice) and the UI (the composer usage gauge) compute + * usage through here, so the number the user sees is exactly the number later + * compaction phases will act on. If this drifts, everything downstream drifts + * with it. * - * Phase 0 only reads it for display; later phases drive budget notices, - * background compaction, and the hard ceiling off the same thresholds below. + * Phase 0 reads it for display; Phase 1 surfaces it to the model as a budget + * notice; later phases drive background compaction and the hard ceiling off the + * same thresholds below. */ +import { formatTokenCount } from './token-budget' +import type { LLMMessage } from './types' + /** * Fractions of the context window at which the compaction system changes * behaviour. Kept here (not scattered across call sites) so the UI gauge and @@ -87,3 +92,99 @@ export function contextUsageLevel(ratio: number): ContextUsageLevel { export function formatContextUsagePercent(ratio: number): string { return `${Math.round(ratio * 100)}%` } + +/** + * Minimal shape of a persisted step row needed to derive context usage — the + * cache-inclusive prompt size, the step's output, the model window, and `_seq` + * (the collection's monotonic insertion order) to find the most recent one. + */ +export interface ContextUsageStep { + _seq?: number + context_input_tokens?: number + context_window?: number + output_tokens?: number +} + +/** + * Pick the most recent step that reported context usage and compute its usage. + * The latest step of the latest run carries the whole conversation, so its + * cache-inclusive prompt size is the best estimate of current fullness. Returns + * `null` when no step has reported usage yet (e.g. the very first turn). + */ +export function selectLatestContextUsage( + steps: ReadonlyArray +): ContextUsage | null { + let latest: ContextUsageStep | null = null + for (const step of steps) { + if ( + typeof step.context_window !== `number` || + step.context_window <= 0 || + typeof step.context_input_tokens !== `number` + ) { + continue + } + if (!latest || (step._seq ?? 0) > (latest._seq ?? 0)) { + latest = step + } + } + if (!latest) return null + return computeContextUsage({ + contextInputTokens: latest.context_input_tokens as number, + outputTokens: latest.output_tokens, + contextWindow: latest.context_window as number, + }) +} + +/** + * Whether to show the model a budget notice — once usage reaches the first + * awareness threshold (25%). Below that the window is empty enough that a + * reminder is just noise. + */ +export function shouldSurfaceContextBudget(ratio: number): boolean { + return ratio >= CONTEXT_USAGE_AWARENESS_THRESHOLDS[0] +} + +/** + * The human-readable body of the model-facing budget notice — remaining tokens + * plus the percentage left. Recomputed every turn from the latest step, so it + * is always current rather than a stale snapshot from when a threshold was + * first crossed. + */ +export function formatContextBudgetNotice(usage: ContextUsage): string { + const remaining = Math.max(0, usage.contextWindow - usage.usedTokens) + const percentLeft = Math.max(0, Math.round((1 - usage.ratio) * 100)) + return `You have about ${formatTokenCount( + remaining + )} tokens (${percentLeft}%) of the context window remaining.` +} + +/** Tag wrapping the budget notice, mirroring Codex's ``. */ +const CONTEXT_BUDGET_NOTICE_TAG = `token_budget` + +/** The model-facing budget notice as a (user-role) message. */ +export function buildContextBudgetNotice(usage: ContextUsage): LLMMessage { + return { + role: `user`, + content: `<${CONTEXT_BUDGET_NOTICE_TAG}>\n${formatContextBudgetNotice( + usage + )}\n`, + } +} + +/** + * Return `messages` with a current context-budget notice injected, or unchanged + * when usage is unknown or below the first awareness threshold. The notice is + * placed just before the final message so the closing turn (and any + * last-message inspection downstream) is preserved. + */ +export function withContextBudgetNotice( + messages: ReadonlyArray, + usage: ContextUsage | null +): Array { + if (!usage || !shouldSurfaceContextBudget(usage.ratio)) { + return [...messages] + } + const notice = buildContextBudgetNotice(usage) + if (messages.length === 0) return [notice] + return [...messages.slice(0, -1), notice, messages[messages.length - 1]!] +} diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts index cab22c8381..44fa810663 100644 --- a/packages/agents-runtime/test/token-accountant.test.ts +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -5,7 +5,12 @@ import { computeContextUsage, contextUsageLevel, formatContextUsagePercent, + formatContextBudgetNotice, + selectLatestContextUsage, + shouldSurfaceContextBudget, + withContextBudgetNotice, } from '../src/token-accountant' +import type { LLMMessage } from '../src/types' describe(`computeContextUsage`, () => { it(`sums input + output against the window`, () => { @@ -80,3 +85,105 @@ describe(`formatContextUsagePercent`, () => { expect(formatContextUsagePercent(1)).toBe(`100%`) }) }) + +describe(`selectLatestContextUsage`, () => { + it(`picks the step with the highest _seq that reported usage`, () => { + const usage = selectLatestContextUsage([ + { _seq: 1, context_input_tokens: 10_000, context_window: 100_000 }, + { + _seq: 3, + context_input_tokens: 60_000, + output_tokens: 5_000, + context_window: 100_000, + }, + { _seq: 2, context_input_tokens: 30_000, context_window: 100_000 }, + ]) + expect(usage?.usedTokens).toBe(65_000) + expect(usage?.ratio).toBe(0.65) + }) + + it(`ignores steps that have not reported context usage`, () => { + const usage = selectLatestContextUsage([ + { _seq: 5, output_tokens: 9 }, // started, no usage yet — must be skipped + { _seq: 2, context_input_tokens: 40_000, context_window: 100_000 }, + ]) + expect(usage?.usedTokens).toBe(40_000) + }) + + it(`returns null when no step has reported usage`, () => { + expect(selectLatestContextUsage([])).toBeNull() + expect(selectLatestContextUsage([{ _seq: 1, output_tokens: 5 }])).toBeNull() + }) +}) + +describe(`shouldSurfaceContextBudget`, () => { + it(`is false below 25% and true at/above it`, () => { + expect(shouldSurfaceContextBudget(0.1)).toBe(false) + expect(shouldSurfaceContextBudget(0.2499)).toBe(false) + expect(shouldSurfaceContextBudget(0.25)).toBe(true) + expect(shouldSurfaceContextBudget(0.9)).toBe(true) + }) +}) + +describe(`formatContextBudgetNotice`, () => { + it(`states remaining tokens and percent left`, () => { + const usage = computeContextUsage({ + contextInputTokens: 75_000, + contextWindow: 100_000, + })! + // 100k window, 75k used → 25k (25%) remaining. + expect(formatContextBudgetNotice(usage)).toBe( + `You have about 25k tokens (25%) of the context window remaining.` + ) + }) + + it(`never reports negative remaining when over the window`, () => { + const usage = computeContextUsage({ + contextInputTokens: 130_000, + contextWindow: 100_000, + })! + expect(formatContextBudgetNotice(usage)).toBe( + `You have about 0 tokens (0%) of the context window remaining.` + ) + }) +}) + +describe(`withContextBudgetNotice`, () => { + const user = (text: string): LLMMessage => ({ role: `user`, content: text }) + const lowUsage = computeContextUsage({ + contextInputTokens: 10_000, + contextWindow: 100_000, + }) + const highUsage = computeContextUsage({ + contextInputTokens: 80_000, + contextWindow: 100_000, + }) + + it(`leaves messages unchanged when usage is unknown`, () => { + const messages = [user(`hello`)] + expect(withContextBudgetNotice(messages, null)).toEqual(messages) + }) + + it(`leaves messages unchanged below the first threshold`, () => { + const messages = [user(`hello`)] + expect(withContextBudgetNotice(messages, lowUsage)).toEqual(messages) + }) + + it(`injects the notice just before the final message`, () => { + const messages = [user(`first`), user(`latest`)] + const result = withContextBudgetNotice(messages, highUsage) + expect(result).toHaveLength(3) + expect(result[0]).toEqual(user(`first`)) + expect(result[1]?.role).toBe(`user`) + expect(String(result[1]?.content)).toContain(``) + // The final message (the live turn) stays last so runInput detection + // that reads `.at(-1)` is unaffected. + expect(result[2]).toEqual(user(`latest`)) + }) + + it(`emits just the notice when there are no messages yet`, () => { + const result = withContextBudgetNotice([], highUsage) + expect(result).toHaveLength(1) + expect(String(result[0]?.content)).toContain(`token_budget`) + }) +}) From e9d46e6641d6025155fc8abaf2833ef8156b91a5 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:21:08 +0200 Subject: [PATCH 005/105] test(agents): end-to-end budget notice injection through runAgent Drive a real agent run with a capturing streamFn and assert the notice reaches the model's context, gated on a seeded step's usage: present at 80% usage (with correct "20k tokens (20%) remaining" wording), absent at 10%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/context-factory.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/agents-runtime/test/context-factory.test.ts b/packages/agents-runtime/test/context-factory.test.ts index 9749dcd22b..6fba774c82 100644 --- a/packages/agents-runtime/test/context-factory.test.ts +++ b/packages/agents-runtime/test/context-factory.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' import { getCronStreamPath } from '../src/cron-utils' import { createHandlerContext } from '../src/context-factory' import { ENTITY_COLLECTIONS } from '../src/entity-schema' @@ -495,4 +496,147 @@ describe(`createHandlerContext`, () => { ], }) }) + + // === Phase 1: budget notice end-to-end through the real runAgent path === + // Drives an actual agent run with a capturing streamFn and asserts the + // `` notice does (or doesn't) reach the model's context, + // gated on a seeded step's usage. This is the "prompt a model and check + // the notice is inserted" verification, made deterministic. + + function seedStep( + db: EntityStreamDBWithActions, + step: { + _seq: number + context_input_tokens: number + context_window: number + output_tokens?: number + } + ): void { + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-${step._seq}`, + run_id: `run-1`, + step_number: 1, + status: `completed`, + ...step, + }) + } + + function completedAssistantMessage(): unknown { + return { + role: `assistant`, + content: [{ type: `text`, text: `ok` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } + } + + async function captureModelContext( + db: EntityStreamDBWithActions + ): Promise { + const { ctx } = createHandlerContext({ + entityUrl: `/chat/test`, + entityType: `chat`, + epoch: 1, + wakeOffset: `-1`, + firstWake: false, + args: {}, + db, + sandbox: testSandboxStub, + state: {}, + actions: {}, + electricTools: [], + events: [] as Array, + writeEvent: vi.fn(), + wakeSession: { + getPhase: () => `active`, + registerManifestEntry: vi.fn(() => true), + removeManifestEntry: vi.fn(() => false), + commitManifestEntries: vi.fn(), + rollbackManifestEntries: vi.fn(), + registerSharedStateHandle: vi.fn(), + registerSpawnHandle: vi.fn(), + registerSourceHandle: vi.fn(), + enqueueSend: vi.fn(), + getManifest: vi.fn(() => []), + getPendingSends: vi.fn(() => []), + getSharedStateHandles: vi.fn(() => new Map()), + getSpawnHandles: vi.fn(() => new Map()), + getSourceHandles: vi.fn(() => new Map()), + finishSetup: vi.fn(() => ({ + manifest: [], + sharedStateHandles: new Map(), + spawnHandles: new Map(), + sourceHandles: new Map(), + })), + close: vi.fn(), + } as any, + wakeEvent: { + type: `inbox`, + payload: `hello`, + fromOffset: 0, + toOffset: 0, + eventCount: 1, + } as any, + doObserve: vi.fn(), + doSpawn: vi.fn(), + doFork: vi.fn(), + doMkdb: vi.fn(), + executeSend: vi.fn(), + tags: {}, + doSetTag: vi.fn().mockResolvedValue(undefined), + doDeleteTag: vi.fn().mockResolvedValue(undefined), + }) + + let captured: unknown = null + ctx.useAgent({ + systemPrompt: `test`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + streamFn: ((_model: unknown, context: unknown) => { + captured = context + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(completedAssistantMessage() as never)) + return stream + }) as never, + }) + + await ctx.agent.run(`hello`) + return JSON.stringify(captured) + } + + it(`injects the token budget notice into the model context at >=25% usage`, async () => { + const db = createMockDb([]) + seedStep(db, { + _seq: 1, + context_input_tokens: 80_000, + context_window: 100_000, + }) + const contextJson = await captureModelContext(db) + expect(contextJson).toContain(`token_budget`) + expect(contextJson).toContain(`remaining`) + }) + + it(`does not inject the notice below 25% usage`, async () => { + const db = createMockDb([]) + seedStep(db, { + _seq: 1, + context_input_tokens: 10_000, + context_window: 100_000, + }) + const contextJson = await captureModelContext(db) + expect(contextJson).not.toContain(`token_budget`) + }) }) From 354659a8ddf4d9b081ad4553b5c165b0077d2da2 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:26:59 +0200 Subject: [PATCH 006/105] feat(agents): truncate oversized tool results before model calls (phase 2) Cap any single tool_result at ~10k tokens, replacing the body with a visible "[Output truncated: ...]" placeholder before the model call, so one giant output can't fill the context window on its own. Preserves toolCallId/isError so tool-call pairing stays valid. Mirrors Codex's per-message truncation. Truncation is always explicit, never silent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/context-factory.ts | 9 +++++- packages/agents-runtime/src/index.ts | 2 ++ .../agents-runtime/src/token-accountant.ts | 32 ++++++++++++++++++- .../test/token-accountant.test.ts | 31 ++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 9c4606d9cf..96ab0c5ae6 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -19,6 +19,7 @@ import { runtimeLog } from './log' import { sliceChars } from './token-budget' import { selectLatestContextUsage, + truncateOversizedToolResults, withContextBudgetNotice, type ContextUsageStep, } from './token-accountant' @@ -790,7 +791,13 @@ export function createHandlerContext( const budgetUsage = selectLatestContextUsage( config.db.collections.steps.toArray as ReadonlyArray ) - const outgoingMessages = withContextBudgetNotice(messages, budgetUsage) + // Phase 2: cap any single oversized tool result before the model call + // (one huge output can fill the window on its own), then surface the + // budget notice. + const outgoingMessages = withContextBudgetNotice( + truncateOversizedToolResults(messages), + budgetUsage + ) const adapterFactory = createPiAgentAdapter({ systemPrompt: activeAgentConfig.systemPrompt, diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index dfa86c299e..8222347ab1 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -372,6 +372,8 @@ export { formatContextBudgetNotice, buildContextBudgetNotice, withContextBudgetNotice, + truncateOversizedToolResults, + CONTEXT_TOOL_OUTPUT_MAX_TOKENS, } from './token-accountant' export type { ContextUsage, diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index b7daa153db..b9b36ec13e 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -13,7 +13,7 @@ * same thresholds below. */ -import { formatTokenCount } from './token-budget' +import { approxTokens, formatTokenCount } from './token-budget' import type { LLMMessage } from './types' /** @@ -188,3 +188,33 @@ export function withContextBudgetNotice( if (messages.length === 0) return [notice] return [...messages.slice(0, -1), notice, messages[messages.length - 1]!] } + +/** + * Default cap on a single tool result's size before it is truncated. One giant + * tool output (a huge file read, a verbose command) can fill the window on its + * own; capping each result keeps any single one bounded. Mirrors Codex's + * per-message truncation. + */ +export const CONTEXT_TOOL_OUTPUT_MAX_TOKENS = 10_000 + +/** + * Replace any single `tool_result` whose content exceeds `maxTokens` with a + * visible placeholder. Truncation is explicit (never silent) and leaves the + * tool-call pairing intact (`toolCallId` / `isError` are preserved). Other + * message roles pass through untouched. + */ +export function truncateOversizedToolResults( + messages: ReadonlyArray, + maxTokens: number = CONTEXT_TOOL_OUTPUT_MAX_TOKENS +): Array { + return messages.map((message) => { + if (message.role !== `tool_result`) return message + if (approxTokens(message.content) <= maxTokens) return message + return { + ...message, + content: `[Output truncated: exceeded ${formatTokenCount( + maxTokens + )} tokens and was removed to fit the context window]`, + } + }) +} diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts index 44fa810663..2ddfa97ca7 100644 --- a/packages/agents-runtime/test/token-accountant.test.ts +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -9,6 +9,7 @@ import { selectLatestContextUsage, shouldSurfaceContextBudget, withContextBudgetNotice, + truncateOversizedToolResults, } from '../src/token-accountant' import type { LLMMessage } from '../src/types' @@ -187,3 +188,33 @@ describe(`withContextBudgetNotice`, () => { expect(String(result[0]?.content)).toContain(`token_budget`) }) }) + +describe(`truncateOversizedToolResults`, () => { + const toolResult = (content: string): LLMMessage => ({ + role: `tool_result`, + toolCallId: `call-1`, + isError: false, + content, + }) + + it(`replaces a tool result that exceeds the cap with a placeholder`, () => { + // approxTokens ≈ chars/4, so 400 chars ≈ 100 tokens > cap of 10. + const big = `x`.repeat(400) + const [out] = truncateOversizedToolResults([toolResult(big)], 10) + expect(String(out?.content)).toContain(`Output truncated`) + // pairing metadata is preserved so the tool_call/result stays valid + expect(out).toMatchObject({ role: `tool_result`, toolCallId: `call-1` }) + }) + + it(`leaves a tool result within the cap untouched`, () => { + const small = toolResult(`small output`) + const [out] = truncateOversizedToolResults([small], 10) + expect(out).toBe(small) + }) + + it(`never touches non-tool-result messages`, () => { + const user: LLMMessage = { role: `user`, content: `x`.repeat(400) } + const [out] = truncateOversizedToolResults([user], 10) + expect(out).toBe(user) + }) +}) From 64211b6548185504727a04384d58d1f635972e90 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:34:26 +0200 Subject: [PATCH 007/105] feat(agents): checkpoint-aware timeline reconstruction (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for context compaction: a compaction checkpoint is a context_inserted row tagged attrs.kind="compaction". timelineMessages now treats the newest such checkpoint's order as a watermark — items before it are dropped (summarized away) and the checkpoint renders the summary in their place. No checkpoint -> watermark is -Infinity -> a strict no-op, so this is inert until the summarizer (next step) writes one. Adds compaction.ts with the checkpoint constants and the Codex summarization prompt + summary prefix (reused verbatim) for the summarization step. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/compaction.ts | 51 ++++++++++++++ .../agents-runtime/src/timeline-context.ts | 28 ++++++++ .../test/timeline-compaction.test.ts | 68 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 packages/agents-runtime/src/compaction.ts create mode 100644 packages/agents-runtime/test/timeline-compaction.test.ts diff --git a/packages/agents-runtime/src/compaction.ts b/packages/agents-runtime/src/compaction.ts new file mode 100644 index 0000000000..da3f4eab48 --- /dev/null +++ b/packages/agents-runtime/src/compaction.ts @@ -0,0 +1,51 @@ +/** + * Context compaction (RFC §8/§9). + * + * When the conversation approaches the context window, it is summarized into a + * durable "checkpoint" — a `context_inserted` row tagged `kind: "compaction"` — + * and the messages it summarizes are dropped from the reconstructed history. + * The checkpoint is written at the head of the timeline, so its order acts as a + * watermark: timeline reconstruction hides everything before it and emits the + * summary in their place (see `timelineMessages`). + * + * Phase 2 wires this up synchronously at the 95% hard ceiling; Phase 3 adds the + * background trigger at 85%. + */ + +/** `attrs.kind` marking a `context_inserted` row as a compaction checkpoint. */ +export const COMPACTION_CHECKPOINT_KIND = `compaction` + +/** `name` (and thus the rendered tag) for a compaction checkpoint entry. */ +export const COMPACTION_CHECKPOINT_NAME = `compaction_summary` + +/** Stable id for the (single, self-superseding) compaction checkpoint entry. */ +export const COMPACTION_CHECKPOINT_ID = `compaction` + +/** + * Whether a `context_inserted` row's attrs mark it as a compaction checkpoint. + */ +export function isCompactionCheckpointAttrs( + attrs: Record | undefined +): boolean { + return attrs?.kind === COMPACTION_CHECKPOINT_KIND +} + +/** + * Summarization prompt, reused verbatim from OpenAI Codex (RFC Appendix A). + * Appended as a user message after the conversation being compacted. + */ +export const COMPACTION_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + +Include: +- Current progress and key decisions made +- Important context, constraints, or user preferences +- What remains to be done (clear next steps) +- Any critical data, examples, or references needed to continue + +Be concise, structured, and focused on helping the next LLM seamlessly continue the work.` + +/** + * Prefix prepended to the produced summary when it is reinserted as the + * checkpoint, reused verbatim from Codex (RFC Appendix A). + */ +export const COMPACTION_SUMMARY_PREFIX = `Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:` diff --git a/packages/agents-runtime/src/timeline-context.ts b/packages/agents-runtime/src/timeline-context.ts index 461430da4a..f0da074054 100644 --- a/packages/agents-runtime/src/timeline-context.ts +++ b/packages/agents-runtime/src/timeline-context.ts @@ -22,6 +22,7 @@ import type { TimestampedMessage, } from './types' import { COMPOSER_INPUT_MESSAGE_TYPE } from './composer-input' +import { isCompactionCheckpointAttrs } from './compaction' function asString(value: unknown, fallback = ``): string { if (typeof value === `string`) { @@ -468,10 +469,18 @@ export function timelineMessages( const attachmentsByInboxKey = attachmentsBySubjectInboxKey(db) const messages: Array = [] + // A compaction checkpoint summarizes everything before it: drop items below + // its order (the watermark) and let the checkpoint itself render the summary + // in their place. No checkpoint → watermark stays -Infinity → no-op. + const compactionWatermark = latestCompactionWatermark(items) + for (const item of items) { if (item.at < since) { continue } + if (item.at < compactionWatermark) { + continue + } for (const message of projection(item) ?? []) { messages.push({ @@ -484,6 +493,25 @@ export function timelineMessages( return messages } +/** + * Order (`at`) of the newest live compaction checkpoint, or -Infinity if none. + * Everything strictly below it has been summarized into the checkpoint. + */ +function latestCompactionWatermark(items: ReadonlyArray): number { + let watermark = Number.NEGATIVE_INFINITY + for (const item of items) { + if ( + item.kind === `context_inserted` && + !item.superseded && + isCompactionCheckpointAttrs(item.attrs) && + item.at > watermark + ) { + watermark = item.at + } + } + return watermark +} + export function timelineToMessages(db: EntityStreamDB): Array { return timelineMessages(db).map( ({ at: _at, ...message }) => message as LLMMessage diff --git a/packages/agents-runtime/test/timeline-compaction.test.ts b/packages/agents-runtime/test/timeline-compaction.test.ts new file mode 100644 index 0000000000..00f4a39d10 --- /dev/null +++ b/packages/agents-runtime/test/timeline-compaction.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { timelineToMessages } from '../src/timeline-context' +import { buildStreamFixture } from './helpers/context-test-helpers' + +function serialize(db: ReturnType): string { + return JSON.stringify(timelineToMessages(db)) +} + +describe(`timelineMessages compaction reconstruction`, () => { + it(`hides items before a compaction checkpoint and keeps the summary + later items`, () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { kind: `inbox`, at: 2, value: { payload: `SECOND_MESSAGE` } }, + { + kind: `context_inserted`, + at: 3, + value: { + id: `compaction`, + name: `compaction_summary`, + attrs: { kind: `compaction` }, + content: `SUMMARY_OF_EARLIER_WORK`, + }, + }, + { kind: `inbox`, at: 4, value: { payload: `LATEST_MESSAGE` } }, + ]) + + const out = serialize(db) + // Everything before the checkpoint is summarized away… + expect(out).not.toContain(`FIRST_MESSAGE`) + expect(out).not.toContain(`SECOND_MESSAGE`) + // …replaced by the summary, with post-checkpoint messages kept verbatim. + expect(out).toContain(`SUMMARY_OF_EARLIER_WORK`) + expect(out).toContain(`LATEST_MESSAGE`) + }) + + it(`is a no-op when there is no compaction checkpoint`, () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { kind: `inbox`, at: 2, value: { payload: `SECOND_MESSAGE` } }, + ]) + + const out = serialize(db) + expect(out).toContain(`FIRST_MESSAGE`) + expect(out).toContain(`SECOND_MESSAGE`) + }) + + it(`ignores a non-compaction context entry (no watermark)`, () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { + kind: `context_inserted`, + at: 2, + value: { + id: `note`, + name: `note`, + attrs: {}, + content: `JUST_A_NOTE`, + }, + }, + { kind: `inbox`, at: 3, value: { payload: `SECOND_MESSAGE` } }, + ]) + + const out = serialize(db) + // A plain context entry must NOT act as a compaction watermark. + expect(out).toContain(`FIRST_MESSAGE`) + expect(out).toContain(`SECOND_MESSAGE`) + }) +}) From d56268708c5dd62b34782a58e9dfc1c7d4a4b152 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:38:49 +0200 Subject: [PATCH 008/105] feat(agents): conversation summarization for compaction (phase 2) summarizeMessages sends the full history + Codex's summarization prompt to the conversation's own model (a cheap small-window model would overflow a near-full context) and prefixes the result with Codex's summary preamble. The model call is injected via a `complete` seam (defaults to pi-ai completeSimple) so it is unit-testable without a network call or API key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/compaction-summarize.ts | 75 +++++++++++++++++++ .../test/compaction-summarize.test.ts | 57 ++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 packages/agents-runtime/src/compaction-summarize.ts create mode 100644 packages/agents-runtime/test/compaction-summarize.test.ts diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts new file mode 100644 index 0000000000..79f1837592 --- /dev/null +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -0,0 +1,75 @@ +import { completeSimple } from '@mariozechner/pi-ai' +import { resolvePiModel, toAgentHistory } from './pi-adapter' +import { + COMPACTION_SUMMARIZATION_PROMPT, + COMPACTION_SUMMARY_PREFIX, +} from './compaction' +import type { LLMMessage } from './types' + +/** + * The model-call seam for summarization. Defaults to pi-ai's `completeSimple`; + * tests inject a stub so the summarizer is exercisable without a network call + * or API key. Typed loosely on purpose — it only needs the text content back. + */ +export type SummarizeCompleteFn = ( + model: unknown, + context: { messages: Array; systemPrompt?: string }, + options?: Record +) => Promise<{ + content: Array<{ type: string; text?: string }> + stopReason?: string + errorMessage?: string +}> + +const DEFAULT_SUMMARY_MAX_TOKENS = 2048 + +/** + * Summarize a conversation into a compaction handoff summary. + * + * Uses the **conversation's own model** by default (RFC Q2): a cheaper, + * small-window model would overflow on a near-full context — the whole reason + * we are compacting. The full history is sent followed by Codex's summarization + * prompt; the returned summary is prefixed with Codex's summary preamble so the + * resuming model knows it is reading a handoff. + */ +export async function summarizeMessages(input: { + model: string | object + provider?: string + messages: ReadonlyArray + apiKey?: string + maxTokens?: number + complete?: SummarizeCompleteFn +}): Promise { + const complete = + input.complete ?? (completeSimple as unknown as SummarizeCompleteFn) + const model = resolvePiModel({ + model: input.model as never, + ...(input.provider && { provider: input.provider as never }), + }) + const history = toAgentHistory([...input.messages]) + const context = { + messages: [ + ...history, + { + role: `user`, + content: COMPACTION_SUMMARIZATION_PROMPT, + timestamp: Date.now(), + }, + ], + } + + const res = await complete(model, context, { + maxTokens: input.maxTokens ?? DEFAULT_SUMMARY_MAX_TOKENS, + ...(input.apiKey && { apiKey: input.apiKey }), + }) + + const textBlock = res.content.find((block) => block.type === `text`) + const text = textBlock && `text` in textBlock ? (textBlock.text ?? ``) : `` + if (text.trim().length === 0) { + throw new Error( + `[compaction] empty summary (stopReason=${res.stopReason ?? `none`} error=${res.errorMessage ?? `none`})` + ) + } + + return `${COMPACTION_SUMMARY_PREFIX}\n${text}` +} diff --git a/packages/agents-runtime/test/compaction-summarize.test.ts b/packages/agents-runtime/test/compaction-summarize.test.ts new file mode 100644 index 0000000000..0c993c9e61 --- /dev/null +++ b/packages/agents-runtime/test/compaction-summarize.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { summarizeMessages } from '../src/compaction-summarize' +import type { SummarizeCompleteFn } from '../src/compaction-summarize' +import type { LLMMessage } from '../src/types' + +const messages: Array = [ + { role: `user`, content: `please build a thing` }, + { role: `assistant`, content: `working on it` }, +] + +describe(`summarizeMessages`, () => { + it(`sends history + the summarization prompt and prefixes the result`, async () => { + let sawPrompt = false + let sawHistory = false + const complete: SummarizeCompleteFn = async (_model, context) => { + // History content is normalized into text blocks, so search the + // serialized messages rather than assuming a string content. + const serialized = JSON.stringify(context.messages) + sawHistory = serialized.includes(`please build a thing`) + sawPrompt = serialized.includes(`CONTEXT CHECKPOINT COMPACTION`) + // the prompt must be the LAST message + expect(JSON.stringify(context.messages.at(-1))).toContain( + `CONTEXT CHECKPOINT COMPACTION` + ) + return { content: [{ type: `text`, text: `SUMMARY_BODY` }] } + } + + const out = await summarizeMessages({ + model: `claude-sonnet-4-5-20250929`, + messages, + complete, + }) + + expect(sawHistory).toBe(true) + expect(sawPrompt).toBe(true) + // Codex summary preamble is prepended… + expect(out).toContain( + `Another language model started to solve this problem` + ) + // …followed by the model's summary body. + expect(out).toContain(`SUMMARY_BODY`) + }) + + it(`throws when the model returns an empty summary`, async () => { + const complete: SummarizeCompleteFn = async () => ({ + content: [{ type: `text`, text: ` ` }], + stopReason: `stop`, + }) + await expect( + summarizeMessages({ + model: `claude-sonnet-4-5-20250929`, + messages, + complete, + }) + ).rejects.toThrow(/empty summary/) + }) +}) From 80336861539198d901e9f69d01de009f4ab1c450 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 16:45:43 +0200 Subject: [PATCH 009/105] feat(agents): synchronous compaction at the 95% hard ceiling (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the compaction engine into the model-call path. Before a turn, if the last step left context at/over 95% of the window (and the history is actually large), summarize it, persist a compaction checkpoint (context_inserted kind=compaction), and send only the summary this turn — the current ask still arrives via runInput, and future turns reconstruct from the checkpoint watermark. Failure degrades gracefully (logs and proceeds uncompacted). - AgentConfig.summarizeComplete: model-call seam for the summarizer (defaults to the conversation model; injected by tests). - guard on estimated history size avoids re-compacting an already compacted (small) history while the last step's usage reads stale-high. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/compaction-summarize.ts | 17 +-- .../agents-runtime/src/context-factory.ts | 75 +++++++++- packages/agents-runtime/src/types.ts | 20 +++ .../test/compaction-trigger.test.ts | 128 ++++++++++++++++++ 4 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 packages/agents-runtime/test/compaction-trigger.test.ts diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts index 79f1837592..b3c76b9190 100644 --- a/packages/agents-runtime/src/compaction-summarize.ts +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -4,22 +4,9 @@ import { COMPACTION_SUMMARIZATION_PROMPT, COMPACTION_SUMMARY_PREFIX, } from './compaction' -import type { LLMMessage } from './types' +import type { LLMMessage, SummarizeCompleteFn } from './types' -/** - * The model-call seam for summarization. Defaults to pi-ai's `completeSimple`; - * tests inject a stub so the summarizer is exercisable without a network call - * or API key. Typed loosely on purpose — it only needs the text content back. - */ -export type SummarizeCompleteFn = ( - model: unknown, - context: { messages: Array; systemPrompt?: string }, - options?: Record -) => Promise<{ - content: Array<{ type: string; text?: string }> - stopReason?: string - errorMessage?: string -}> +export type { SummarizeCompleteFn } const DEFAULT_SUMMARY_MAX_TOKENS = 2048 diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 96ab0c5ae6..f3baf3cf9f 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -21,8 +21,16 @@ import { selectLatestContextUsage, truncateOversizedToolResults, withContextBudgetNotice, + CONTEXT_USAGE_HARD_CEILING, type ContextUsageStep, } from './token-accountant' +import { approxTokens } from './token-budget' +import { + COMPACTION_CHECKPOINT_ID, + COMPACTION_CHECKPOINT_KIND, + COMPACTION_CHECKPOINT_NAME, +} from './compaction' +import { summarizeMessages } from './compaction-summarize' import { createContextTools } from './tools/context-tools' import { CACHE_TIERS } from './types' import { composeToolsWithProviders } from './tool-providers' @@ -791,12 +799,69 @@ export function createHandlerContext( const budgetUsage = selectLatestContextUsage( config.db.collections.steps.toArray as ReadonlyArray ) - // Phase 2: cap any single oversized tool result before the model call - // (one huge output can fill the window on its own), then surface the - // budget notice. + + // Phase 2 hard ceiling: if the last turn left context at/over 95% of + // the window, compact synchronously before this model call. Summarize + // the current history, persist a compaction checkpoint (so future turns + // reconstruct from it via the timeline watermark), and send only the + // summary this turn — the current ask is delivered separately as + // runInput. The `approxTokens` guard avoids re-compacting an already + // compacted (small) history while the last step's usage is still high. + let workingMessages = messages + let didCompact = false + const estimatedHistoryTokens = messages.reduce( + (sum, message) => sum + approxTokens(message.content), + 0 + ) + if ( + budgetUsage && + budgetUsage.ratio >= CONTEXT_USAGE_HARD_CEILING && + estimatedHistoryTokens > budgetUsage.contextWindow / 2 + ) { + const logPrefix = `[${config.entityUrl}]` + try { + const provider = agentModelProvider(activeAgentConfig) + const apiKey = await activeAgentConfig.getApiKey?.(provider) + const summary = await summarizeMessages({ + model: activeAgentConfig.model, + provider, + messages, + ...(apiKey ? { apiKey } : {}), + ...(activeAgentConfig.summarizeComplete + ? { complete: activeAgentConfig.summarizeComplete } + : {}), + }) + contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { + name: COMPACTION_CHECKPOINT_NAME, + attrs: { kind: COMPACTION_CHECKPOINT_KIND }, + content: summary, + }) + workingMessages = [ + { + role: `user`, + content: `<${COMPACTION_CHECKPOINT_NAME}>\n${summary}\n`, + }, + ] + didCompact = true + runtimeLog.info( + logPrefix, + `compaction: summarized ${messages.length} messages at ratio=${budgetUsage.ratio.toFixed(2)} window=${budgetUsage.contextWindow}` + ) + } catch (error) { + runtimeLog.warn( + logPrefix, + `compaction failed; proceeding uncompacted: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + // Cap any single oversized tool result (one huge output can fill the + // window on its own), then surface the budget notice. After a fresh + // compaction the last step's ratio is stale, so skip the notice this + // turn rather than report a misleading "~0% remaining". const outgoingMessages = withContextBudgetNotice( - truncateOversizedToolResults(messages), - budgetUsage + truncateOversizedToolResults(workingMessages), + didCompact ? null : budgetUsage ) const adapterFactory = createPiAgentAdapter({ diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 7e4759ff8e..8cee5d4722 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -976,8 +976,28 @@ export interface AgentConfig { modelTimeoutMs?: number modelMaxRetries?: number testResponses?: TestResponses + // Model-call seam for context-compaction summarization. Defaults to pi-ai's + // completeSimple (i.e. the conversation model). Injected by tests, and a hook + // for routing summarization to a different model later. + summarizeComplete?: SummarizeCompleteFn } +/** + * Model-call seam for compaction summarization. Typed loosely (only the text + * content is needed back) so it matches pi-ai's `completeSimple` without + * importing its types here. Defined in types.ts to keep `compaction-summarize` + * → `types` a one-way dependency. + */ +export type SummarizeCompleteFn = ( + model: unknown, + context: { messages: Array; systemPrompt?: string }, + options?: Record +) => Promise<{ + content: Array<{ type: string; text?: string }> + stopReason?: string + errorMessage?: string +}> + export type TestResponses = Array | TestResponseFn export type TestResponseFn = ( diff --git a/packages/agents-runtime/test/compaction-trigger.test.ts b/packages/agents-runtime/test/compaction-trigger.test.ts new file mode 100644 index 0000000000..a3f70b4db1 --- /dev/null +++ b/packages/agents-runtime/test/compaction-trigger.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { + buildStreamFixture, + createTestHandlerContext, +} from './helpers/context-test-helpers' +import type { ChangeEvent } from '@durable-streams/state' + +function completedAssistantMessage(): unknown { + return { + role: `assistant`, + content: [{ type: `text`, text: `ok` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } +} + +describe(`synchronous compaction trigger`, () => { + it(`compacts at the 95% hard ceiling: model sees the summary, checkpoint persisted`, async () => { + // A large prior message (≈1000 tokens) and a step reporting 98% usage of a + // small (test) 1000-token window — over the 95% ceiling and worth + // compacting (approxTokens > window/2). + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `A`.repeat(4000) } }, + ]) + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-1`, + _seq: 1, + run_id: `r`, + step_number: 1, + status: `completed`, + context_input_tokens: 980, + context_window: 1000, + }) + + const writes: Array = [] + const { ctx } = createTestHandlerContext({ + db, + writeEvent: (event: ChangeEvent) => { + writes.push(event) + db.utils.applyEvent(event) + }, + }) + + let captured = `` + let summarizeCalled = false + ctx.useAgent({ + systemPrompt: `test`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + summarizeComplete: async () => { + summarizeCalled = true + return { content: [{ type: `text`, text: `COMPACTED_SUMMARY` }] } + }, + streamFn: ((_model: unknown, context: unknown) => { + captured = JSON.stringify(context) + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(completedAssistantMessage() as never)) + return stream + }) as never, + }) + + await ctx.agent.run(`continue`) + + // Summarizer ran… + expect(summarizeCalled).toBe(true) + // …the model saw the summary, not the giant original message… + expect(captured).toContain(`COMPACTED_SUMMARY`) + expect(captured).not.toContain(`AAAAAAAAAAAAAAAAAAAA`) + // …and a compaction checkpoint was persisted for future turns. + const checkpoint = writes.find( + (event) => + (event.value as { attrs?: { kind?: string } } | undefined)?.attrs + ?.kind === `compaction` + ) + expect(checkpoint).toBeTruthy() + }) + + it(`does not compact below the hard ceiling`, async () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `A`.repeat(4000) } }, + ]) + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-1`, + _seq: 1, + run_id: `r`, + step_number: 1, + status: `completed`, + context_input_tokens: 500, // 50% — below the 95% ceiling + context_window: 1000, + }) + + let summarizeCalled = false + const { ctx } = createTestHandlerContext({ db }) + ctx.useAgent({ + systemPrompt: `test`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + summarizeComplete: async () => { + summarizeCalled = true + return { content: [{ type: `text`, text: `X` }] } + }, + streamFn: ((_model: unknown) => { + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(completedAssistantMessage() as never)) + return stream + }) as never, + }) + + await ctx.agent.run(`continue`) + expect(summarizeCalled).toBe(false) + }) +}) From 5ff491c0d812edbb7f4af2b6527bc6465cf070f9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 15 Jun 2026 17:08:34 +0200 Subject: [PATCH 010/105] feat(agents): lower blocking compaction ceiling to 90% (match Codex) CONTEXT_USAGE_HARD_CEILING 0.95 -> 0.90, matching Codex's auto-compaction threshold. Drives the synchronous compaction trigger and the UI gauge's "critical" colour. Background start stays at 85%. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/token-accountant.ts | 6 +++--- .../src/components/ContextUsageIndicator.module.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index b9b36ec13e..e7ee013b04 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -24,12 +24,12 @@ import type { LLMMessage } from './types' * - `AWARENESS` (25/50/75%): inject a budget notice so the model can pace * itself (Phase 1). * - `BACKGROUND_START` (85%): kick off background compaction (Phase 3). - * - `HARD_CEILING` (95%): usable ceiling; compact/truncate synchronously - * before the next model call (Phase 2). + * - `HARD_CEILING` (90%): compact synchronously before the next model call + * (Phase 2). Matches Codex's auto-compaction threshold. */ export const CONTEXT_USAGE_AWARENESS_THRESHOLDS = [0.25, 0.5, 0.75] as const export const CONTEXT_USAGE_BACKGROUND_START = 0.85 -export const CONTEXT_USAGE_HARD_CEILING = 0.95 +export const CONTEXT_USAGE_HARD_CEILING = 0.9 export interface ContextUsageInput { /** diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css index acb6fb4ec3..ec502c41d2 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css @@ -25,7 +25,7 @@ } /* Levels mirror the compaction thresholds: warning once background - compaction would start (85%), critical at the hard ceiling (95%). */ + compaction would start (85%), critical at the hard ceiling (90%). */ .normal { color: var(--text-muted, rgba(0, 0, 0, 0.55)); } From fc119652f0bd72ad6d718057750f380e6942c1cc Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 09:37:49 +0200 Subject: [PATCH 011/105] test(agents): gated live compaction test against a real model Drives the real runtime end-to-end and makes a real Anthropic summarization call, asserting the returned summary carries the Codex prefix and retains key conversation facts. Skipped unless RUN_LIVE_COMPACTION=1 + LIVE_ANTHROPIC_API_KEY are set, so it never runs (or costs) in CI. Verified passing with claude-haiku-4-5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/compaction-live.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/agents-runtime/test/compaction-live.test.ts diff --git a/packages/agents-runtime/test/compaction-live.test.ts b/packages/agents-runtime/test/compaction-live.test.ts new file mode 100644 index 0000000000..6b1a1d0b89 --- /dev/null +++ b/packages/agents-runtime/test/compaction-live.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { + buildStreamFixture, + createTestHandlerContext, +} from './helpers/context-test-helpers' +import type { ChangeEvent } from '@durable-streams/state' + +// Gated live test: drives the real agent runtime and makes a REAL summarization +// call to Anthropic. Requires a STANDARD api key (sk-ant-api…, used as +// x-api-key), not an OAuth token. Run with: +// LIVE_ANTHROPIC_API_KEY="$(cat /tmp/anthropic_key)" \ +// RUN_LIVE_COMPACTION=1 npx vitest run test/compaction-live.test.ts +const LIVE = Boolean(process.env.RUN_LIVE_COMPACTION) + +function stubbedAgentReply(): unknown { + return { + role: `assistant`, + content: [{ type: `text`, text: `ok` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `stub`, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } +} + +describe.skipIf(!LIVE)(`LIVE compaction with a real model`, () => { + it(`summarizes a real conversation and persists the checkpoint`, async () => { + const model = process.env.LIVE_MODEL ?? `claude-haiku-4-5-20251001` + const apiKey = process.env.LIVE_ANTHROPIC_API_KEY + expect( + apiKey, + `LIVE_ANTHROPIC_API_KEY must be a sk-ant-api… key` + ).toBeTruthy() + + // A realistic multi-topic conversation, inflated so the history exceeds the + // (small, test) window's half — and a step reporting 95% of that window so + // the 90% ceiling fires. + const conversation = [ + `We are building a CLI todo app in Rust. We chose clap for argument parsing and serde_json to persist tasks in ~/.todos.json.`, + `Implemented the add, list, and done subcommands. "done" marks a task complete by its 1-based index in the list.`, + `Open decisions: the user prefers minimal dependencies and wants colored terminal output via the owo-colors crate.`, + `Still TODO: an "rm" subcommand to delete tasks, optional due dates (parsed with chrono), and a --json output mode for scripting.`, + `A bug was found: completing a task shifts indices, so completing two in a row by index hits the wrong task. We plan to switch to stable task IDs.`, + ] + .join(`\n\n`) + .repeat(30) + + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: conversation } }, + ]) + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-1`, + _seq: 1, + run_id: `r`, + step_number: 1, + status: `completed`, + context_input_tokens: 3800, + context_window: 4000, + }) + + const writes: Array = [] + const { ctx } = createTestHandlerContext({ + db, + writeEvent: (event: ChangeEvent) => { + writes.push(event) + db.utils.applyEvent(event) + }, + }) + + ctx.useAgent({ + systemPrompt: `You are a helpful coding assistant.`, + model, + provider: `anthropic`, + tools: [], + getApiKey: () => apiKey, + // Stub the agent's own reply so ONLY the summarization hits the real API. + streamFn: ((_model: unknown) => { + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(stubbedAgentReply() as never)) + return stream + }) as never, + // summarizeComplete left undefined → real pi-ai completeSimple (real model) + }) + + await ctx.agent.run(`Please continue with the rm subcommand.`) + + const checkpoint = writes.find( + (event) => + (event.value as { attrs?: { kind?: string } } | undefined)?.attrs + ?.kind === `compaction` + ) + expect(checkpoint, `compaction checkpoint must be persisted`).toBeTruthy() + + const summary = String( + (checkpoint!.value as { content?: unknown }).content ?? `` + ) + + console.log( + `\n===== REAL MODEL SUMMARY (${model}) =====\n${summary}\n========================================\n` + ) + + expect(summary).toContain(`Another language model started`) // Codex prefix + expect(summary.length).toBeGreaterThan(150) + + // The summary should mention concrete facts from the conversation. + const mentions = [`rust`, `todo`, `clap`, `index`, `rm`].filter((term) => + summary.toLowerCase().includes(term) + ) + expect( + mentions.length, + `summary should retain key facts; matched: ${mentions.join(`,`)}` + ).toBeGreaterThanOrEqual(2) + }, 60_000) +}) From ec3d9528cd9efeadcf48f319ca9556d493b3d54b Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 09:48:45 +0200 Subject: [PATCH 012/105] test(agents): remove one-off gated live compaction test Its purpose (confirming a real model summarizes correctly through the compaction path) is done. Ongoing coverage lives in the deterministic stubbed tests (compaction-trigger, compaction-summarize, timeline-compaction); a live test pinning a model id + needing a paid key would only rot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/compaction-live.test.ts | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 packages/agents-runtime/test/compaction-live.test.ts diff --git a/packages/agents-runtime/test/compaction-live.test.ts b/packages/agents-runtime/test/compaction-live.test.ts deleted file mode 100644 index 6b1a1d0b89..0000000000 --- a/packages/agents-runtime/test/compaction-live.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' -import { - buildStreamFixture, - createTestHandlerContext, -} from './helpers/context-test-helpers' -import type { ChangeEvent } from '@durable-streams/state' - -// Gated live test: drives the real agent runtime and makes a REAL summarization -// call to Anthropic. Requires a STANDARD api key (sk-ant-api…, used as -// x-api-key), not an OAuth token. Run with: -// LIVE_ANTHROPIC_API_KEY="$(cat /tmp/anthropic_key)" \ -// RUN_LIVE_COMPACTION=1 npx vitest run test/compaction-live.test.ts -const LIVE = Boolean(process.env.RUN_LIVE_COMPACTION) - -function stubbedAgentReply(): unknown { - return { - role: `assistant`, - content: [{ type: `text`, text: `ok` }], - api: `anthropic-messages`, - provider: `anthropic`, - model: `stub`, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: `stop`, - timestamp: Date.now(), - } -} - -describe.skipIf(!LIVE)(`LIVE compaction with a real model`, () => { - it(`summarizes a real conversation and persists the checkpoint`, async () => { - const model = process.env.LIVE_MODEL ?? `claude-haiku-4-5-20251001` - const apiKey = process.env.LIVE_ANTHROPIC_API_KEY - expect( - apiKey, - `LIVE_ANTHROPIC_API_KEY must be a sk-ant-api… key` - ).toBeTruthy() - - // A realistic multi-topic conversation, inflated so the history exceeds the - // (small, test) window's half — and a step reporting 95% of that window so - // the 90% ceiling fires. - const conversation = [ - `We are building a CLI todo app in Rust. We chose clap for argument parsing and serde_json to persist tasks in ~/.todos.json.`, - `Implemented the add, list, and done subcommands. "done" marks a task complete by its 1-based index in the list.`, - `Open decisions: the user prefers minimal dependencies and wants colored terminal output via the owo-colors crate.`, - `Still TODO: an "rm" subcommand to delete tasks, optional due dates (parsed with chrono), and a --json output mode for scripting.`, - `A bug was found: completing a task shifts indices, so completing two in a row by index hits the wrong task. We plan to switch to stable task IDs.`, - ] - .join(`\n\n`) - .repeat(30) - - const db = buildStreamFixture([ - { kind: `inbox`, at: 1, value: { payload: conversation } }, - ]) - ;( - db.collections as unknown as { steps: { insert: (r: unknown) => void } } - ).steps.insert({ - key: `step-1`, - _seq: 1, - run_id: `r`, - step_number: 1, - status: `completed`, - context_input_tokens: 3800, - context_window: 4000, - }) - - const writes: Array = [] - const { ctx } = createTestHandlerContext({ - db, - writeEvent: (event: ChangeEvent) => { - writes.push(event) - db.utils.applyEvent(event) - }, - }) - - ctx.useAgent({ - systemPrompt: `You are a helpful coding assistant.`, - model, - provider: `anthropic`, - tools: [], - getApiKey: () => apiKey, - // Stub the agent's own reply so ONLY the summarization hits the real API. - streamFn: ((_model: unknown) => { - const stream = createAssistantMessageEventStream() - queueMicrotask(() => stream.end(stubbedAgentReply() as never)) - return stream - }) as never, - // summarizeComplete left undefined → real pi-ai completeSimple (real model) - }) - - await ctx.agent.run(`Please continue with the rm subcommand.`) - - const checkpoint = writes.find( - (event) => - (event.value as { attrs?: { kind?: string } } | undefined)?.attrs - ?.kind === `compaction` - ) - expect(checkpoint, `compaction checkpoint must be persisted`).toBeTruthy() - - const summary = String( - (checkpoint!.value as { content?: unknown }).content ?? `` - ) - - console.log( - `\n===== REAL MODEL SUMMARY (${model}) =====\n${summary}\n========================================\n` - ) - - expect(summary).toContain(`Another language model started`) // Codex prefix - expect(summary.length).toBeGreaterThan(150) - - // The summary should mention concrete facts from the conversation. - const mentions = [`rust`, `todo`, `clap`, `index`, `rm`].filter((term) => - summary.toLowerCase().includes(term) - ) - expect( - mentions.length, - `summary should retain key facts; matched: ${mentions.join(`,`)}` - ).toBeGreaterThanOrEqual(2) - }, 60_000) -}) From d8024c4e78758a9eab4778e5c61de040510e804d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 10:30:04 +0200 Subject: [PATCH 013/105] feat(agents): compaction checkpoint status lifecycle (running/complete/failed) Foundation for surfacing synchronous compaction in the UI. The trigger now writes a `running` checkpoint before summarizing and updates it to `complete` (with the summary) or `failed` after. Only a `complete` checkpoint acts as the reconstruction watermark, so an in-flight or crashed compaction never hides history; running/failed checkpoints are UI-only markers, skipped from the model context. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/compaction.ts | 24 +++++++++++- .../agents-runtime/src/context-factory.ts | 39 +++++++++++++------ .../agents-runtime/src/timeline-context.ts | 37 +++++++++++++----- .../test/compaction-trigger.test.ts | 22 +++++++---- .../test/timeline-compaction.test.ts | 26 ++++++++++++- 5 files changed, 118 insertions(+), 30 deletions(-) diff --git a/packages/agents-runtime/src/compaction.ts b/packages/agents-runtime/src/compaction.ts index da3f4eab48..bea6f5dd34 100644 --- a/packages/agents-runtime/src/compaction.ts +++ b/packages/agents-runtime/src/compaction.ts @@ -22,7 +22,16 @@ export const COMPACTION_CHECKPOINT_NAME = `compaction_summary` export const COMPACTION_CHECKPOINT_ID = `compaction` /** - * Whether a `context_inserted` row's attrs mark it as a compaction checkpoint. + * Lifecycle of a compaction checkpoint, carried in `attrs.status`: + * - `running` — summarization in flight (UI shows a live "Compacting…" entry) + * - `complete` — summary ready; acts as the timeline watermark + * - `failed` — summarization failed; turn proceeded uncompacted + */ +export type CompactionStatus = `running` | `complete` | `failed` + +/** + * Whether a `context_inserted` row's attrs mark it as a compaction checkpoint + * (any status). */ export function isCompactionCheckpointAttrs( attrs: Record | undefined @@ -30,6 +39,19 @@ export function isCompactionCheckpointAttrs( return attrs?.kind === COMPACTION_CHECKPOINT_KIND } +/** + * Whether attrs mark a *completed* compaction checkpoint — the only state that + * acts as the reconstruction watermark. A `running` (or crashed) checkpoint + * must never hide history. + */ +export function isCompleteCompactionCheckpointAttrs( + attrs: Record | undefined +): boolean { + return ( + attrs?.kind === COMPACTION_CHECKPOINT_KIND && attrs?.status === `complete` + ) +} + /** * Summarization prompt, reused verbatim from OpenAI Codex (RFC Appendix A). * Appended as a user message after the conversation being compacted. diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index f3baf3cf9f..a585a2a9de 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -29,6 +29,7 @@ import { COMPACTION_CHECKPOINT_ID, COMPACTION_CHECKPOINT_KIND, COMPACTION_CHECKPOINT_NAME, + type CompactionStatus, } from './compaction' import { summarizeMessages } from './compaction-summarize' import { createContextTools } from './tools/context-tools' @@ -800,13 +801,15 @@ export function createHandlerContext( config.db.collections.steps.toArray as ReadonlyArray ) - // Phase 2 hard ceiling: if the last turn left context at/over 95% of - // the window, compact synchronously before this model call. Summarize - // the current history, persist a compaction checkpoint (so future turns - // reconstruct from it via the timeline watermark), and send only the - // summary this turn — the current ask is delivered separately as - // runInput. The `approxTokens` guard avoids re-compacting an already - // compacted (small) history while the last step's usage is still high. + // Phase 2 hard ceiling: if the last turn left context at/over 90% of + // the window, compact synchronously before this model call. We surface + // the work as a status'd checkpoint row so the UI can show it live: + // insert a `running` checkpoint, summarize, then mark it `complete` + // with the summary (or `failed`). Only a `complete` checkpoint acts as + // the timeline watermark, so a crash mid-compaction never hides history. + // The current ask is delivered separately as runInput. The `approxTokens` + // guard avoids re-compacting an already compacted (small) history while + // the last step's usage is still high. let workingMessages = messages let didCompact = false const estimatedHistoryTokens = messages.reduce( @@ -819,6 +822,18 @@ export function createHandlerContext( estimatedHistoryTokens > budgetUsage.contextWindow / 2 ) { const logPrefix = `[${config.entityUrl}]` + const writeCheckpoint = ( + status: CompactionStatus, + content: string + ): void => { + contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { + name: COMPACTION_CHECKPOINT_NAME, + attrs: { kind: COMPACTION_CHECKPOINT_KIND, status }, + content, + }) + } + // Live: announce compaction is in progress (UI shows "Compacting…"). + writeCheckpoint(`running`, ``) try { const provider = agentModelProvider(activeAgentConfig) const apiKey = await activeAgentConfig.getApiKey?.(provider) @@ -831,11 +846,9 @@ export function createHandlerContext( ? { complete: activeAgentConfig.summarizeComplete } : {}), }) - contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { - name: COMPACTION_CHECKPOINT_NAME, - attrs: { kind: COMPACTION_CHECKPOINT_KIND }, - content: summary, - }) + // Complete: this supersedes the `running` row and becomes the + // watermark; future turns reconstruct from it. + writeCheckpoint(`complete`, summary) workingMessages = [ { role: `user`, @@ -848,6 +861,8 @@ export function createHandlerContext( `compaction: summarized ${messages.length} messages at ratio=${budgetUsage.ratio.toFixed(2)} window=${budgetUsage.contextWindow}` ) } catch (error) { + // Mark failed (clears the UI indicator) and proceed uncompacted. + writeCheckpoint(`failed`, ``) runtimeLog.warn( logPrefix, `compaction failed; proceeding uncompacted: ${error instanceof Error ? error.message : String(error)}` diff --git a/packages/agents-runtime/src/timeline-context.ts b/packages/agents-runtime/src/timeline-context.ts index f0da074054..9c85dc7618 100644 --- a/packages/agents-runtime/src/timeline-context.ts +++ b/packages/agents-runtime/src/timeline-context.ts @@ -22,7 +22,10 @@ import type { TimestampedMessage, } from './types' import { COMPOSER_INPUT_MESSAGE_TYPE } from './composer-input' -import { isCompactionCheckpointAttrs } from './compaction' +import { + isCompactionCheckpointAttrs, + isCompleteCompactionCheckpointAttrs, +} from './compaction' function asString(value: unknown, fallback = ``): string { if (typeof value === `string`) { @@ -469,10 +472,12 @@ export function timelineMessages( const attachmentsByInboxKey = attachmentsBySubjectInboxKey(db) const messages: Array = [] - // A compaction checkpoint summarizes everything before it: drop items below - // its order (the watermark) and let the checkpoint itself render the summary - // in their place. No checkpoint → watermark stays -Infinity → no-op. - const compactionWatermark = latestCompactionWatermark(items) + // A completed compaction checkpoint summarizes everything before it: drop + // items below its order (the watermark) and let the checkpoint itself render + // the summary in their place. Only `complete` checkpoints count, so a + // `running` (or crashed) one never hides history. No checkpoint → watermark + // stays -Infinity → no-op. + const compactionWatermark = latestCompleteCompactionWatermark(items) for (const item of items) { if (item.at < since) { @@ -481,6 +486,16 @@ export function timelineMessages( if (item.at < compactionWatermark) { continue } + // Compaction checkpoints are UI-only markers, except the active (complete, + // watermark) one, which renders the summary. Skip running/failed/older + // checkpoints from the model context. + if ( + item.kind === `context_inserted` && + isCompactionCheckpointAttrs(item.attrs) && + item.at !== compactionWatermark + ) { + continue + } for (const message of projection(item) ?? []) { messages.push({ @@ -494,16 +509,20 @@ export function timelineMessages( } /** - * Order (`at`) of the newest live compaction checkpoint, or -Infinity if none. - * Everything strictly below it has been summarized into the checkpoint. + * Order (`at`) of the newest live *completed* compaction checkpoint, or + * -Infinity if none. Everything strictly below it has been summarized into the + * checkpoint. `running`/`failed` checkpoints are ignored so an in-flight or + * crashed compaction never hides history. */ -function latestCompactionWatermark(items: ReadonlyArray): number { +function latestCompleteCompactionWatermark( + items: ReadonlyArray +): number { let watermark = Number.NEGATIVE_INFINITY for (const item of items) { if ( item.kind === `context_inserted` && !item.superseded && - isCompactionCheckpointAttrs(item.attrs) && + isCompleteCompactionCheckpointAttrs(item.attrs) && item.at > watermark ) { watermark = item.at diff --git a/packages/agents-runtime/test/compaction-trigger.test.ts b/packages/agents-runtime/test/compaction-trigger.test.ts index a3f70b4db1..7eddbc0b82 100644 --- a/packages/agents-runtime/test/compaction-trigger.test.ts +++ b/packages/agents-runtime/test/compaction-trigger.test.ts @@ -80,13 +80,21 @@ describe(`synchronous compaction trigger`, () => { // …the model saw the summary, not the giant original message… expect(captured).toContain(`COMPACTED_SUMMARY`) expect(captured).not.toContain(`AAAAAAAAAAAAAAAAAAAA`) - // …and a compaction checkpoint was persisted for future turns. - const checkpoint = writes.find( - (event) => - (event.value as { attrs?: { kind?: string } } | undefined)?.attrs - ?.kind === `compaction` - ) - expect(checkpoint).toBeTruthy() + // …and the checkpoint went through the running → complete lifecycle + // (so the UI can show a live "compacting" entry then the final marker). + const statuses = writes + .map( + (event) => + ( + event.value as + | { attrs?: { kind?: string; status?: string } } + | undefined + )?.attrs + ) + .filter((attrs) => attrs?.kind === `compaction`) + .map((attrs) => attrs?.status) + expect(statuses).toContain(`running`) + expect(statuses).toContain(`complete`) }) it(`does not compact below the hard ceiling`, async () => { diff --git a/packages/agents-runtime/test/timeline-compaction.test.ts b/packages/agents-runtime/test/timeline-compaction.test.ts index 00f4a39d10..8de6f5d5af 100644 --- a/packages/agents-runtime/test/timeline-compaction.test.ts +++ b/packages/agents-runtime/test/timeline-compaction.test.ts @@ -17,7 +17,7 @@ describe(`timelineMessages compaction reconstruction`, () => { value: { id: `compaction`, name: `compaction_summary`, - attrs: { kind: `compaction` }, + attrs: { kind: `compaction`, status: `complete` }, content: `SUMMARY_OF_EARLIER_WORK`, }, }, @@ -33,6 +33,30 @@ describe(`timelineMessages compaction reconstruction`, () => { expect(out).toContain(`LATEST_MESSAGE`) }) + it(`does NOT hide history for a running (incomplete) checkpoint`, () => { + // Crash-safety: an in-flight/crashed compaction must never drop history. + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { + kind: `context_inserted`, + at: 2, + value: { + id: `compaction`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `running` }, + content: ``, + }, + }, + { kind: `inbox`, at: 3, value: { payload: `SECOND_MESSAGE` } }, + ]) + + const out = serialize(db) + expect(out).toContain(`FIRST_MESSAGE`) + expect(out).toContain(`SECOND_MESSAGE`) + // The running checkpoint is a UI-only marker — not rendered to the model. + expect(out).not.toContain(`compaction_summary`) + }) + it(`is a no-op when there is no compaction checkpoint`, () => { const db = buildStreamFixture([ { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, From ee19e0c00288721126e403a8c1833af9361b5942 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 10:35:15 +0200 Subject: [PATCH 014/105] =?UTF-8?q?feat(agents-server-ui):=20live=20"Compa?= =?UTF-8?q?cting=20context=E2=80=A6"=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a spinner + "Compacting context…" in the composer footer while a synchronous compaction is in flight, reading the latest compaction checkpoint row and showing it while attrs.status is "running" (clears on complete). Tells the user why the turn paused and that their next prompt is being queued. Mirrors the ContextUsageIndicator pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/CompactionIndicator.module.css | 37 +++++++++++++ .../src/components/CompactionIndicator.tsx | 55 +++++++++++++++++++ .../src/components/MessageInput.tsx | 2 + 3 files changed, 94 insertions(+) create mode 100644 packages/agents-server-ui/src/components/CompactionIndicator.module.css create mode 100644 packages/agents-server-ui/src/components/CompactionIndicator.tsx diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.module.css b/packages/agents-server-ui/src/components/CompactionIndicator.module.css new file mode 100644 index 0000000000..3ec362a28e --- /dev/null +++ b/packages/agents-server-ui/src/components/CompactionIndicator.module.css @@ -0,0 +1,37 @@ +.indicator { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0 0.25rem; + color: var(--warning-text, #b26a00); + user-select: none; +} + +.label { + font-size: 0.72rem; + line-height: 1; + color: inherit; +} + +.spinner { + width: 10px; + height: 10px; + flex-shrink: 0; + border-radius: 50%; + border: 2px solid currentColor; + border-top-color: transparent; + opacity: 0.8; + animation: compaction-spin 0.7s linear infinite; +} + +@keyframes compaction-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.tsx b/packages/agents-server-ui/src/components/CompactionIndicator.tsx new file mode 100644 index 0000000000..04b49a3153 --- /dev/null +++ b/packages/agents-server-ui/src/components/CompactionIndicator.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' +import styles from './CompactionIndicator.module.css' + +/** + * Live indicator shown while a synchronous context compaction is in flight. + * + * The runtime writes a compaction checkpoint row with `attrs.status: "running"` + * before summarizing and supersedes it with a `complete` row when done. We read + * the latest such row reactively; while it's `running` we show "Compacting + * context…" so the user understands the pause (and knows their next prompt is + * being queued). It clears the moment compaction completes. + */ + +interface CheckpointRow { + _seq?: number + attrs?: { kind?: string; status?: string } +} + +interface CompactionIndicatorProps { + db: EntityStreamDBWithActions | null +} + +export function CompactionIndicator({ + db, +}: CompactionIndicatorProps): React.ReactElement | null { + const { data: rows = [] } = useLiveQuery( + (q) => + db && db.collections.contextInserted + ? q.from({ entry: db.collections.contextInserted as any }) + : undefined, + [db] + ) + + const compacting = useMemo(() => { + // The newest compaction checkpoint wins (later writes supersede earlier + // ones for the same id). If it's still `running`, compaction is in flight. + let latest: CheckpointRow | null = null + for (const row of rows as Array) { + if (row.attrs?.kind !== `compaction`) continue + if (!latest || (row._seq ?? 0) > (latest._seq ?? 0)) latest = row + } + return latest?.attrs?.status === `running` + }, [rows]) + + if (!compacting) return null + + return ( + + + ) +} diff --git a/packages/agents-server-ui/src/components/MessageInput.tsx b/packages/agents-server-ui/src/components/MessageInput.tsx index d71a6edd7d..883a1e7743 100644 --- a/packages/agents-server-ui/src/components/MessageInput.tsx +++ b/packages/agents-server-ui/src/components/MessageInput.tsx @@ -21,6 +21,7 @@ import { import { ComposerEditor } from './ComposerEditor' import { ComposerShell } from './ComposerShell' import { ContextUsageIndicator } from './ContextUsageIndicator' +import { CompactionIndicator } from './CompactionIndicator' import { Icon, Stack, Text, Tooltip } from '../ui' import { AttachmentActionMenu, @@ -484,6 +485,7 @@ export function MessageInput({ /> ) : null} + } send={ From b5a894fae6f3d4bfce03adccdbc00a963e47ad52 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 10:42:36 +0200 Subject: [PATCH 015/105] feat(agents): env-configurable compaction thresholds (testing tunables) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) and ELECTRIC_AGENTS_COMPACT_MIN_TOKENS (default contextWindow/2) let the synchronous compaction path be exercised without filling a real window. RFC §12 tunables; default behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/context-factory.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index a585a2a9de..33da29b635 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -86,6 +86,18 @@ function agentModelProvider(config: AgentConfig): string { : config.model.provider } +/** Parse a 0..1 ratio from an env string, falling back when invalid. */ +function ratioFromEnv(raw: string | undefined, fallback: number): number { + const value = Number(raw) + return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback +} + +/** Parse a positive number from an env string, falling back when invalid. */ +function positiveFromEnv(raw: string | undefined, fallback: number): number { + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : fallback +} + const MAX_HYDRATED_IMAGE_ATTACHMENTS = 4 const MAX_HYDRATED_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024 @@ -816,10 +828,22 @@ export function createHandlerContext( (sum, message) => sum + approxTokens(message.content), 0 ) + // Thresholds are overridable via env (RFC §12 tunables) so the sync + // compaction path can be exercised without filling a real window: + // ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) + // ELECTRIC_AGENTS_COMPACT_MIN_TOKENS (default contextWindow/2) + const compactCeiling = ratioFromEnv( + process.env.ELECTRIC_AGENTS_COMPACT_CEILING, + CONTEXT_USAGE_HARD_CEILING + ) + const compactMinHistoryTokens = positiveFromEnv( + process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS, + (budgetUsage?.contextWindow ?? 0) / 2 + ) if ( budgetUsage && - budgetUsage.ratio >= CONTEXT_USAGE_HARD_CEILING && - estimatedHistoryTokens > budgetUsage.contextWindow / 2 + budgetUsage.ratio >= compactCeiling && + estimatedHistoryTokens > compactMinHistoryTokens ) { const logPrefix = `[${config.entityUrl}]` const writeCheckpoint = ( From a6b8f2d5535c821492b2d9e6d0f13d5ef788125f Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 10:55:43 +0200 Subject: [PATCH 016/105] feat(agents-server-ui): "Context compacted" entry in the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces a completed compaction checkpoint as a collapsed, expandable "Context compacted" marker in the message history, at the point compaction happened. Adds a compaction custom timeline source (mirroring the comments source) reading compaction_summary context_inserted rows, a compaction row kind across the timeline dispatch, and a CompactionTimelineRow card (InlineEventCard, expand to view the summary). Running/failed checkpoints are filtered out — those are shown by the live composer indicator instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/EntityTimeline.tsx | 35 ++++++++++- .../src/hooks/useEntityTimeline.ts | 24 +++++-- packages/agents-server-ui/src/lib/comments.ts | 10 ++- .../agents-server-ui/src/lib/compaction.ts | 62 +++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 packages/agents-server-ui/src/lib/compaction.ts diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index feee790a8c..9a52a8c94f 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -21,6 +21,7 @@ import { Database, ExternalLink, FileJson, + FoldVertical, GitBranch, Radio, Reply, @@ -243,7 +244,7 @@ function estimateRowHeight( const lines = Math.max(1, Math.ceil(row.comment.body.length / charsPerLine)) return Math.max(58, 42 + lines * lineHeight) + timelineRowGap(row, nextRow) } - if (row.wake || row.signal || row.manifest) { + if (row.wake || row.signal || row.manifest || row.compaction) { return 76 + timelineRowGap(row, nextRow) } return 120 + timelineRowGap(row, nextRow) @@ -261,7 +262,9 @@ function timelineRowGap( nextRow?: RenderTimelineRow ): number { if (shouldCollapseCommentMeta(row, nextRow)) return 6 - return row.manifest || row.wake || row.signal ? MANIFEST_ROW_GAP : ROW_GAP + return row.manifest || row.wake || row.signal || row.compaction + ? MANIFEST_ROW_GAP + : ROW_GAP } function isPlainCommentRow(row: RenderTimelineRow | undefined): boolean { @@ -309,11 +312,13 @@ function timelineRowSearchText( if (row.signal) return signalSearchText(row.signal) if (row.error) return `${row.error.error_code} ${row.error.message}` if (row.manifest) return manifestSearchText(row.manifest) + if (row.compaction) return `context compacted ${row.compaction.content}` return runSearchTextByKey.get(row.$key) ?? runSearchTextFromSnapshot(row.run) } function timelineRowLabel(row: RenderTimelineRow): string { if (row.comment) return `Comment` + if (row.compaction) return `Context compacted` if (row.inbox?.from_agent) return `Agent message` if (row.inbox) return `User message` if (row.wake) return `Wake` @@ -676,6 +681,28 @@ function ErrorTimelineRow({ ) } +function CompactionTimelineRow({ + compaction, +}: { + compaction: NonNullable +}): React.ReactElement { + const summary = compaction.content.trim() + return ( +
+ + {summary.length > 0 ? ( +
{summary}
+ ) : undefined} +
+
+ ) +} + function signalSearchText( signal: NonNullable ): string { @@ -1313,6 +1340,10 @@ const TimelineRow = memo(function TimelineRow({ return } + if (row.compaction) { + return + } + if (row.manifest) { return ( { if (!db) return undefined return createEntityTimelineQuery(db, { - ...(includeComments && { - customSources: { comment: createCommentsTimelineSource(db) }, - }), + customSources: { + compaction: createCompactionTimelineSource(db), + ...(includeComments && { + comment: createCommentsTimelineSource(db), + }), + }, })(q) }, [db, includeComments] @@ -155,7 +162,16 @@ export function useEntityTimeline( : undefined, [db] ) - const typedTimelineRows = timelineRows as Array + // Only a *completed* compaction checkpoint renders as a timeline marker; + // `running`/`failed` checkpoints are surfaced by the live composer indicator + // instead, not as message-history entries. + const typedTimelineRows = useMemo( + () => + (timelineRows as Array).filter( + (row) => !row.compaction || isCompletedCompactionRow(row) + ), + [timelineRows] + ) const pendingInbox = useMemo( () => diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index 056addc82f..8279261f56 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -14,6 +14,7 @@ import type { EntityTimelineCustomSource, EntityTimelineQueryRow, } from '@electric-ax/agents-runtime/client' +import type { CompactionTimelineRow } from './compaction' /** * Comments are a UI-level concern: the runtime timeline query knows nothing @@ -33,6 +34,7 @@ export type EntityTimelineCommentRow = { export type CommentTimelineRow = { $key: string comment: EntityTimelineCommentRow + compaction?: undefined inbox?: undefined run?: undefined wake?: undefined @@ -41,10 +43,14 @@ export type CommentTimelineRow = { manifest?: undefined } -/** Timeline row as consumed by UI views: runtime rows plus merged comment rows. */ +/** + * Timeline row as consumed by UI views: runtime rows plus merged custom-source + * rows (comments, compaction markers). + */ export type TimelineRow = - | (EntityTimelineQueryRow & { comment?: undefined }) + | (EntityTimelineQueryRow & { comment?: undefined; compaction?: undefined }) | CommentTimelineRow + | CompactionTimelineRow /** * Timeline source for the `comments` collection, passed to the runtime's diff --git a/packages/agents-server-ui/src/lib/compaction.ts b/packages/agents-server-ui/src/lib/compaction.ts new file mode 100644 index 0000000000..1d0db61bde --- /dev/null +++ b/packages/agents-server-ui/src/lib/compaction.ts @@ -0,0 +1,62 @@ +import { coalesce, eq } from '@durable-streams/state/db' +import { TIMELINE_ORDER_FALLBACK } from '@electric-ax/agents-runtime/client' +import type { + EntityStreamDBWithActions, + EntityTimelineCustomSource, +} from '@electric-ax/agents-runtime/client' + +/** + * Compaction checkpoints are persisted as `context_inserted` rows tagged + * `name: "compaction_summary"`. The runtime timeline query doesn't surface + * them; `useEntityTimeline` merges them in via this custom source so a + * "Context compacted" marker can render in the message history at the point + * compaction happened. Must match the runtime's COMPACTION_CHECKPOINT_NAME. + */ +const COMPACTION_CHECKPOINT_NAME = `compaction_summary` + +export type EntityTimelineCompactionRow = { + key: string + order: string + attrs?: { kind?: string; status?: string } + content: string + timestamp: string +} + +export type CompactionTimelineRow = { + $key: string + compaction: EntityTimelineCompactionRow + comment?: undefined + inbox?: undefined + run?: undefined + wake?: undefined + signal?: undefined + error?: undefined + manifest?: undefined +} + +export function createCompactionTimelineSource( + db: EntityStreamDBWithActions +): EntityTimelineCustomSource { + const contextInserted = (db.collections as Record) + .contextInserted + return (q) => + q + .from({ compaction: contextInserted }) + .where(({ compaction }: any) => + eq(compaction.name, COMPACTION_CHECKPOINT_NAME) + ) + .select(({ compaction }: any) => ({ + order: coalesce(compaction._timeline_order, TIMELINE_ORDER_FALLBACK), + key: compaction.key, + attrs: compaction.attrs, + content: coalesce(compaction.content, ``), + timestamp: coalesce(compaction.timestamp, ``), + })) +} + +/** A completed compaction is the persistent marker; running/failed are not. */ +export function isCompletedCompactionRow(row: { + compaction?: EntityTimelineCompactionRow +}): boolean { + return row.compaction?.attrs?.status === `complete` +} From 28ddc670b62817bd0e1bbd04ec0a6103150f7445 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 12:04:05 +0200 Subject: [PATCH 017/105] feat(agents): mid-turn compaction via pi-agent transformContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compaction now runs before EVERY model step (not just between turns), so a single turn that balloons across many tool calls can no longer exhaust the context window before the turn ends. Wired through pi-agent-core's transformContext hook. - compaction-midturn.ts: createMidTurnCompactor — folds older messages into a summary, returns [summary, ...recent tail], caches the summary for the rest of the turn (re-summarizing chained off the prior summary only if the tail grows back over the ceiling). - pi-adapter: Codex-style token signal — anchor on the last step's REAL cache-inclusive usage + estimate only the trailing items appended since, vs the model's real context window (not an estimate of the whole history). transformContext + initialContextTokens options added. - context-factory: build the compactor and pass it to the adapter; removed the per-turn pre-sampling compaction block it replaces. Reuses the summarizer, checkpoint lifecycle (running/complete/failed), and UI. summarizeAgentMessages summarizes the AgentMessage[] the hook receives without re-converting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/compaction-midturn.ts | 116 ++++++++++++++++ .../src/compaction-summarize.ts | 43 +++++- .../agents-runtime/src/context-factory.ts | 118 ++++++---------- packages/agents-runtime/src/pi-adapter.ts | 58 ++++++++ .../test/compaction-midturn.test.ts | 129 ++++++++++++++++++ .../test/compaction-trigger.test.ts | 87 ++++++------ 6 files changed, 430 insertions(+), 121 deletions(-) create mode 100644 packages/agents-runtime/src/compaction-midturn.ts create mode 100644 packages/agents-runtime/test/compaction-midturn.test.ts diff --git a/packages/agents-runtime/src/compaction-midturn.ts b/packages/agents-runtime/src/compaction-midturn.ts new file mode 100644 index 0000000000..cad1d78ccb --- /dev/null +++ b/packages/agents-runtime/src/compaction-midturn.ts @@ -0,0 +1,116 @@ +import { COMPACTION_CHECKPOINT_NAME } from './compaction' +import type { CompactionStatus } from './compaction' + +/** A pi-agent message — loose shape; we only build and slice these. */ +export type AgentMessageLike = { + role: string + content: unknown + timestamp?: number +} + +export interface MidTurnCompactorDeps { + /** Summarize the given (older) messages into a handoff summary string. */ + summarize: (messages: Array) => Promise + /** Persist the checkpoint lifecycle row (UI marker + future reconstruction). */ + writeCheckpoint: (status: CompactionStatus, content: string) => void + /** Compaction fires at/above this fraction of the context window. */ + ceiling: number + /** Don't compact unless the context is at least this many tokens. */ + minTokens: number + /** Keep this many of the most recent messages verbatim (the live tail). */ + keepTail: number +} + +export interface CompactContextInput { + messages: Array + /** Estimated tokens of the outgoing context (real last-step usage + tail). */ + currentTokens: number + contextWindow: number +} + +export type CompactContextFn = ( + input: CompactContextInput +) => Promise | null> + +export function buildCompactionSummaryMessage( + summary: string +): AgentMessageLike { + return { + role: `user`, + content: [ + { + type: `text`, + text: `<${COMPACTION_CHECKPOINT_NAME}>\n${summary}\n`, + }, + ], + timestamp: Date.now(), + } +} + +/** + * Build the per-turn mid-turn compaction hook for one agent run. The returned + * function is wired to pi-agent's `transformContext` — it runs before every + * model step. Once the estimated outgoing context crosses the ceiling it folds + * the older messages into a summary and returns `[summary, ...recent tail]`. + * + * The summary is cached for the rest of the turn: later steps reuse it + * (returning the compacted view) instead of re-summarizing. Coverage is only + * extended if the tail grows back over the ceiling, and a re-summarization + * chains off the previous summary (prev summary + new middle) rather than + * re-reading the whole already-summarized bulk. + * + * Returns `null` to leave the context untouched (no compaction needed/active). + */ +export function createMidTurnCompactor( + deps: MidTurnCompactorDeps +): CompactContextFn { + let state: { summary: string; coveredCount: number } | null = null + + const compactedView = ( + messages: Array + ): Array | null => + state + ? [ + buildCompactionSummaryMessage(state.summary), + ...messages.slice(state.coveredCount), + ] + : null + + return async ({ messages, currentTokens, contextWindow }) => { + const overCeiling = + currentTokens >= deps.ceiling * contextWindow && + currentTokens > deps.minTokens + + // Under the ceiling: keep the compacted view sticky if we already compacted + // this turn, otherwise leave the context untouched. + if (!overCeiling) return compactedView(messages) + + const keepTail = Math.min(deps.keepTail, Math.max(0, messages.length - 1)) + const coveredCount = messages.length - keepTail + + // Nothing new to fold beyond the existing summary's coverage. + if (coveredCount <= 0) return compactedView(messages) + if (state && coveredCount <= state.coveredCount) + return compactedView(messages) + + // Chain off the previous summary so we don't re-summarize the whole bulk. + const toSummarize = state + ? [ + buildCompactionSummaryMessage(state.summary), + ...messages.slice(state.coveredCount, coveredCount), + ] + : messages.slice(0, coveredCount) + + deps.writeCheckpoint(`running`, ``) + try { + const summary = await deps.summarize(toSummarize) + deps.writeCheckpoint(`complete`, summary) + state = { summary, coveredCount } + return compactedView(messages) + } catch { + deps.writeCheckpoint(`failed`, ``) + // Fall back to any compaction we already had (or leave untouched). + return compactedView(messages) + } + } +} diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts index b3c76b9190..b241b4eb25 100644 --- a/packages/agents-runtime/src/compaction-summarize.ts +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -19,24 +19,33 @@ const DEFAULT_SUMMARY_MAX_TOKENS = 2048 * prompt; the returned summary is prefixed with Codex's summary preamble so the * resuming model knows it is reading a handoff. */ -export async function summarizeMessages(input: { +interface SummarizeCoreInput { model: string | object provider?: string - messages: ReadonlyArray apiKey?: string maxTokens?: number complete?: SummarizeCompleteFn -}): Promise { +} + +/** + * Core summarization over already-converted history messages (pi-agent's + * `AgentMessage[]` shape). Appends Codex's summarization prompt, calls the + * model, and prefixes the result. Both the LLMMessage path and the mid-turn + * AgentMessage path funnel through here. + */ +async function summarizeConverted( + historyMessages: ReadonlyArray, + input: SummarizeCoreInput +): Promise { const complete = input.complete ?? (completeSimple as unknown as SummarizeCompleteFn) const model = resolvePiModel({ model: input.model as never, ...(input.provider && { provider: input.provider as never }), }) - const history = toAgentHistory([...input.messages]) const context = { messages: [ - ...history, + ...historyMessages, { role: `user`, content: COMPACTION_SUMMARIZATION_PROMPT, @@ -60,3 +69,27 @@ export async function summarizeMessages(input: { return `${COMPACTION_SUMMARY_PREFIX}\n${text}` } + +/** + * Summarize a conversation (LLMMessage form) into a compaction handoff summary. + * + * Uses the **conversation's own model** by default (RFC Q2): a cheaper, + * small-window model would overflow on a near-full context — the whole reason + * we are compacting. + */ +export async function summarizeMessages( + input: SummarizeCoreInput & { messages: ReadonlyArray } +): Promise { + return summarizeConverted(toAgentHistory([...input.messages]), input) +} + +/** + * Summarize already-converted `AgentMessage[]` (what `transformContext` hands + * us mid-turn) — same as `summarizeMessages` but skips the LLMMessage→Agent + * conversion since the messages are already in that shape. + */ +export async function summarizeAgentMessages( + input: SummarizeCoreInput & { messages: ReadonlyArray } +): Promise { + return summarizeConverted(input.messages, input) +} diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 33da29b635..9017a77cc5 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -24,14 +24,18 @@ import { CONTEXT_USAGE_HARD_CEILING, type ContextUsageStep, } from './token-accountant' -import { approxTokens } from './token-budget' import { COMPACTION_CHECKPOINT_ID, COMPACTION_CHECKPOINT_KIND, COMPACTION_CHECKPOINT_NAME, - type CompactionStatus, } from './compaction' -import { summarizeMessages } from './compaction-summarize' +import { createMidTurnCompactor } from './compaction-midturn' +import { summarizeAgentMessages } from './compaction-summarize' + +/** Default minimum context size (tokens) before mid-turn compaction fires. */ +const DEFAULT_COMPACT_MIN_TOKENS = 2000 +/** Recent messages kept verbatim when compacting (the live tool-call tail). */ +const COMPACT_KEEP_TAIL = 6 import { createContextTools } from './tools/context-tools' import { CACHE_TIERS } from './types' import { composeToolsWithProviders } from './tool-providers' @@ -813,116 +817,74 @@ export function createHandlerContext( config.db.collections.steps.toArray as ReadonlyArray ) - // Phase 2 hard ceiling: if the last turn left context at/over 90% of - // the window, compact synchronously before this model call. We surface - // the work as a status'd checkpoint row so the UI can show it live: - // insert a `running` checkpoint, summarize, then mark it `complete` - // with the summary (or `failed`). Only a `complete` checkpoint acts as - // the timeline watermark, so a crash mid-compaction never hides history. - // The current ask is delivered separately as runInput. The `approxTokens` - // guard avoids re-compacting an already compacted (small) history while - // the last step's usage is still high. - let workingMessages = messages - let didCompact = false - const estimatedHistoryTokens = messages.reduce( - (sum, message) => sum + approxTokens(message.content), - 0 - ) - // Thresholds are overridable via env (RFC §12 tunables) so the sync - // compaction path can be exercised without filling a real window: - // ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) - // ELECTRIC_AGENTS_COMPACT_MIN_TOKENS (default contextWindow/2) + // Phase 2 hard ceiling — enforced MID-TURN. Compaction runs before each + // model step via pi-agent's `transformContext` (wired in the adapter to + // the compactor below), so a single turn that balloons across many tool + // calls can't exhaust the window before the turn ends. The compactor + // anchors its token estimate on the last step's real usage (+ trailing), + // folds older messages into a summary, and persists the running/complete/ + // failed checkpoint that drives the UI. Thresholds overridable via env + // (RFC §12 tunables): + // ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) + // ELECTRIC_AGENTS_COMPACT_MIN_TOKENS (default 2000) const compactCeiling = ratioFromEnv( process.env.ELECTRIC_AGENTS_COMPACT_CEILING, CONTEXT_USAGE_HARD_CEILING ) - const compactMinHistoryTokens = positiveFromEnv( + const compactMinTokens = positiveFromEnv( process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS, - (budgetUsage?.contextWindow ?? 0) / 2 + DEFAULT_COMPACT_MIN_TOKENS ) - if ( - budgetUsage && - budgetUsage.ratio >= compactCeiling && - estimatedHistoryTokens > compactMinHistoryTokens - ) { - const logPrefix = `[${config.entityUrl}]` - const writeCheckpoint = ( - status: CompactionStatus, - content: string - ): void => { + const compactProvider = agentModelProvider(activeAgentConfig) + const onCompactContext = createMidTurnCompactor({ + ceiling: compactCeiling, + minTokens: compactMinTokens, + keepTail: COMPACT_KEEP_TAIL, + writeCheckpoint: (status, content) => { contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { name: COMPACTION_CHECKPOINT_NAME, attrs: { kind: COMPACTION_CHECKPOINT_KIND, status }, content, }) - } - // Live: announce compaction is in progress (UI shows "Compacting…"). - writeCheckpoint(`running`, ``) - try { - const provider = agentModelProvider(activeAgentConfig) - const apiKey = await activeAgentConfig.getApiKey?.(provider) - const summary = await summarizeMessages({ + }, + summarize: async (agentMessages) => { + const apiKey = await activeAgentConfig.getApiKey?.(compactProvider) + return summarizeAgentMessages({ model: activeAgentConfig.model, - provider, - messages, + provider: compactProvider, + messages: agentMessages, ...(apiKey ? { apiKey } : {}), ...(activeAgentConfig.summarizeComplete ? { complete: activeAgentConfig.summarizeComplete } : {}), }) - // Complete: this supersedes the `running` row and becomes the - // watermark; future turns reconstruct from it. - writeCheckpoint(`complete`, summary) - workingMessages = [ - { - role: `user`, - content: `<${COMPACTION_CHECKPOINT_NAME}>\n${summary}\n`, - }, - ] - didCompact = true - runtimeLog.info( - logPrefix, - `compaction: summarized ${messages.length} messages at ratio=${budgetUsage.ratio.toFixed(2)} window=${budgetUsage.contextWindow}` - ) - } catch (error) { - // Mark failed (clears the UI indicator) and proceed uncompacted. - writeCheckpoint(`failed`, ``) - runtimeLog.warn( - logPrefix, - `compaction failed; proceeding uncompacted: ${error instanceof Error ? error.message : String(error)}` - ) - } - } + }, + }) // Cap any single oversized tool result (one huge output can fill the - // window on its own), then surface the budget notice. After a fresh - // compaction the last step's ratio is stale, so skip the notice this - // turn rather than report a misleading "~0% remaining". + // window on its own), then surface the budget notice. const outgoingMessages = withContextBudgetNotice( - truncateOversizedToolResults(workingMessages), - didCompact ? null : budgetUsage + truncateOversizedToolResults(messages), + budgetUsage ) const adapterFactory = createPiAgentAdapter({ systemPrompt: activeAgentConfig.systemPrompt, model: activeAgentConfig.model, - provider: activeAgentConfig.provider, - tools: [...composedTools, ...extraTools] as Array, - streamFn: activeAgentConfig.streamFn, - getApiKey: activeAgentConfig.getApiKey, - reasoning: activeAgentConfig.reasoning, thinkingBudgets: activeAgentConfig.thinkingBudgets, - onPayload: activeAgentConfig.onPayload, - onStepEnd: activeAgentConfig.onStepEnd, modelTimeoutMs: activeAgentConfig.modelTimeoutMs, modelMaxRetries: activeAgentConfig.modelMaxRetries, + onCompactContext, + ...(budgetUsage + ? { initialContextTokens: budgetUsage.usedTokens } + : {}), }) const handle = adapterFactory({ entityUrl: config.entityUrl, diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 18857c6815..7cc760325c 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -12,6 +12,8 @@ import { getModel, streamSimple } from '@mariozechner/pi-ai' import { createOutboundBridge } from './outbound-bridge' import { MOONSHOT_PROVIDER, getMoonshotModel } from './moonshot-models' import { runtimeLog } from './log' +import { approxTokens } from './token-budget' +import type { AgentMessageLike, CompactContextFn } from './compaction-midturn' import { ModelProviderError, toModelProviderError, @@ -85,6 +87,14 @@ export interface PiAdapterOptions { }) => void modelTimeoutMs?: number modelMaxRetries?: number + // Mid-turn compaction hook. Called before each model step with the outgoing + // messages; may return a compacted message list to send instead. The adapter + // supplies `currentTokens` (real last-step usage + estimated trailing) and the + // model's context window so the hook can decide. See createMidTurnCompactor. + onCompactContext?: CompactContextFn + // Real cache-inclusive token usage entering this run (the previous turn's + // last step), used to anchor the first step's token estimate. + initialContextTokens?: number } const DEFAULT_MODEL_TIMEOUT_MS = 30_000 @@ -289,6 +299,47 @@ export function createPiAgentAdapter( maxRetries: modelMaxRetries, }) + // Mid-turn compaction token accounting (Codex-style): anchor on the real + // cache-inclusive usage reported for the last model step, plus an estimate + // of only the items appended since (the trailing tail). `anchorMessageCount` + // marks the message-array length at that last step so the trailing slice can + // be measured. Initialised from the previous turn's usage for the first call. + const estimateContent = (m: AgentMessageLike): number => + approxTokens((m as { content?: unknown }).content) + let anchorTokens = + opts.initialContextTokens ?? + (history as Array).reduce( + (sum, m) => sum + estimateContent(m), + 0 + ) + let anchorMessageCount = history.length + let pendingRequestMessageCount = anchorMessageCount + + const modelContextWindow = + typeof model.contextWindow === `number` ? model.contextWindow : 0 + + const transformContext = + opts.onCompactContext && modelContextWindow > 0 + ? async ( + messages: Array + ): Promise> => { + const list = messages as unknown as Array + const trailingTokens = list + .slice(anchorMessageCount) + .reduce((sum, m) => sum + estimateContent(m), 0) + const currentTokens = anchorTokens + trailingTokens + pendingRequestMessageCount = messages.length + const compacted = await opts.onCompactContext!({ + messages: list, + currentTokens, + contextWindow: modelContextWindow, + }) + return compacted + ? (compacted as unknown as Array) + : messages + } + : undefined + const agentOptions = { initialState: { systemPrompt: opts.systemPrompt, @@ -297,6 +348,7 @@ export function createPiAgentAdapter( model, }, streamFn, + ...(transformContext && { transformContext }), ...(opts.getApiKey && { getApiKey: opts.getApiKey }), ...(opts.onPayload && { onPayload: opts.onPayload }), } @@ -549,6 +601,12 @@ export function createPiAgentAdapter( typeof model.contextWindow === `number` ? model.contextWindow : undefined + // Re-anchor the mid-turn token estimate on this step's real + // (cache-inclusive) usage and the message count we sent. + if (usageContext !== undefined) { + anchorTokens = usageContext + anchorMessageCount = pendingRequestMessageCount + } bridge.onStepEnd({ finishReason, durationMs: Date.now() - stepStartTime, diff --git a/packages/agents-runtime/test/compaction-midturn.test.ts b/packages/agents-runtime/test/compaction-midturn.test.ts new file mode 100644 index 0000000000..00f2aba1f8 --- /dev/null +++ b/packages/agents-runtime/test/compaction-midturn.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMidTurnCompactor } from '../src/compaction-midturn' +import type { AgentMessageLike } from '../src/compaction-midturn' + +function msgs(n: number): Array { + return Array.from({ length: n }, (_, i) => ({ + role: i % 2 ? `assistant` : `user`, + content: `m${i}`, + })) +} + +describe(`createMidTurnCompactor`, () => { + it(`leaves context untouched below the ceiling`, async () => { + const summarize = vi.fn() + const writeCheckpoint = vi.fn() + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint, + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + const out = await compact({ + messages: msgs(10), + currentTokens: 1000, // 10% of window + contextWindow: 10000, + }) + expect(out).toBeNull() + expect(summarize).not.toHaveBeenCalled() + }) + + it(`compacts over the ceiling: running→complete + [summary, ...tail]`, async () => { + const summarize = vi.fn().mockResolvedValue(`SUMMARY`) + const statuses: Array = [] + const writeCheckpoint = vi.fn((s: string) => statuses.push(s)) + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint, + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + const messages = msgs(10) + const out = await compact({ + messages, + currentTokens: 9500, // 95% + contextWindow: 10000, + }) + // folded the first 6 (10 − keepTail 4) + expect(summarize).toHaveBeenCalledTimes(1) + expect((summarize.mock.calls[0]![0] as Array).length).toBe(6) + expect(statuses).toEqual([`running`, `complete`]) + expect(out).not.toBeNull() + expect(out!.length).toBe(1 + 4) // summary + recent tail + expect(JSON.stringify(out![0])).toContain(`SUMMARY`) + expect(out![1]).toBe(messages[6]) // tail begins at coveredCount=6 + }) + + it(`is sticky: reuses the cached summary below the ceiling`, async () => { + const summarize = vi.fn().mockResolvedValue(`SUMMARY`) + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint: vi.fn(), + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + const messages = msgs(10) + await compact({ messages, currentTokens: 9500, contextWindow: 10000 }) + summarize.mockClear() + const grown = [...messages, { role: `user`, content: `new` }] + const out = await compact({ + messages: grown, + currentTokens: 2000, // 20%, well under ceiling + contextWindow: 10000, + }) + expect(summarize).not.toHaveBeenCalled() // reused, not re-summarized + expect(JSON.stringify(out![0])).toContain(`SUMMARY`) + expect(out!.length).toBe(1 + 5) // summary + (4 original tail + 1 new) + }) + + it(`re-summarizes by chaining off the previous summary`, async () => { + const summarize = vi + .fn() + .mockResolvedValueOnce(`SUMMARY1`) + .mockResolvedValueOnce(`SUMMARY2`) + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint: vi.fn(), + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + await compact({ + messages: msgs(10), + currentTokens: 9500, + contextWindow: 10000, + }) + const out = await compact({ + messages: msgs(20), + currentTokens: 9500, + contextWindow: 10000, + }) + expect(summarize).toHaveBeenCalledTimes(2) + // the re-summarization input starts with the prior summary (chained) + const secondInput = summarize.mock.calls[1]![0] as Array + expect(JSON.stringify(secondInput[0])).toContain(`SUMMARY1`) + expect(JSON.stringify(out![0])).toContain(`SUMMARY2`) + }) + + it(`on failure writes "failed" and leaves context untouched`, async () => { + const summarize = vi.fn().mockRejectedValue(new Error(`boom`)) + const statuses: Array = [] + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint: (s: string) => statuses.push(s), + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + const out = await compact({ + messages: msgs(10), + currentTokens: 9500, + contextWindow: 10000, + }) + expect(statuses).toEqual([`running`, `failed`]) + expect(out).toBeNull() // no prior compaction → untouched + }) +}) diff --git a/packages/agents-runtime/test/compaction-trigger.test.ts b/packages/agents-runtime/test/compaction-trigger.test.ts index 7eddbc0b82..6b293d56da 100644 --- a/packages/agents-runtime/test/compaction-trigger.test.ts +++ b/packages/agents-runtime/test/compaction-trigger.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' import { buildStreamFixture, @@ -6,6 +6,18 @@ import { } from './helpers/context-test-helpers' import type { ChangeEvent } from '@durable-streams/state' +// The mid-turn compactor gates on (real last-step usage + trailing estimate) vs +// the MODEL's context window. We can't shrink the real window in a unit test, so +// we drive the trigger with the env override + a high seeded anchor instead. +const savedEnv = { + ceiling: process.env.ELECTRIC_AGENTS_COMPACT_CEILING, + minTokens: process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS, +} +afterEach(() => { + process.env.ELECTRIC_AGENTS_COMPACT_CEILING = savedEnv.ceiling + process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS = savedEnv.minTokens +}) + function completedAssistantMessage(): unknown { return { role: `assistant`, @@ -26,25 +38,38 @@ function completedAssistantMessage(): unknown { } } -describe(`synchronous compaction trigger`, () => { - it(`compacts at the 95% hard ceiling: model sees the summary, checkpoint persisted`, async () => { - // A large prior message (≈1000 tokens) and a step reporting 98% usage of a - // small (test) 1000-token window — over the 95% ceiling and worth - // compacting (approxTokens > window/2). - const db = buildStreamFixture([ - { kind: `inbox`, at: 1, value: { payload: `A`.repeat(4000) } }, - ]) - ;( - db.collections as unknown as { steps: { insert: (r: unknown) => void } } - ).steps.insert({ - key: `step-1`, - _seq: 1, - run_id: `r`, - step_number: 1, - status: `completed`, - context_input_tokens: 980, - context_window: 1000, - }) +function seedStep( + db: ReturnType, + contextInputTokens: number +): void { + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-1`, + _seq: 1, + run_id: `r`, + step_number: 1, + status: `completed`, + context_input_tokens: contextInputTokens, + context_window: 200000, + }) +} + +describe(`mid-turn compaction trigger`, () => { + it(`compacts mid-turn: summarizer runs, model sees the summary, checkpoint persisted`, async () => { + // Ceiling tiny so any real model window is crossed by the seeded anchor. + process.env.ELECTRIC_AGENTS_COMPACT_CEILING = `0.0001` + process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS = `10` + + // Enough messages that there's real content beyond the kept tail (6). + const db = buildStreamFixture( + Array.from({ length: 8 }, (_, i) => ({ + kind: `inbox` as const, + at: i + 1, + value: { payload: `MESSAGE_${i}` }, + })) + ) + seedStep(db, 50_000) // high anchor → over the tiny ceiling const writes: Array = [] const { ctx } = createTestHandlerContext({ @@ -75,13 +100,8 @@ describe(`synchronous compaction trigger`, () => { await ctx.agent.run(`continue`) - // Summarizer ran… expect(summarizeCalled).toBe(true) - // …the model saw the summary, not the giant original message… expect(captured).toContain(`COMPACTED_SUMMARY`) - expect(captured).not.toContain(`AAAAAAAAAAAAAAAAAAAA`) - // …and the checkpoint went through the running → complete lifecycle - // (so the UI can show a live "compacting" entry then the final marker). const statuses = writes .map( (event) => @@ -97,21 +117,12 @@ describe(`synchronous compaction trigger`, () => { expect(statuses).toContain(`complete`) }) - it(`does not compact below the hard ceiling`, async () => { + it(`does not compact when well under the ceiling`, async () => { + // Default ceiling (0.9); a small anchor against the real ~200k+ window. const db = buildStreamFixture([ - { kind: `inbox`, at: 1, value: { payload: `A`.repeat(4000) } }, + { kind: `inbox`, at: 1, value: { payload: `hello` } }, ]) - ;( - db.collections as unknown as { steps: { insert: (r: unknown) => void } } - ).steps.insert({ - key: `step-1`, - _seq: 1, - run_id: `r`, - step_number: 1, - status: `completed`, - context_input_tokens: 500, // 50% — below the 95% ceiling - context_window: 1000, - }) + seedStep(db, 500) let summarizeCalled = false const { ctx } = createTestHandlerContext({ db }) From 9a68f50905b14884029de862d8128069cfdc0176 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 12:23:20 +0200 Subject: [PATCH 018/105] fix(agents): don't orphan a tool_result when compacting mid-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keepTail boundary could split a tool_call/tool_result pair — folding the assistant tool_use into the summary while keeping the matching tool_result in the tail. Anthropic rejects the orphaned tool_result (400 invalid_request_error: "tool_result must have a corresponding tool_use block in the previous message"). Advance the fold boundary past any leading tool-result messages so the kept tail starts on a fresh user/assistant turn. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/compaction-midturn.ts | 14 ++++++++++- .../test/compaction-midturn.test.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/agents-runtime/src/compaction-midturn.ts b/packages/agents-runtime/src/compaction-midturn.ts index cad1d78ccb..6cb9cba494 100644 --- a/packages/agents-runtime/src/compaction-midturn.ts +++ b/packages/agents-runtime/src/compaction-midturn.ts @@ -86,7 +86,19 @@ export function createMidTurnCompactor( if (!overCeiling) return compactedView(messages) const keepTail = Math.min(deps.keepTail, Math.max(0, messages.length - 1)) - const coveredCount = messages.length - keepTail + let coveredCount = messages.length - keepTail + + // Don't split a tool_call/tool_result pair: the kept tail must not start + // with an orphaned tool_result, whose matching tool_use (in the assistant + // message) would be folded into the summary — Anthropic rejects that. Advance + // the fold boundary past any leading tool-result messages so the tail begins + // on a fresh user/assistant turn. + while ( + coveredCount < messages.length && + messages[coveredCount]?.role === `toolResult` + ) { + coveredCount++ + } // Nothing new to fold beyond the existing summary's coverage. if (coveredCount <= 0) return compactedView(messages) diff --git a/packages/agents-runtime/test/compaction-midturn.test.ts b/packages/agents-runtime/test/compaction-midturn.test.ts index 00f2aba1f8..ccafd52a1b 100644 --- a/packages/agents-runtime/test/compaction-midturn.test.ts +++ b/packages/agents-runtime/test/compaction-midturn.test.ts @@ -108,6 +108,30 @@ describe(`createMidTurnCompactor`, () => { expect(JSON.stringify(out![0])).toContain(`SUMMARY2`) }) + it(`never starts the kept tail with an orphaned tool_result`, async () => { + const summarize = vi.fn().mockResolvedValue(`SUMMARY`) + const compact = createMidTurnCompactor({ + summarize, + writeCheckpoint: vi.fn(), + ceiling: 0.9, + minTokens: 100, + keepTail: 4, + }) + // Boundary would land at index 6 (10 − keepTail 4); make that a tool_result + // whose tool_use (index 5 assistant) is being folded. The fold boundary must + // advance past it so the tail starts on the assistant turn at index 7. + const messages = msgs(10) + messages[6] = { role: `toolResult`, content: `tr` } + const out = await compact({ + messages, + currentTokens: 9500, + contextWindow: 10000, + }) + expect((summarize.mock.calls[0]![0] as Array).length).toBe(7) + expect(out![1]).toBe(messages[7]) + expect((out![1] as { role: string }).role).toBe(`assistant`) + }) + it(`on failure writes "failed" and leaves context untouched`, async () => { const summarize = vi.fn().mockRejectedValue(new Error(`boom`)) const statuses: Array = [] From 991bef6ff4578df012e7649f3708231d5832cd7e Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 15:02:57 +0200 Subject: [PATCH 019/105] feat(agents): background (turn-end) context compaction [phase 3] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-blocking compaction so the user almost never waits for the 90% sync floor. Self-contained so it can be reverted as one commit. - Trigger (process-wake): after a turn whose usage ≥ 85% (env ELECTRIC_AGENTS_COMPACT_BG_CEILING), kick off a DETACHED summarization. Its checkpoint is applied at the NEXT turn's start, OR — if the summarize finishes while the entity is idle — immediately by waking the idle loop and writing the checkpoint WITHOUT running the agent (so the indicator never lingers past completion). The slow summarize never blocks; a fast follow-up prompt just runs un-compacted. - context-factory: maybeStartBackgroundCompaction / writeBackgroundCheckpoint / failBackgroundCheckpoint on the handler-context result. Snapshots the timeline head as the watermark; summarizes the whole reconstructed history; writes a background-flavored running→complete checkpoint. - Unique checkpoint id per generation (compaction-bg-): context supersession keys on id alone, so with one shared id the NEXT background's `running` row silently superseded the PREVIOUS `complete` one — erasing the watermark and undoing every compaction (context never shrank, the indicator stuck on "running"). running→complete→failed of one generation share the id; the next generation can't clobber it. Mid-turn sync keeps the shared id on purpose (its re-summarization chain wants supersession). - Reconstruction (timeline-context): checkpoints carry a stored attrs.watermark and the summary is rendered AT that watermark — so a prompt+answer that arrived while a background summary ran (physically after the checkpoint, logically after the watermark) are kept verbatim AFTER the summary (RFC §8.5). Falls back to the row's order for sync. - UI: CompactionIndicator shows a subtle "Compacting in background…" for background checkpoints, distinct from the blocking sync indicator. - Summarize hardening (compaction-summarize): bound every summarize call with a 120s deadline — the anthropic provider only enforces a timeout/ abort when the caller passes timeoutMs/signal and never retries, so a stalled stream hung forever. Forward timeoutMs + an AbortSignal the provider honours AND race a hard timer; a timeout becomes an ordinary failure retried next turn-end. The mid-turn 90% sync floor stays as the safety net for single runaway turns. To drop the feature: revert this commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/compaction-summarize.ts | 41 +++++- .../agents-runtime/src/context-factory.ts | 133 ++++++++++++++++- packages/agents-runtime/src/process-wake.ts | 125 +++++++++++++++- .../agents-runtime/src/timeline-context.ts | 79 ++++++++--- .../test/compaction-background.test.ts | 134 ++++++++++++++++++ .../test/compaction-summarize.test.ts | 26 ++++ .../test/timeline-compaction.test.ts | 75 ++++++++++ .../components/CompactionIndicator.module.css | 7 + .../src/components/CompactionIndicator.tsx | 28 +++- 9 files changed, 613 insertions(+), 35 deletions(-) create mode 100644 packages/agents-runtime/test/compaction-background.test.ts diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts index b241b4eb25..baa3ec64e8 100644 --- a/packages/agents-runtime/src/compaction-summarize.ts +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -10,6 +10,18 @@ export type { SummarizeCompleteFn } const DEFAULT_SUMMARY_MAX_TOKENS = 2048 +/** + * Hard deadline for a single summarization request. + * + * pi-ai's anthropic provider applies a client-side timeout (and abort) ONLY + * when the caller passes `timeoutMs`/`signal`, and it never retries. Background + * compaction fires this call CONCURRENTLY with the agent's own streaming turn on + * the same (OAuth) token; if that concurrent stream stalls, an unbounded call + * hangs forever — wedging the pending slot and blocking all future attempts. + * Bounding it turns a stall into a failure the caller can retry next turn-end. + */ +const DEFAULT_SUMMARY_TIMEOUT_MS = 120_000 + /** * Summarize a conversation into a compaction handoff summary. * @@ -24,6 +36,8 @@ interface SummarizeCoreInput { provider?: string apiKey?: string maxTokens?: number + /** Hard deadline for the model call; defaults to {@link DEFAULT_SUMMARY_TIMEOUT_MS}. */ + timeoutMs?: number complete?: SummarizeCompleteFn } @@ -54,10 +68,35 @@ async function summarizeConverted( ], } - const res = await complete(model, context, { + // Bound the call: pass `timeoutMs`/`signal` (which the anthropic provider + // honours) AND race against a hard timer, so a stalled stream that ignores the + // abort still rejects rather than hanging the background slot forever. + const timeoutMs = input.timeoutMs ?? DEFAULT_SUMMARY_TIMEOUT_MS + const controller = new AbortController() + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort() + reject(new Error(`[compaction] summarize timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }) + + const call = complete(model, context, { maxTokens: input.maxTokens ?? DEFAULT_SUMMARY_MAX_TOKENS, ...(input.apiKey && { apiKey: input.apiKey }), + signal: controller.signal, + timeoutMs, }) + // If the timeout wins the race, `call` rejects later (aborted) — swallow it so + // the loser doesn't surface as an unhandled rejection. + call.catch(() => {}) + + let res: Awaited> + try { + res = await Promise.race([call, timeout]) + } finally { + if (timer) clearTimeout(timer) + } const textBlock = res.content.find((block) => block.type === `text`) const text = textBlock && `text` in textBlock ? (textBlock.text ?? ``) : `` diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 9017a77cc5..33a407dea9 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -28,9 +28,14 @@ import { COMPACTION_CHECKPOINT_ID, COMPACTION_CHECKPOINT_KIND, COMPACTION_CHECKPOINT_NAME, + type CompactionStatus, } from './compaction' import { createMidTurnCompactor } from './compaction-midturn' -import { summarizeAgentMessages } from './compaction-summarize' +import { + summarizeAgentMessages, + summarizeMessages, +} from './compaction-summarize' +import { CONTEXT_USAGE_BACKGROUND_START } from './token-accountant' /** Default minimum context size (tokens) before mid-turn compaction fires. */ const DEFAULT_COMPACT_MIN_TOKENS = 2000 @@ -177,6 +182,19 @@ export interface HandlerContextConfig { export interface HandlerContextResult { ctx: HandlerContext getSleepRequested: () => boolean + /** + * Background (turn-end) compaction, driven by process-wake. Returns a handle + * whose promise resolves to the summary, or null when compaction isn't + * warranted (low usage / no agent / already compacted). + */ + maybeStartBackgroundCompaction: () => { + watermark: number + promise: Promise + } | null + /** Persist a completed background compaction checkpoint at its watermark. */ + writeBackgroundCheckpoint: (watermark: number, summary: string) => void + /** Mark an in-flight background compaction (identified by its watermark) failed. */ + failBackgroundCheckpoint: (watermark: number) => void } type DebugHandlerContext = @@ -1240,8 +1258,121 @@ export function createHandlerContext( }, } + // ── Background compaction (turn-end, non-blocking) ────────────────────── + // Driven by process-wake: after a turn, maybeStartBackgroundCompaction kicks + // off a detached summarization if usage is high; when it resolves, the NEXT + // turn writes the checkpoint via writeBackgroundCheckpoint (so all event + // writes stay inside a handler invocation). The checkpoint stores the + // snapshot watermark so reconstruction places the summary before any messages + // that arrived while it ran (see timeline-context). + const writeCompactionCheckpoint = ( + status: CompactionStatus, + content: string, + extraAttrs: Record = {}, + id: string = COMPACTION_CHECKPOINT_ID + ): void => { + contextApi.insertContext(id, { + name: COMPACTION_CHECKPOINT_NAME, + attrs: { kind: COMPACTION_CHECKPOINT_KIND, status, ...extraAttrs }, + content, + }) + } + + // Each background generation gets a watermark-unique checkpoint id, so the + // NEXT background's `running` row never supersedes the PREVIOUS `complete` + // one. They otherwise share the fixed id (supersession keys on id alone), and + // a fresh `running` silently erased the last `complete` watermark — undoing + // every compaction and pinning the indicator to "running". running→complete→ + // failed of ONE generation share this id so a generation supersedes itself. + const backgroundCheckpointId = (watermark: number): string => + `${COMPACTION_CHECKPOINT_ID}-bg-${watermark}` + + const latestCompleteCheckpointWatermark = (): number => { + let watermark = Number.NEGATIVE_INFINITY + for (const row of config.db.collections.contextInserted.toArray) { + const r = row as { + name?: string + attrs?: { kind?: string; status?: string; watermark?: unknown } + } + if ( + r.name !== COMPACTION_CHECKPOINT_NAME || + r.attrs?.kind !== COMPACTION_CHECKPOINT_KIND || + r.attrs?.status !== `complete` + ) { + continue + } + const w = + typeof r.attrs.watermark === `number` + ? r.attrs.watermark + : Number(r.attrs.watermark) + if (Number.isFinite(w) && w > watermark) watermark = w + } + return watermark + } + + const maybeStartBackgroundCompaction = (): { + watermark: number + promise: Promise + } | null => { + const cfg = agentConfig + if (!cfg) return null + const usage = selectLatestContextUsage( + config.db.collections.steps.toArray as ReadonlyArray + ) + if (!usage) return null + const ceiling = ratioFromEnv( + process.env.ELECTRIC_AGENTS_COMPACT_BG_CEILING, + CONTEXT_USAGE_BACKGROUND_START + ) + if (usage.ratio < ceiling) return null + const timestamped = runtimeTimelineMessages(config.db) + if (timestamped.length === 0) return null + const head = timestamped.reduce( + (max, message) => Math.max(max, message.at), + Number.NEGATIVE_INFINITY + ) + // Already summarized up to/past the head — nothing new to compact. + if (latestCompleteCheckpointWatermark() >= head) return null + + const messages = timelineToMessages(config.db) + const provider = agentModelProvider(cfg) + // Background-flavored `running` checkpoint for the UI (subtle indicator). + writeCompactionCheckpoint( + `running`, + ``, + { background: true, watermark: head }, + backgroundCheckpointId(head) + ) + const promise = (async () => { + const apiKey = await cfg.getApiKey?.(provider) + return summarizeMessages({ + model: cfg.model, + provider, + messages, + ...(apiKey ? { apiKey } : {}), + ...(cfg.summarizeComplete ? { complete: cfg.summarizeComplete } : {}), + }) + })() + return { watermark: head, promise } + } + return { ctx, getSleepRequested: () => sleepRequested, + maybeStartBackgroundCompaction, + writeBackgroundCheckpoint: (watermark: number, summary: string) => + writeCompactionCheckpoint( + `complete`, + summary, + { background: true, watermark }, + backgroundCheckpointId(watermark) + ), + failBackgroundCheckpoint: (watermark: number) => + writeCompactionCheckpoint( + `failed`, + ``, + { background: true }, + backgroundCheckpointId(watermark) + ), } } diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 5ce8e2d062..54fae51235 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -591,6 +591,20 @@ export async function processWake( let idleTimer: ReturnType | null = null let idleController: AbortController | null = null let runAbortController: AbortController | null = null + // Turn-end background compaction: a detached summarization started after a + // turn whose result (or failure) is applied as a checkpoint either at the + // *next* turn's start, or — if the summarize finishes while the entity is + // idle — immediately by waking the idle loop (so the "Compacting…" indicator + // never lingers past completion). The checkpoint write always stays inside a + // handler/idle drain. Persists across loop iterations. + let pendingBackgroundCompaction: { + watermark: number + status: `pending` | `ready` | `failed` + summary: string + } | null = null + // Set by the detached summarize when it settles while idle; signals the idle + // loop to apply the checkpoint without running the agent handler. + let backgroundApplyRequestedDuringIdle = false let activeSignalHandler: | (( signal: Pick @@ -2058,6 +2072,16 @@ export async function processWake( }) armIdleTimer() queueNextWakeIfReady() + // A background compaction may have settled in the gap between the + // post-handler start and entering idle; wake immediately to apply it. + if ( + queuedNextWake === null && + pendingBackgroundCompaction && + pendingBackgroundCompaction.status !== `pending` + ) { + backgroundApplyRequestedDuringIdle = true + idleController.abort() + } await idleWait acceptLiveInputs = false @@ -2162,7 +2186,13 @@ export async function processWake( pendingRunAbortRequested = false runAbortController.abort() } - const { ctx: handlerCtx, getSleepRequested } = createHandlerContext({ + const { + ctx: handlerCtx, + getSleepRequested, + maybeStartBackgroundCompaction, + writeBackgroundCheckpoint, + failBackgroundCheckpoint, + } = createHandlerContext({ entityUrl, entityType: typeName, epoch, @@ -2241,10 +2271,65 @@ export async function processWake( `payloadType=${typeof currentWakeEvent.payload} ` + `firstWake=${initialFirstWake && !setupComplete}` ) + // Apply a ready (or failed) background compaction from a prior turn + // *before* the handler reconstructs its context, so this turn starts + // compacted. Always inside a handler invocation → normal write/flush. + if ( + pendingBackgroundCompaction && + pendingBackgroundCompaction.status !== `pending` + ) { + if (pendingBackgroundCompaction.status === `ready`) { + writeBackgroundCheckpoint( + pendingBackgroundCompaction.watermark, + pendingBackgroundCompaction.summary + ) + } else { + failBackgroundCheckpoint(pendingBackgroundCompaction.watermark) + } + pendingBackgroundCompaction = null + } + const handlerT0 = performance.now() await definition.handler(handlerCtx, currentWakeEvent) handlerMs += +(performance.now() - handlerT0).toFixed(2) log.info(`handler returned`) + + // Kick off a turn-end background compaction if usage is high and none is + // already pending. Detached: the summarize runs off the critical path + // and its checkpoint is written at the next turn's start (above). + if (!pendingBackgroundCompaction) { + const started = maybeStartBackgroundCompaction() + if (started) { + const slot: { + watermark: number + status: `pending` | `ready` | `failed` + summary: string + } = { watermark: started.watermark, status: `pending`, summary: `` } + pendingBackgroundCompaction = slot + const onSettled = (): void => { + // If the summarize finished while we're idle (no turn to apply it + // at), wake the idle loop so the checkpoint is written now and the + // indicator clears. Otherwise the next turn's pre-handler applies. + if (acceptLiveInputs && pendingBackgroundCompaction === slot) { + backgroundApplyRequestedDuringIdle = true + idleController?.abort() + } + } + started.promise + .then((summary) => { + slot.status = `ready` + slot.summary = summary + onSettled() + }) + .catch((err) => { + slot.status = `failed` + log.warn( + `background compaction failed: ${toError(err).message}` + ) + onSettled() + }) + } + } await waitForSignalHandlers() activeSignalHandler = null await waitForSharedStateWiring() @@ -2377,10 +2462,40 @@ export async function processWake( break } - const resumed = await awaitIdleForFreshWork( - `handler returned, entering idle (${idleTimeout / 1000}s timeout)` - ) - if (resumed) { + let resumedFromIdle = false + let idleReason = `handler returned` + for (;;) { + const resumed = await awaitIdleForFreshWork( + `${idleReason}, entering idle (${idleTimeout / 1000}s timeout)` + ) + if (resumed) { + resumedFromIdle = true + break + } + // Woke from idle because a detached background compaction settled with + // no turn to apply it at. Write its checkpoint now (NO agent run) so the + // indicator clears and the next turn starts compacted, then re-idle. + const settled = backgroundApplyRequestedDuringIdle + ? pendingBackgroundCompaction + : null + backgroundApplyRequestedDuringIdle = false + if (!settled || settled.status === `pending`) { + break + } + if (settled.status === `ready`) { + writeBackgroundCheckpoint(settled.watermark, settled.summary) + log.info(`background compaction applied during idle`) + } else { + failBackgroundCheckpoint(settled.watermark) + log.info(`background compaction failed; cleared during idle`) + } + pendingBackgroundCompaction = null + await drainAllPendingWrites() + await wakeSession.commitManifestEntries() + await flushProducedWrites() + idleReason = `background compaction applied` + } + if (resumedFromIdle) { continue } break diff --git a/packages/agents-runtime/src/timeline-context.ts b/packages/agents-runtime/src/timeline-context.ts index 9c85dc7618..b8fdf84415 100644 --- a/packages/agents-runtime/src/timeline-context.ts +++ b/packages/agents-runtime/src/timeline-context.ts @@ -472,27 +472,43 @@ export function timelineMessages( const attachmentsByInboxKey = attachmentsBySubjectInboxKey(db) const messages: Array = [] - // A completed compaction checkpoint summarizes everything before it: drop - // items below its order (the watermark) and let the checkpoint itself render - // the summary in their place. Only `complete` checkpoints count, so a - // `running` (or crashed) one never hides history. No checkpoint → watermark - // stays -Infinity → no-op. - const compactionWatermark = latestCompleteCompactionWatermark(items) + // A completed compaction checkpoint summarizes everything up to its watermark. + // Items below the watermark are dropped; the summary is rendered AT the + // watermark — so it precedes any messages that arrived during a *background* + // compaction (physically written before the checkpoint row, but logically + // after the watermark). The watermark is the checkpoint's stored + // `attrs.watermark` (captured when background compaction snapshotted the + // head), falling back to the row's own order for synchronous compaction. Only + // `complete` checkpoints count, so a `running`/crashed one never hides history. + const activeCheckpoint = latestCompleteCompactionCheckpoint(items) + const compactionWatermark = activeCheckpoint + ? compactionWatermarkOf(activeCheckpoint) + : Number.NEGATIVE_INFINITY + let summaryEmitted = false + const emitSummary = (): void => { + if (!activeCheckpoint || summaryEmitted) return + for (const message of projection(activeCheckpoint) ?? []) { + messages.push({ ...message, at: compactionWatermark }) + } + summaryEmitted = true + } for (const item of items) { if (item.at < since) { continue } - if (item.at < compactionWatermark) { + // The watermark is the order of the last summarized item (the snapshot + // head), so everything at or below it is folded into the summary. + if (item.at <= compactionWatermark) { continue } - // Compaction checkpoints are UI-only markers, except the active (complete, - // watermark) one, which renders the summary. Skip running/failed/older - // checkpoints from the model context. + // Place the summary at the watermark boundary, before the first kept item. + emitSummary() + // All compaction checkpoint rows are UI-only here (the active summary is + // emitted above); skip them from the model context. if ( item.kind === `context_inserted` && - isCompactionCheckpointAttrs(item.attrs) && - item.at !== compactionWatermark + isCompactionCheckpointAttrs(item.attrs) ) { continue } @@ -504,31 +520,50 @@ export function timelineMessages( }) } } + // A checkpoint with no kept items after it (e.g. a `since` filter past the + // head) still surfaces its summary. + emitSummary() return messages } /** - * Order (`at`) of the newest live *completed* compaction checkpoint, or - * -Infinity if none. Everything strictly below it has been summarized into the - * checkpoint. `running`/`failed` checkpoints are ignored so an in-flight or - * crashed compaction never hides history. + * The newest live *completed* compaction checkpoint item, or null. `running`/ + * `failed` checkpoints are ignored so an in-flight or crashed compaction never + * hides history. */ -function latestCompleteCompactionWatermark( +function latestCompleteCompactionCheckpoint( items: ReadonlyArray -): number { - let watermark = Number.NEGATIVE_INFINITY +): Extract | null { + let latest: Extract | null = null for (const item of items) { if ( item.kind === `context_inserted` && !item.superseded && isCompleteCompactionCheckpointAttrs(item.attrs) && - item.at > watermark + (latest === null || item.at > latest.at) ) { - watermark = item.at + latest = item } } - return watermark + return latest +} + +/** + * Logical watermark for a compaction checkpoint: the stored `attrs.watermark` + * (set by background compaction at snapshot time) if valid, else the row's own + * order (synchronous compaction writes at the point it summarizes). + */ +function compactionWatermarkOf( + checkpoint: Extract +): number { + const stored = checkpoint.attrs?.watermark + if (typeof stored === `number` && Number.isFinite(stored)) return stored + if (typeof stored === `string` && stored.length > 0) { + const parsed = Number(stored) + if (Number.isFinite(parsed)) return parsed + } + return checkpoint.at } export function timelineToMessages(db: EntityStreamDB): Array { diff --git a/packages/agents-runtime/test/compaction-background.test.ts b/packages/agents-runtime/test/compaction-background.test.ts new file mode 100644 index 0000000000..733235bf70 --- /dev/null +++ b/packages/agents-runtime/test/compaction-background.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + buildStreamFixture, + createTestHandlerContext, +} from './helpers/context-test-helpers' +import type { ChangeEvent } from '@durable-streams/state' + +function seedStep( + db: ReturnType, + contextInputTokens: number +): void { + ;( + db.collections as unknown as { steps: { insert: (r: unknown) => void } } + ).steps.insert({ + key: `step-1`, + _seq: 1, + run_id: `r`, + step_number: 1, + status: `completed`, + context_input_tokens: contextInputTokens, + context_window: 200000, + }) +} + +function compactionAttrs(event: ChangeEvent): + | { + kind?: string + status?: string + background?: boolean + watermark?: unknown + } + | undefined { + return (event.value as { attrs?: { kind?: string } } | undefined) + ?.attrs as never +} + +describe(`background compaction (turn-end)`, () => { + it(`starts when usage is high: summarizes + writes a background running checkpoint`, async () => { + const db = buildStreamFixture( + Array.from({ length: 6 }, (_, i) => ({ + kind: `inbox` as const, + at: i + 1, + value: { payload: `MSG_${i}` }, + })) + ) + seedStep(db, 190000) // 95% of the 200k window ≥ 85% background start + + const writes: Array = [] + const res = createTestHandlerContext({ + db, + writeEvent: (event) => { + writes.push(event) + db.utils.applyEvent(event) + }, + }) + res.ctx.useAgent({ + systemPrompt: `t`, + model: `claude-sonnet-4-5-20250929`, + provider: `anthropic`, + tools: [], + summarizeComplete: async () => ({ + content: [{ type: `text`, text: `BG_SUMMARY` }], + }), + }) + + const handle = res.maybeStartBackgroundCompaction() + expect(handle).not.toBeNull() + + const summary = await handle!.promise + expect(summary).toContain(`BG_SUMMARY`) + + // A background-flavored running checkpoint was written for the UI. + const idOf = (e: ChangeEvent): string | undefined => + (e.value as { id?: string } | undefined)?.id + const runningWrite = writes.find((e) => { + const a = compactionAttrs(e) + return a?.status === `running` && a?.background === true + }) + expect(runningWrite).toBeDefined() + // …under a watermark-unique id, so the NEXT generation's `running` can't + // supersede this generation's `complete`. + expect(idOf(runningWrite!)).toBe(`compaction-bg-${handle!.watermark}`) + + // Applying the result writes a complete checkpoint carrying the watermark, + // under the SAME generation id (so it supersedes its own running row). + res.writeBackgroundCheckpoint(handle!.watermark, summary) + const completeWrite = writes.find( + (e) => compactionAttrs(e)?.status === `complete` + ) + expect(completeWrite).toBeDefined() + expect(compactionAttrs(completeWrite!)?.watermark).toBe(handle!.watermark) + expect(idOf(completeWrite!)).toBe(`compaction-bg-${handle!.watermark}`) + }) + + it(`skips when usage is below the background threshold`, () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `hi` } }, + ]) + seedStep(db, 50000) // 25% + const res = createTestHandlerContext({ db }) + res.ctx.useAgent({ + systemPrompt: `t`, + model: `claude-sonnet-4-5-20250929`, + provider: `anthropic`, + tools: [], + }) + expect(res.maybeStartBackgroundCompaction()).toBeNull() + }) + + it(`skips when already compacted up to the head`, () => { + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `hi` } }, + { + kind: `context_inserted`, + at: 2, + value: { + id: `compaction`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `complete`, watermark: 99999 }, + content: `S`, + }, + }, + ]) + seedStep(db, 190000) + const res = createTestHandlerContext({ db }) + res.ctx.useAgent({ + systemPrompt: `t`, + model: `claude-sonnet-4-5-20250929`, + provider: `anthropic`, + tools: [], + }) + expect(res.maybeStartBackgroundCompaction()).toBeNull() + }) +}) diff --git a/packages/agents-runtime/test/compaction-summarize.test.ts b/packages/agents-runtime/test/compaction-summarize.test.ts index 0c993c9e61..6928de71c6 100644 --- a/packages/agents-runtime/test/compaction-summarize.test.ts +++ b/packages/agents-runtime/test/compaction-summarize.test.ts @@ -41,6 +41,32 @@ describe(`summarizeMessages`, () => { expect(out).toContain(`SUMMARY_BODY`) }) + it(`rejects (does not hang) when the model call stalls past the timeout`, async () => { + // A stalled stream that never resolves — the real failure mode that wedged + // background compaction. The timeout must turn it into a rejection. + let aborted = false + const complete: SummarizeCompleteFn = (_model, _context, options) => + // Never settles on its own — models a stalled stream that ignores abort, + // so only the hard timer can break the wait. + new Promise(() => { + const signal = (options as { signal?: AbortSignal } | undefined)?.signal + signal?.addEventListener(`abort`, () => { + aborted = true + }) + }) + + await expect( + summarizeMessages({ + model: `claude-sonnet-4-5-20250929`, + messages, + complete, + timeoutMs: 20, + }) + ).rejects.toThrow(/timed out after 20ms/) + // The caller is also signalled to abort so the underlying fetch can unwind. + expect(aborted).toBe(true) + }) + it(`throws when the model returns an empty summary`, async () => { const complete: SummarizeCompleteFn = async () => ({ content: [{ type: `text`, text: ` ` }], diff --git a/packages/agents-runtime/test/timeline-compaction.test.ts b/packages/agents-runtime/test/timeline-compaction.test.ts index 8de6f5d5af..661d1580b0 100644 --- a/packages/agents-runtime/test/timeline-compaction.test.ts +++ b/packages/agents-runtime/test/timeline-compaction.test.ts @@ -33,6 +33,40 @@ describe(`timelineMessages compaction reconstruction`, () => { expect(out).toContain(`LATEST_MESSAGE`) }) + it(`places the summary at the stored watermark, before during-compaction messages`, () => { + // Background compaction snapshotted W=2 (after SECOND), then a prompt+answer + // arrived (at 3,4) while summarizing, and the checkpoint was written LATE + // (at 5) with attrs.watermark=2. The summary must render BEFORE the + // during-compaction messages, not at its physical (late) position. + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { kind: `inbox`, at: 2, value: { payload: `SECOND_MESSAGE` } }, + { kind: `inbox`, at: 3, value: { payload: `DURING_PROMPT` } }, + { kind: `inbox`, at: 4, value: { payload: `DURING_ANSWER` } }, + { + kind: `context_inserted`, + at: 5, + value: { + id: `compaction`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `complete`, watermark: 2 }, + content: `SUMMARY_OF_EARLIER_WORK`, + }, + }, + ]) + + const out = serialize(db) + expect(out).not.toContain(`FIRST_MESSAGE`) + expect(out).not.toContain(`SECOND_MESSAGE`) + // Summary first, then the during-compaction prompt + answer verbatim. + const iSummary = out.indexOf(`SUMMARY_OF_EARLIER_WORK`) + const iPrompt = out.indexOf(`DURING_PROMPT`) + const iAnswer = out.indexOf(`DURING_ANSWER`) + expect(iSummary).toBeGreaterThanOrEqual(0) + expect(iSummary).toBeLessThan(iPrompt) + expect(iPrompt).toBeLessThan(iAnswer) + }) + it(`does NOT hide history for a running (incomplete) checkpoint`, () => { // Crash-safety: an in-flight/crashed compaction must never drop history. const db = buildStreamFixture([ @@ -57,6 +91,47 @@ describe(`timelineMessages compaction reconstruction`, () => { expect(out).not.toContain(`compaction_summary`) }) + it(`keeps applying a completed background checkpoint after the NEXT background starts`, () => { + // Regression: every background generation has a watermark-unique id, so a + // fresh `running` checkpoint (next generation) must NOT supersede the prior + // `complete` one. With the old shared id, the running row erased the + // complete watermark and reconstruction stopped compacting entirely. + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, + { kind: `inbox`, at: 2, value: { payload: `SECOND_MESSAGE` } }, + { + kind: `context_inserted`, + at: 3, + value: { + id: `compaction-bg-2`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `complete`, watermark: 2 }, + content: `SUMMARY_OF_EARLIER_WORK`, + }, + }, + { kind: `inbox`, at: 4, value: { payload: `LATEST_MESSAGE` } }, + // The next background generation kicks off — different id, so it must not + // clobber the complete above. + { + kind: `context_inserted`, + at: 5, + value: { + id: `compaction-bg-4`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `running`, watermark: 4 }, + content: ``, + }, + }, + ]) + + const out = serialize(db) + // The completed checkpoint still compacts away the early messages… + expect(out).not.toContain(`FIRST_MESSAGE`) + expect(out).not.toContain(`SECOND_MESSAGE`) + expect(out).toContain(`SUMMARY_OF_EARLIER_WORK`) + expect(out).toContain(`LATEST_MESSAGE`) + }) + it(`is a no-op when there is no compaction checkpoint`, () => { const db = buildStreamFixture([ { kind: `inbox`, at: 1, value: { payload: `FIRST_MESSAGE` } }, diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.module.css b/packages/agents-server-ui/src/components/CompactionIndicator.module.css index 3ec362a28e..e610a7be94 100644 --- a/packages/agents-server-ui/src/components/CompactionIndicator.module.css +++ b/packages/agents-server-ui/src/components/CompactionIndicator.module.css @@ -7,6 +7,13 @@ user-select: none; } +/* Background compaction is non-blocking — render it muted/subtle so it doesn't + read as "you're blocked" like the sync (amber) indicator. */ +.background { + color: var(--text-muted, rgba(0, 0, 0, 0.55)); + opacity: 0.8; +} + .label { font-size: 0.72rem; line-height: 1; diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.tsx b/packages/agents-server-ui/src/components/CompactionIndicator.tsx index 04b49a3153..a13d881892 100644 --- a/packages/agents-server-ui/src/components/CompactionIndicator.tsx +++ b/packages/agents-server-ui/src/components/CompactionIndicator.tsx @@ -15,7 +15,7 @@ import styles from './CompactionIndicator.module.css' interface CheckpointRow { _seq?: number - attrs?: { kind?: string; status?: string } + attrs?: { kind?: string; status?: string; background?: boolean } } interface CompactionIndicatorProps { @@ -33,7 +33,7 @@ export function CompactionIndicator({ [db] ) - const compacting = useMemo(() => { + const running = useMemo(() => { // The newest compaction checkpoint wins (later writes supersede earlier // ones for the same id). If it's still `running`, compaction is in flight. let latest: CheckpointRow | null = null @@ -41,15 +41,31 @@ export function CompactionIndicator({ if (row.attrs?.kind !== `compaction`) continue if (!latest || (row._seq ?? 0) > (latest._seq ?? 0)) latest = row } - return latest?.attrs?.status === `running` + if (latest?.attrs?.status !== `running`) return null + return { background: Boolean(latest.attrs?.background) } }, [rows]) - if (!compacting) return null + if (!running) return null + // Background compaction is non-blocking, so it's shown subtly and distinctly + // from the blocking (sync, mid-turn) "Compacting context…". return ( - + ) } From 5a1ea25d15d1869629a49f84710cbfc5534a2207 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 16:56:07 +0200 Subject: [PATCH 020/105] chore: changeset for context compaction --- .changeset/context-compaction.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/context-compaction.md diff --git a/.changeset/context-compaction.md b/.changeset/context-compaction.md new file mode 100644 index 0000000000..cabb7a4ffd --- /dev/null +++ b/.changeset/context-compaction.md @@ -0,0 +1,26 @@ +--- +'@electric-ax/agents-runtime': minor +'@electric-ax/agents-server-ui': patch +--- + +Context compaction for the agents runtime. Modelled on OpenAI Codex's +summarization but adapted to the event-sourced timeline (a `context_inserted` +checkpoint placed at a stored watermark, so reconstruction folds older messages +into a summary): + +- A context-window usage gauge (cache-inclusive `context_input_tokens` + + `context_window` persisted per step) and `` notices injected at + 25 / 50 / 75% usage. +- Oversized tool-output truncation, and a synchronous mid-turn compaction floor + at the 90% hard ceiling (runs before every model step via the adapter's + `transformContext` hook). +- Non-blocking background (turn-end) compaction that starts at 85%: a detached + summarize whose checkpoint is applied at the next turn's start, or immediately + if it finishes while idle. Each generation uses a watermark-unique checkpoint + id so a new run can't supersede a prior completed one. Summarize calls are + bounded by a hard timeout. +- UI: a "Compacting…" indicator (blocking vs. background) and a collapsible + "Context compacted" entry in the conversation timeline. + +Thresholds are env-tunable (`ELECTRIC_AGENTS_COMPACT_CEILING`, +`ELECTRIC_AGENTS_COMPACT_BG_CEILING`, `ELECTRIC_AGENTS_COMPACT_MIN_TOKENS`). From 26a190106cddc7e300ad4f40b73b1f6697429cff Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 17:03:40 +0200 Subject: [PATCH 021/105] chore: downgrade context compaction changeset to patch --- .changeset/context-compaction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/context-compaction.md b/.changeset/context-compaction.md index cabb7a4ffd..c4d9139109 100644 --- a/.changeset/context-compaction.md +++ b/.changeset/context-compaction.md @@ -1,5 +1,5 @@ --- -'@electric-ax/agents-runtime': minor +'@electric-ax/agents-runtime': patch '@electric-ax/agents-server-ui': patch --- From 86d5e66895f3d3229e1b4cd9530350b24ca42834 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 10:27:25 +0200 Subject: [PATCH 022/105] refactor(agents): drop the mid-turn compaction min-tokens floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `minTokens` guard (default 2000, env ELECTRIC_AGENTS_COMPACT_MIN_TOKENS) never changed the outcome at any realistic ceiling — 90% of a real context window is always far above 2000 tokens, so the floor only ever mattered when testing with an artificially low ceiling. Codex has no equivalent floor (it triggers on a single token threshold). Remove the knob, its env override, and the now-unused positiveFromEnv helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/context-compaction.md | 2 +- packages/agents-runtime/src/compaction-midturn.ts | 6 +----- packages/agents-runtime/src/context-factory.ts | 14 -------------- .../agents-runtime/test/compaction-midturn.test.ts | 6 ------ .../agents-runtime/test/compaction-trigger.test.ts | 3 --- 5 files changed, 2 insertions(+), 29 deletions(-) diff --git a/.changeset/context-compaction.md b/.changeset/context-compaction.md index c4d9139109..aa01920e9a 100644 --- a/.changeset/context-compaction.md +++ b/.changeset/context-compaction.md @@ -23,4 +23,4 @@ into a summary): "Context compacted" entry in the conversation timeline. Thresholds are env-tunable (`ELECTRIC_AGENTS_COMPACT_CEILING`, -`ELECTRIC_AGENTS_COMPACT_BG_CEILING`, `ELECTRIC_AGENTS_COMPACT_MIN_TOKENS`). +`ELECTRIC_AGENTS_COMPACT_BG_CEILING`). diff --git a/packages/agents-runtime/src/compaction-midturn.ts b/packages/agents-runtime/src/compaction-midturn.ts index 6cb9cba494..bf9bca6ad9 100644 --- a/packages/agents-runtime/src/compaction-midturn.ts +++ b/packages/agents-runtime/src/compaction-midturn.ts @@ -15,8 +15,6 @@ export interface MidTurnCompactorDeps { writeCheckpoint: (status: CompactionStatus, content: string) => void /** Compaction fires at/above this fraction of the context window. */ ceiling: number - /** Don't compact unless the context is at least this many tokens. */ - minTokens: number /** Keep this many of the most recent messages verbatim (the live tail). */ keepTail: number } @@ -77,9 +75,7 @@ export function createMidTurnCompactor( : null return async ({ messages, currentTokens, contextWindow }) => { - const overCeiling = - currentTokens >= deps.ceiling * contextWindow && - currentTokens > deps.minTokens + const overCeiling = currentTokens >= deps.ceiling * contextWindow // Under the ceiling: keep the compacted view sticky if we already compacted // this turn, otherwise leave the context untouched. diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 33a407dea9..9bef9302b9 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -37,8 +37,6 @@ import { } from './compaction-summarize' import { CONTEXT_USAGE_BACKGROUND_START } from './token-accountant' -/** Default minimum context size (tokens) before mid-turn compaction fires. */ -const DEFAULT_COMPACT_MIN_TOKENS = 2000 /** Recent messages kept verbatim when compacting (the live tool-call tail). */ const COMPACT_KEEP_TAIL = 6 import { createContextTools } from './tools/context-tools' @@ -101,12 +99,6 @@ function ratioFromEnv(raw: string | undefined, fallback: number): number { return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback } -/** Parse a positive number from an env string, falling back when invalid. */ -function positiveFromEnv(raw: string | undefined, fallback: number): number { - const value = Number(raw) - return Number.isFinite(value) && value > 0 ? value : fallback -} - const MAX_HYDRATED_IMAGE_ATTACHMENTS = 4 const MAX_HYDRATED_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024 @@ -844,19 +836,13 @@ export function createHandlerContext( // failed checkpoint that drives the UI. Thresholds overridable via env // (RFC §12 tunables): // ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) - // ELECTRIC_AGENTS_COMPACT_MIN_TOKENS (default 2000) const compactCeiling = ratioFromEnv( process.env.ELECTRIC_AGENTS_COMPACT_CEILING, CONTEXT_USAGE_HARD_CEILING ) - const compactMinTokens = positiveFromEnv( - process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS, - DEFAULT_COMPACT_MIN_TOKENS - ) const compactProvider = agentModelProvider(activeAgentConfig) const onCompactContext = createMidTurnCompactor({ ceiling: compactCeiling, - minTokens: compactMinTokens, keepTail: COMPACT_KEEP_TAIL, writeCheckpoint: (status, content) => { contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { diff --git a/packages/agents-runtime/test/compaction-midturn.test.ts b/packages/agents-runtime/test/compaction-midturn.test.ts index ccafd52a1b..212f4e3659 100644 --- a/packages/agents-runtime/test/compaction-midturn.test.ts +++ b/packages/agents-runtime/test/compaction-midturn.test.ts @@ -17,7 +17,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint, ceiling: 0.9, - minTokens: 100, keepTail: 4, }) const out = await compact({ @@ -37,7 +36,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint, ceiling: 0.9, - minTokens: 100, keepTail: 4, }) const messages = msgs(10) @@ -62,7 +60,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: vi.fn(), ceiling: 0.9, - minTokens: 100, keepTail: 4, }) const messages = msgs(10) @@ -88,7 +85,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: vi.fn(), ceiling: 0.9, - minTokens: 100, keepTail: 4, }) await compact({ @@ -114,7 +110,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: vi.fn(), ceiling: 0.9, - minTokens: 100, keepTail: 4, }) // Boundary would land at index 6 (10 − keepTail 4); make that a tool_result @@ -139,7 +134,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: (s: string) => statuses.push(s), ceiling: 0.9, - minTokens: 100, keepTail: 4, }) const out = await compact({ diff --git a/packages/agents-runtime/test/compaction-trigger.test.ts b/packages/agents-runtime/test/compaction-trigger.test.ts index 6b293d56da..db0127d992 100644 --- a/packages/agents-runtime/test/compaction-trigger.test.ts +++ b/packages/agents-runtime/test/compaction-trigger.test.ts @@ -11,11 +11,9 @@ import type { ChangeEvent } from '@durable-streams/state' // we drive the trigger with the env override + a high seeded anchor instead. const savedEnv = { ceiling: process.env.ELECTRIC_AGENTS_COMPACT_CEILING, - minTokens: process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS, } afterEach(() => { process.env.ELECTRIC_AGENTS_COMPACT_CEILING = savedEnv.ceiling - process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS = savedEnv.minTokens }) function completedAssistantMessage(): unknown { @@ -59,7 +57,6 @@ describe(`mid-turn compaction trigger`, () => { it(`compacts mid-turn: summarizer runs, model sees the summary, checkpoint persisted`, async () => { // Ceiling tiny so any real model window is crossed by the seeded anchor. process.env.ELECTRIC_AGENTS_COMPACT_CEILING = `0.0001` - process.env.ELECTRIC_AGENTS_COMPACT_MIN_TOKENS = `10` // Enough messages that there's real content beyond the kept tail (6). const db = buildStreamFixture( From d81d8c3e31efd24552da8fee883ae142be09e048 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 11:13:07 +0200 Subject: [PATCH 023/105] fix(agents): mid-turn compaction must not drop the kept tail [review #4605] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn (sync floor) checkpoint was persisted with no attrs.watermark, so reconstruction fell back to the checkpoint row's own (latest) stream position and, on the next turn, dropped every item before it — including the verbatim tail the mid-turn summary deliberately excluded (keepTail). Those recent messages were in neither the summary nor the kept set, so they were silently lost on the 90% path. Fix: the mid-turn compactor now folds the WHOLE context into the summary (Codex-style — no verbatim pre-compaction tail), and the checkpoint is stamped with watermark = current timeline head. Summary and watermark now agree, so reconstruction folds exactly what was summarized and keeps everything appended afterward (the model's post-compaction output + the next prompt). This also removes the keepTail + tool-pair orphan-guard complexity. Recent context is still shown after a compaction via the sticky view (summary + messages appended since), so within-turn coherence is preserved. Also drop the stale "95%" hard-ceiling comments (it has been 90% since the ceiling was lowered to match Codex). Tests: rewrote compaction-midturn for the summarize-everything behavior; added a reconstruction test asserting post-compaction messages survive a mid-turn checkpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/compaction-midturn.ts | 34 ++++++-------- packages/agents-runtime/src/compaction.ts | 2 +- .../agents-runtime/src/context-factory.ts | 22 +++++++-- .../agents-runtime/src/token-accountant.ts | 2 +- .../test/compaction-midturn.test.ts | 47 +++++-------------- .../test/timeline-compaction.test.ts | 34 ++++++++++++++ 6 files changed, 79 insertions(+), 62 deletions(-) diff --git a/packages/agents-runtime/src/compaction-midturn.ts b/packages/agents-runtime/src/compaction-midturn.ts index bf9bca6ad9..b72e305ed0 100644 --- a/packages/agents-runtime/src/compaction-midturn.ts +++ b/packages/agents-runtime/src/compaction-midturn.ts @@ -15,8 +15,6 @@ export interface MidTurnCompactorDeps { writeCheckpoint: (status: CompactionStatus, content: string) => void /** Compaction fires at/above this fraction of the context window. */ ceiling: number - /** Keep this many of the most recent messages verbatim (the live tail). */ - keepTail: number } export interface CompactContextInput { @@ -49,13 +47,20 @@ export function buildCompactionSummaryMessage( * Build the per-turn mid-turn compaction hook for one agent run. The returned * function is wired to pi-agent's `transformContext` — it runs before every * model step. Once the estimated outgoing context crosses the ceiling it folds - * the older messages into a summary and returns `[summary, ...recent tail]`. + * the WHOLE context into a summary and continues from `[summary, ...anything + * appended since]` (Codex-style — no verbatim pre-compaction tail). + * + * Summarizing everything is what keeps the persisted checkpoint sound: the + * `writeCheckpoint` wiring stamps it with `watermark = timeline head`, and a + * verbatim tail kept below that head would be dropped on the next turn's + * reconstruction yet excluded from the summary — silently losing context. By + * covering everything up to the head, summary and watermark agree. * * The summary is cached for the rest of the turn: later steps reuse it * (returning the compacted view) instead of re-summarizing. Coverage is only - * extended if the tail grows back over the ceiling, and a re-summarization - * chains off the previous summary (prev summary + new middle) rather than - * re-reading the whole already-summarized bulk. + * extended once new messages appended this turn push back over the ceiling, and + * a re-summarization chains off the previous summary (prev summary + new + * middle) rather than re-reading the whole already-summarized bulk. * * Returns `null` to leave the context untouched (no compaction needed/active). */ @@ -81,20 +86,9 @@ export function createMidTurnCompactor( // this turn, otherwise leave the context untouched. if (!overCeiling) return compactedView(messages) - const keepTail = Math.min(deps.keepTail, Math.max(0, messages.length - 1)) - let coveredCount = messages.length - keepTail - - // Don't split a tool_call/tool_result pair: the kept tail must not start - // with an orphaned tool_result, whose matching tool_use (in the assistant - // message) would be folded into the summary — Anthropic rejects that. Advance - // the fold boundary past any leading tool-result messages so the tail begins - // on a fresh user/assistant turn. - while ( - coveredCount < messages.length && - messages[coveredCount]?.role === `toolResult` - ) { - coveredCount++ - } + // Fold the ENTIRE current context (everything maps to timeline items at or + // below the head the checkpoint will store), so summary and watermark agree. + const coveredCount = messages.length // Nothing new to fold beyond the existing summary's coverage. if (coveredCount <= 0) return compactedView(messages) diff --git a/packages/agents-runtime/src/compaction.ts b/packages/agents-runtime/src/compaction.ts index bea6f5dd34..842bc5cb88 100644 --- a/packages/agents-runtime/src/compaction.ts +++ b/packages/agents-runtime/src/compaction.ts @@ -8,7 +8,7 @@ * watermark: timeline reconstruction hides everything before it and emits the * summary in their place (see `timelineMessages`). * - * Phase 2 wires this up synchronously at the 95% hard ceiling; Phase 3 adds the + * Phase 2 wires this up synchronously at the 90% hard ceiling; Phase 3 adds the * background trigger at 85%. */ diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 9bef9302b9..10c4a87329 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -37,8 +37,6 @@ import { } from './compaction-summarize' import { CONTEXT_USAGE_BACKGROUND_START } from './token-accountant' -/** Recent messages kept verbatim when compacting (the live tool-call tail). */ -const COMPACT_KEEP_TAIL = 6 import { createContextTools } from './tools/context-tools' import { CACHE_TIERS } from './types' import { composeToolsWithProviders } from './tool-providers' @@ -843,11 +841,27 @@ export function createHandlerContext( const compactProvider = agentModelProvider(activeAgentConfig) const onCompactContext = createMidTurnCompactor({ ceiling: compactCeiling, - keepTail: COMPACT_KEEP_TAIL, writeCheckpoint: (status, content) => { + const attrs: Record = { + kind: COMPACTION_CHECKPOINT_KIND, + status, + } + // Stamp the completed checkpoint with the current timeline head as + // its watermark. The mid-turn summary covers the whole context, so + // reconstruction folds exactly that (items <= head) and keeps + // everything appended after — no verbatim tail is dropped. Without + // this, the watermark falls back to the checkpoint row's own (later) + // position and silently drops the most recent messages next turn. + if (status === `complete`) { + const head = runtimeTimelineMessages(config.db).reduce( + (max, message) => Math.max(max, message.at), + Number.NEGATIVE_INFINITY + ) + if (Number.isFinite(head)) attrs.watermark = head + } contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { name: COMPACTION_CHECKPOINT_NAME, - attrs: { kind: COMPACTION_CHECKPOINT_KIND, status }, + attrs, content, }) }, diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index e7ee013b04..6be216f9b9 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -80,7 +80,7 @@ export type ContextUsageLevel = `normal` | `warning` | `critical` /** * Severity bucket for a usage ratio, aligned to the compaction thresholds: * `warning` once background compaction would start (85%), `critical` at the - * hard ceiling (95%). + * hard ceiling (90%). */ export function contextUsageLevel(ratio: number): ContextUsageLevel { if (ratio >= CONTEXT_USAGE_HARD_CEILING) return `critical` diff --git a/packages/agents-runtime/test/compaction-midturn.test.ts b/packages/agents-runtime/test/compaction-midturn.test.ts index 212f4e3659..93ca06c97a 100644 --- a/packages/agents-runtime/test/compaction-midturn.test.ts +++ b/packages/agents-runtime/test/compaction-midturn.test.ts @@ -17,7 +17,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint, ceiling: 0.9, - keepTail: 4, }) const out = await compact({ messages: msgs(10), @@ -28,7 +27,7 @@ describe(`createMidTurnCompactor`, () => { expect(summarize).not.toHaveBeenCalled() }) - it(`compacts over the ceiling: running→complete + [summary, ...tail]`, async () => { + it(`compacts over the ceiling: summarizes the WHOLE context, continues from [summary]`, async () => { const summarize = vi.fn().mockResolvedValue(`SUMMARY`) const statuses: Array = [] const writeCheckpoint = vi.fn((s: string) => statuses.push(s)) @@ -36,7 +35,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint, ceiling: 0.9, - keepTail: 4, }) const messages = msgs(10) const out = await compact({ @@ -44,23 +42,22 @@ describe(`createMidTurnCompactor`, () => { currentTokens: 9500, // 95% contextWindow: 10000, }) - // folded the first 6 (10 − keepTail 4) + // The entire context is folded (no verbatim tail kept), so the summary and + // the checkpoint's timeline-head watermark agree. expect(summarize).toHaveBeenCalledTimes(1) - expect((summarize.mock.calls[0]![0] as Array).length).toBe(6) + expect((summarize.mock.calls[0]![0] as Array).length).toBe(10) expect(statuses).toEqual([`running`, `complete`]) expect(out).not.toBeNull() - expect(out!.length).toBe(1 + 4) // summary + recent tail + expect(out!.length).toBe(1) // summary only expect(JSON.stringify(out![0])).toContain(`SUMMARY`) - expect(out![1]).toBe(messages[6]) // tail begins at coveredCount=6 }) - it(`is sticky: reuses the cached summary below the ceiling`, async () => { + it(`is sticky: reuses the cached summary and appends messages added since`, async () => { const summarize = vi.fn().mockResolvedValue(`SUMMARY`) const compact = createMidTurnCompactor({ summarize, writeCheckpoint: vi.fn(), ceiling: 0.9, - keepTail: 4, }) const messages = msgs(10) await compact({ messages, currentTokens: 9500, contextWindow: 10000 }) @@ -73,7 +70,8 @@ describe(`createMidTurnCompactor`, () => { }) expect(summarize).not.toHaveBeenCalled() // reused, not re-summarized expect(JSON.stringify(out![0])).toContain(`SUMMARY`) - expect(out!.length).toBe(1 + 5) // summary + (4 original tail + 1 new) + expect(out!.length).toBe(1 + 1) // summary + the one message added since + expect(out![1]).toBe(grown[10]) }) it(`re-summarizes by chaining off the previous summary`, async () => { @@ -85,7 +83,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: vi.fn(), ceiling: 0.9, - keepTail: 4, }) await compact({ messages: msgs(10), @@ -98,35 +95,14 @@ describe(`createMidTurnCompactor`, () => { contextWindow: 10000, }) expect(summarize).toHaveBeenCalledTimes(2) - // the re-summarization input starts with the prior summary (chained) + // The re-summarization input is [prior summary, ...the messages added since] + // (chained, not the whole already-summarized bulk re-read). const secondInput = summarize.mock.calls[1]![0] as Array + expect(secondInput.length).toBe(1 + 10) // SUMMARY1 + msgs 10..19 expect(JSON.stringify(secondInput[0])).toContain(`SUMMARY1`) expect(JSON.stringify(out![0])).toContain(`SUMMARY2`) }) - it(`never starts the kept tail with an orphaned tool_result`, async () => { - const summarize = vi.fn().mockResolvedValue(`SUMMARY`) - const compact = createMidTurnCompactor({ - summarize, - writeCheckpoint: vi.fn(), - ceiling: 0.9, - keepTail: 4, - }) - // Boundary would land at index 6 (10 − keepTail 4); make that a tool_result - // whose tool_use (index 5 assistant) is being folded. The fold boundary must - // advance past it so the tail starts on the assistant turn at index 7. - const messages = msgs(10) - messages[6] = { role: `toolResult`, content: `tr` } - const out = await compact({ - messages, - currentTokens: 9500, - contextWindow: 10000, - }) - expect((summarize.mock.calls[0]![0] as Array).length).toBe(7) - expect(out![1]).toBe(messages[7]) - expect((out![1] as { role: string }).role).toBe(`assistant`) - }) - it(`on failure writes "failed" and leaves context untouched`, async () => { const summarize = vi.fn().mockRejectedValue(new Error(`boom`)) const statuses: Array = [] @@ -134,7 +110,6 @@ describe(`createMidTurnCompactor`, () => { summarize, writeCheckpoint: (s: string) => statuses.push(s), ceiling: 0.9, - keepTail: 4, }) const out = await compact({ messages: msgs(10), diff --git a/packages/agents-runtime/test/timeline-compaction.test.ts b/packages/agents-runtime/test/timeline-compaction.test.ts index 661d1580b0..27f1983b9b 100644 --- a/packages/agents-runtime/test/timeline-compaction.test.ts +++ b/packages/agents-runtime/test/timeline-compaction.test.ts @@ -67,6 +67,40 @@ describe(`timelineMessages compaction reconstruction`, () => { expect(iPrompt).toBeLessThan(iAnswer) }) + it(`mid-turn checkpoint keeps messages produced after compaction (no tail loss)`, () => { + // Regression: the mid-turn (sync) checkpoint summarizes the WHOLE context and + // is stamped with watermark = timeline head at compaction (here 3). Messages + // written *after* it — the model's post-compaction output + the next prompt — + // must survive. Previously the checkpoint carried no watermark, so it fell + // back to its own (later) row order and silently dropped recent messages. + const db = buildStreamFixture([ + { kind: `inbox`, at: 1, value: { payload: `EARLY_ONE` } }, + { kind: `inbox`, at: 2, value: { payload: `EARLY_TWO` } }, + { kind: `inbox`, at: 3, value: { payload: `EARLY_THREE` } }, + { + kind: `context_inserted`, + at: 4, + value: { + id: `compaction`, + name: `compaction_summary`, + attrs: { kind: `compaction`, status: `complete`, watermark: 3 }, + content: `MIDTURN_SUMMARY`, + }, + }, + { kind: `inbox`, at: 5, value: { payload: `POST_COMPACTION_ANSWER` } }, + { kind: `inbox`, at: 6, value: { payload: `NEXT_PROMPT` } }, + ]) + + const out = serialize(db) + // Everything <= head is folded into the summary… + expect(out).not.toContain(`EARLY_ONE`) + expect(out).not.toContain(`EARLY_THREE`) + expect(out).toContain(`MIDTURN_SUMMARY`) + // …and everything written after compaction is kept verbatim. + expect(out).toContain(`POST_COMPACTION_ANSWER`) + expect(out).toContain(`NEXT_PROMPT`) + }) + it(`does NOT hide history for a running (incomplete) checkpoint`, () => { // Crash-safety: an in-flight/crashed compaction must never drop history. const db = buildStreamFixture([ From 647baf0f31bbdf068d33f83eb74c3e8ba0ce9b7f Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 11:19:45 +0200 Subject: [PATCH 024/105] =?UTF-8?q?fix(agents-server-ui):=20clear=20an=20o?= =?UTF-8?q?rphaned=20"Compacting=E2=80=A6"=20spinner=20[review=20#4605]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A summarize is bounded by a ~120s hard timeout after which a terminal (complete/failed) checkpoint is always written, so a `running` checkpoint that lingers well past that is orphaned — its process crashed before writing the terminal row, and (with watermark-unique background ids) nothing supersedes it, pinning the spinner forever. Treat a `running` checkpoint older than 150s as stale and stop showing it, with a self-clearing timer so the spinner disappears even when no further events arrive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/CompactionIndicator.tsx | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.tsx b/packages/agents-server-ui/src/components/CompactionIndicator.tsx index a13d881892..34f4b0d821 100644 --- a/packages/agents-server-ui/src/components/CompactionIndicator.tsx +++ b/packages/agents-server-ui/src/components/CompactionIndicator.tsx @@ -1,20 +1,30 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useLiveQuery } from '@tanstack/react-db' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' import styles from './CompactionIndicator.module.css' /** - * Live indicator shown while a synchronous context compaction is in flight. + * Live indicator shown while a context compaction is in flight. * * The runtime writes a compaction checkpoint row with `attrs.status: "running"` - * before summarizing and supersedes it with a `complete` row when done. We read - * the latest such row reactively; while it's `running` we show "Compacting - * context…" so the user understands the pause (and knows their next prompt is - * being queued). It clears the moment compaction completes. + * before summarizing and supersedes it with a `complete` (or `failed`) row when + * done. We read the latest such row reactively; while it's `running` we show + * "Compacting context…" so the user understands the pause. It clears the moment + * compaction completes. + * + * A summarize is bounded by a hard timeout (~120s) after which a terminal row is + * always written, so a `running` row that lingers well past that is orphaned — + * its process crashed before writing the terminal row. We stop showing the + * spinner for such a row (and self-clear via a timer so it disappears even with + * no further events). */ +/** Beyond this age a still-`running` checkpoint is treated as orphaned/crashed. */ +const STALE_RUNNING_MS = 150_000 + interface CheckpointRow { _seq?: number + timestamp?: string attrs?: { kind?: string; status?: string; background?: boolean } } @@ -33,28 +43,44 @@ export function CompactionIndicator({ [db] ) - const running = useMemo(() => { - // The newest compaction checkpoint wins (later writes supersede earlier - // ones for the same id). If it's still `running`, compaction is in flight. + const latest = useMemo(() => { + // The newest compaction checkpoint wins (later writes supersede earlier ones + // for the same id). Only a `running` one drives the spinner. let latest: CheckpointRow | null = null for (const row of rows as Array) { if (row.attrs?.kind !== `compaction`) continue if (!latest || (row._seq ?? 0) > (latest._seq ?? 0)) latest = row } - if (latest?.attrs?.status !== `running`) return null - return { background: Boolean(latest.attrs?.background) } + return latest?.attrs?.status === `running` ? latest : null }, [rows]) - if (!running) return null + const runningSince = latest?.timestamp + ? Date.parse(latest.timestamp) + : Number.NaN + + // Re-render once the running checkpoint crosses the staleness deadline, so an + // orphaned spinner clears itself even if no further events arrive. + const [, bump] = useState(0) + useEffect(() => { + if (!latest || !Number.isFinite(runningSince)) return + const remaining = STALE_RUNNING_MS - (Date.now() - runningSince) + if (remaining <= 0) return + const id = setTimeout(() => bump((n) => n + 1), remaining) + return () => clearTimeout(id) + }, [latest, runningSince]) + + if (!latest) return null + const orphaned = + Number.isFinite(runningSince) && + Date.now() - runningSince >= STALE_RUNNING_MS + if (orphaned) return null // Background compaction is non-blocking, so it's shown subtly and distinctly // from the blocking (sync, mid-turn) "Compacting context…". + const background = Boolean(latest.attrs?.background) return ( ) From 226c209fd1bf6ebd7955d09b82901cdd98b38d1c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 11:20:18 +0200 Subject: [PATCH 025/105] docs(agents): clarify the mid-turn token anchor approximation [review #4605] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pendingRequestMessageCount` records the incoming (uncompacted) message count, not the compacted list the adapter may return. That's intentional: pi-agent passes transformContext the full conversation each step, so the count indexes that original array — the next step's trailing slice then measures exactly the messages appended since. Add a comment so it doesn't read as a bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/pi-adapter.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 7cc760325c..863c584386 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -328,6 +328,12 @@ export function createPiAgentAdapter( .slice(anchorMessageCount) .reduce((sum, m) => sum + estimateContent(m), 0) const currentTokens = anchorTokens + trailingTokens + // Anchor the NEXT step's trailing estimate on this step's *incoming* + // (uncompacted) message count — NOT the compacted list we may return. + // pi-agent hands transformContext the full conversation every step, + // so `list.slice(anchorMessageCount)` next step measures exactly the + // messages appended since. `anchorTokens` is separately re-anchored + // to the step's real cache-inclusive usage at message_end. pendingRequestMessageCount = messages.length const compacted = await opts.onCompactContext!({ messages: list, From 6ca66e8afd20fa250468094612ebc9a7252e4931 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 11:37:03 +0200 Subject: [PATCH 026/105] fix(agents): snapshot the mid-turn watermark before summarizing [review #4605] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn checkpoint computed its watermark (timeline head) when the `complete` row was written — after the summarize await. Any event that materialized into the StreamDB during that (slow) await would bump the head past what the summary actually covered, so reconstruction could drop an un-summarized item next turn (item.at <= watermark). Narrow (mid-turn blocks the agent, and pending inbox rows don't materialize) but timing-dependent. Snapshot the head when the `running` row is written instead — before the await — so coverage and watermark derive from the same instant, matching how background compaction already captures its head up front. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/context-factory.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 10c4a87329..9f257afe9f 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -839,6 +839,14 @@ export function createHandlerContext( CONTEXT_USAGE_HARD_CEILING ) const compactProvider = agentModelProvider(activeAgentConfig) + // The watermark the mid-turn summary actually covers, snapshotted when + // the `running` checkpoint is written — i.e. BEFORE the (slow) summarize + // await. Capturing it at `complete`-write time instead would let any + // event that materialized into the StreamDB during the summarize bump + // the head past what the summary covered, so that item would be dropped + // un-summarized next turn. Mirrors background compaction, which also + // snapshots its head before starting the summarize. + let midTurnWatermark: number | undefined const onCompactContext = createMidTurnCompactor({ ceiling: compactCeiling, writeCheckpoint: (status, content) => { @@ -846,18 +854,21 @@ export function createHandlerContext( kind: COMPACTION_CHECKPOINT_KIND, status, } - // Stamp the completed checkpoint with the current timeline head as - // its watermark. The mid-turn summary covers the whole context, so - // reconstruction folds exactly that (items <= head) and keeps - // everything appended after — no verbatim tail is dropped. Without - // this, the watermark falls back to the checkpoint row's own (later) - // position and silently drops the most recent messages next turn. - if (status === `complete`) { + if (status === `running`) { const head = runtimeTimelineMessages(config.db).reduce( (max, message) => Math.max(max, message.at), Number.NEGATIVE_INFINITY ) - if (Number.isFinite(head)) attrs.watermark = head + midTurnWatermark = Number.isFinite(head) ? head : undefined + } + // Stamp the completed checkpoint with the snapshotted head as its + // watermark. The mid-turn summary covers the whole context up to that + // head, so reconstruction folds exactly that (items <= head) and + // keeps everything appended after. Without it, the watermark falls + // back to the checkpoint row's own (later) position and silently + // drops the most recent messages next turn. + if (status === `complete` && midTurnWatermark !== undefined) { + attrs.watermark = midTurnWatermark } contextApi.insertContext(COMPACTION_CHECKPOINT_ID, { name: COMPACTION_CHECKPOINT_NAME, From 2c179f3f80e5da8bdc8047dced005325ad3bed62 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 11:38:34 +0200 Subject: [PATCH 027/105] test(agents-server-ui): cover the orphaned-spinner staleness logic [review #4605] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orphan-clearing logic added to CompactionIndicator had no test, and a regression where rows stop carrying `timestamp` would silently revert to the lingering-spinner bug (NaN → not orphaned → spinner stays). This package has no React-render harness, so extract the decision into a pure `isRunningCheckpointOrphaned(timestamp, now)` helper (with STALE_RUNNING_MS) in lib/ and unit-test it: fresh → shown, just under the deadline → shown, at/past the deadline → hidden, and missing/unparseable timestamp → shown (documented). The component now imports the helper, so the tested logic is exactly what runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/CompactionIndicator.tsx | 14 +++---- .../src/lib/compactionIndicator.test.ts | 38 +++++++++++++++++++ .../src/lib/compactionIndicator.ts | 30 +++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 packages/agents-server-ui/src/lib/compactionIndicator.test.ts create mode 100644 packages/agents-server-ui/src/lib/compactionIndicator.ts diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.tsx b/packages/agents-server-ui/src/components/CompactionIndicator.tsx index 34f4b0d821..7e0ad43712 100644 --- a/packages/agents-server-ui/src/components/CompactionIndicator.tsx +++ b/packages/agents-server-ui/src/components/CompactionIndicator.tsx @@ -1,6 +1,10 @@ import { useEffect, useMemo, useState } from 'react' import { useLiveQuery } from '@tanstack/react-db' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' +import { + STALE_RUNNING_MS, + isRunningCheckpointOrphaned, +} from '../lib/compactionIndicator' import styles from './CompactionIndicator.module.css' /** @@ -16,12 +20,9 @@ import styles from './CompactionIndicator.module.css' * always written, so a `running` row that lingers well past that is orphaned — * its process crashed before writing the terminal row. We stop showing the * spinner for such a row (and self-clear via a timer so it disappears even with - * no further events). + * no further events). See `lib/compactionIndicator` for the staleness rule. */ -/** Beyond this age a still-`running` checkpoint is treated as orphaned/crashed. */ -const STALE_RUNNING_MS = 150_000 - interface CheckpointRow { _seq?: number timestamp?: string @@ -70,10 +71,7 @@ export function CompactionIndicator({ }, [latest, runningSince]) if (!latest) return null - const orphaned = - Number.isFinite(runningSince) && - Date.now() - runningSince >= STALE_RUNNING_MS - if (orphaned) return null + if (isRunningCheckpointOrphaned(latest.timestamp, Date.now())) return null // Background compaction is non-blocking, so it's shown subtly and distinctly // from the blocking (sync, mid-turn) "Compacting context…". diff --git a/packages/agents-server-ui/src/lib/compactionIndicator.test.ts b/packages/agents-server-ui/src/lib/compactionIndicator.test.ts new file mode 100644 index 0000000000..9b1a7f4361 --- /dev/null +++ b/packages/agents-server-ui/src/lib/compactionIndicator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + STALE_RUNNING_MS, + isRunningCheckpointOrphaned, +} from './compactionIndicator' + +describe(`isRunningCheckpointOrphaned`, () => { + const now = 1_000_000_000_000 + const iso = (ms: number) => new Date(ms).toISOString() + + it(`is NOT orphaned for a fresh running checkpoint`, () => { + expect(isRunningCheckpointOrphaned(iso(now), now)).toBe(false) + expect(isRunningCheckpointOrphaned(iso(now - 30_000), now)).toBe(false) + }) + + it(`is NOT orphaned just under the staleness deadline`, () => { + expect( + isRunningCheckpointOrphaned(iso(now - (STALE_RUNNING_MS - 1)), now) + ).toBe(false) + }) + + it(`IS orphaned at/after the staleness deadline (crashed mid-summarize)`, () => { + expect(isRunningCheckpointOrphaned(iso(now - STALE_RUNNING_MS), now)).toBe( + true + ) + expect( + isRunningCheckpointOrphaned(iso(now - STALE_RUNNING_MS * 10), now) + ).toBe(true) + }) + + it(`treats a missing or unparseable timestamp as NOT orphaned`, () => { + // We can't prove staleness, so we keep showing the spinner rather than hide + // a possibly-live compaction. (insertContext always stamps a timestamp.) + expect(isRunningCheckpointOrphaned(undefined, now)).toBe(false) + expect(isRunningCheckpointOrphaned(``, now)).toBe(false) + expect(isRunningCheckpointOrphaned(`not-a-date`, now)).toBe(false) + }) +}) diff --git a/packages/agents-server-ui/src/lib/compactionIndicator.ts b/packages/agents-server-ui/src/lib/compactionIndicator.ts new file mode 100644 index 0000000000..03fd7851f6 --- /dev/null +++ b/packages/agents-server-ui/src/lib/compactionIndicator.ts @@ -0,0 +1,30 @@ +/** + * Staleness logic for the "Compacting…" indicator, extracted so it can be unit + * tested without a DOM/render harness (this package has no React-render test + * setup). + */ + +/** + * Beyond this age a still-`running` compaction checkpoint is treated as orphaned + * (its process crashed before writing a terminal `complete`/`failed` row). A + * summarize is bounded by a ~120s hard timeout after which a terminal row is + * always written, so 150s comfortably clears only genuinely-crashed runs. + */ +export const STALE_RUNNING_MS = 150_000 + +/** + * Whether a `running` checkpoint with the given `timestamp` (ISO string) should + * be treated as orphaned at `now` (ms). A missing or unparseable timestamp is + * treated as NOT orphaned — we can't prove staleness, and hiding a genuinely + * in-flight compaction is worse than briefly over-showing one. (insertContext + * always stamps a timestamp, so this only guards against a schema regression.) + */ +export function isRunningCheckpointOrphaned( + timestamp: string | undefined, + now: number +): boolean { + if (!timestamp) return false + const since = Date.parse(timestamp) + if (!Number.isFinite(since)) return false + return now - since >= STALE_RUNNING_MS +} From 722ec634ddf58ef4795116400e04399caab3e2c9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:24:49 +0200 Subject: [PATCH 028/105] docs(agents): drop RFC/phase references and trim compaction comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC isn't checked in, so comments can't reference it; phase numbers are internal sequencing, not something the code should narrate. Remove the dangling RFC/§/phase references and tighten the surrounding comments to be brief and only where they clarify something non-obvious. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/compaction-summarize.ts | 14 +++--- packages/agents-runtime/src/compaction.ts | 18 +++---- .../agents-runtime/src/context-factory.ts | 50 ++++++------------- packages/agents-runtime/src/entity-schema.ts | 21 ++------ .../agents-runtime/src/token-accountant.ts | 30 ++++------- .../src/components/ContextUsageIndicator.tsx | 9 ++-- 6 files changed, 46 insertions(+), 96 deletions(-) diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts index baa3ec64e8..cfa3d9bfcc 100644 --- a/packages/agents-runtime/src/compaction-summarize.ts +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -25,11 +25,10 @@ const DEFAULT_SUMMARY_TIMEOUT_MS = 120_000 /** * Summarize a conversation into a compaction handoff summary. * - * Uses the **conversation's own model** by default (RFC Q2): a cheaper, - * small-window model would overflow on a near-full context — the whole reason - * we are compacting. The full history is sent followed by Codex's summarization - * prompt; the returned summary is prefixed with Codex's summary preamble so the - * resuming model knows it is reading a handoff. + * Uses the conversation's own model by default: a cheaper, small-window model + * would overflow on a near-full context — the whole reason we are compacting. + * The full history is sent followed by Codex's summarization prompt; the summary + * is prefixed with Codex's preamble so the resuming model knows it's a handoff. */ interface SummarizeCoreInput { model: string | object @@ -112,9 +111,8 @@ async function summarizeConverted( /** * Summarize a conversation (LLMMessage form) into a compaction handoff summary. * - * Uses the **conversation's own model** by default (RFC Q2): a cheaper, - * small-window model would overflow on a near-full context — the whole reason - * we are compacting. + * Uses the conversation's own model by default: a cheaper, small-window model + * would overflow on a near-full context — the whole reason we are compacting. */ export async function summarizeMessages( input: SummarizeCoreInput & { messages: ReadonlyArray } diff --git a/packages/agents-runtime/src/compaction.ts b/packages/agents-runtime/src/compaction.ts index 842bc5cb88..c9c1ad5356 100644 --- a/packages/agents-runtime/src/compaction.ts +++ b/packages/agents-runtime/src/compaction.ts @@ -1,15 +1,11 @@ /** - * Context compaction (RFC §8/§9). + * Context compaction. * * When the conversation approaches the context window, it is summarized into a * durable "checkpoint" — a `context_inserted` row tagged `kind: "compaction"` — - * and the messages it summarizes are dropped from the reconstructed history. - * The checkpoint is written at the head of the timeline, so its order acts as a - * watermark: timeline reconstruction hides everything before it and emits the - * summary in their place (see `timelineMessages`). - * - * Phase 2 wires this up synchronously at the 90% hard ceiling; Phase 3 adds the - * background trigger at 85%. + * and the messages it summarizes are dropped from the reconstructed history. The + * checkpoint carries a watermark: reconstruction hides everything up to it and + * emits the summary in their place (see `timelineMessages`). */ /** `attrs.kind` marking a `context_inserted` row as a compaction checkpoint. */ @@ -53,8 +49,8 @@ export function isCompleteCompactionCheckpointAttrs( } /** - * Summarization prompt, reused verbatim from OpenAI Codex (RFC Appendix A). - * Appended as a user message after the conversation being compacted. + * Summarization prompt, reused verbatim from OpenAI Codex. Appended as a user + * message after the conversation being compacted. */ export const COMPACTION_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. @@ -68,6 +64,6 @@ Be concise, structured, and focused on helping the next LLM seamlessly continue /** * Prefix prepended to the produced summary when it is reinserted as the - * checkpoint, reused verbatim from Codex (RFC Appendix A). + * checkpoint, reused verbatim from Codex. */ export const COMPACTION_SUMMARY_PREFIX = `Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:` diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index 9f257afe9f..38ccb2e5d8 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -813,39 +813,24 @@ export function createHandlerContext( activeAgentConfig.tools )) as Array - // Phase 1: surface the remaining context budget to the model once usage - // reaches the first awareness threshold (25%). Synthesized fresh each - // call from the latest persisted step — so it is always current, and we - // deliberately do NOT persist it as a context row (a self-superseding - // entry would leave `load_context_history(...)` tombstones, which are - // meaningless breadcrumbs for an ephemeral budget hint). The notice is - // injected just before the final message so the closing turn — and the - // runInput detection below, which reads `.at(-1)` — is unchanged. + // Drives the remaining-budget notice injected below. Synthesized fresh + // from the latest step, never persisted — a self-superseding context row + // would leave misleading load_context_history tombstones. const budgetUsage = selectLatestContextUsage( config.db.collections.steps.toArray as ReadonlyArray ) - // Phase 2 hard ceiling — enforced MID-TURN. Compaction runs before each - // model step via pi-agent's `transformContext` (wired in the adapter to - // the compactor below), so a single turn that balloons across many tool - // calls can't exhaust the window before the turn ends. The compactor - // anchors its token estimate on the last step's real usage (+ trailing), - // folds older messages into a summary, and persists the running/complete/ - // failed checkpoint that drives the UI. Thresholds overridable via env - // (RFC §12 tunables): - // ELECTRIC_AGENTS_COMPACT_CEILING (0..1, default 0.9) + // Mid-turn compaction: runs before every model step (the adapter's + // transformContext) so one tool-heavy turn can't exhaust the window + // before it ends. Overridable via ELECTRIC_AGENTS_COMPACT_CEILING. const compactCeiling = ratioFromEnv( process.env.ELECTRIC_AGENTS_COMPACT_CEILING, CONTEXT_USAGE_HARD_CEILING ) const compactProvider = agentModelProvider(activeAgentConfig) - // The watermark the mid-turn summary actually covers, snapshotted when - // the `running` checkpoint is written — i.e. BEFORE the (slow) summarize - // await. Capturing it at `complete`-write time instead would let any - // event that materialized into the StreamDB during the summarize bump - // the head past what the summary covered, so that item would be dropped - // un-summarized next turn. Mirrors background compaction, which also - // snapshots its head before starting the summarize. + // Watermark for the mid-turn summary, snapshotted when the `running` row + // is written (before the slow summarize await) so an event materializing + // during the summarize can't bump the head past what the summary covered. let midTurnWatermark: number | undefined const onCompactContext = createMidTurnCompactor({ ceiling: compactCeiling, @@ -861,11 +846,9 @@ export function createHandlerContext( ) midTurnWatermark = Number.isFinite(head) ? head : undefined } - // Stamp the completed checkpoint with the snapshotted head as its - // watermark. The mid-turn summary covers the whole context up to that - // head, so reconstruction folds exactly that (items <= head) and - // keeps everything appended after. Without it, the watermark falls - // back to the checkpoint row's own (later) position and silently + // Stamp `complete` with that head: reconstruction folds items <= + // head (what the summary covered) and keeps everything after. Without + // it the watermark falls back to the row's own (later) position and // drops the most recent messages next turn. if (status === `complete` && midTurnWatermark !== undefined) { attrs.watermark = midTurnWatermark @@ -1289,12 +1272,9 @@ export function createHandlerContext( }) } - // Each background generation gets a watermark-unique checkpoint id, so the - // NEXT background's `running` row never supersedes the PREVIOUS `complete` - // one. They otherwise share the fixed id (supersession keys on id alone), and - // a fresh `running` silently erased the last `complete` watermark — undoing - // every compaction and pinning the indicator to "running". running→complete→ - // failed of ONE generation share this id so a generation supersedes itself. + // Watermark-unique id per background generation, so a new `running` row can't + // supersede the previous `complete` one (supersession keys on id alone). + // running→complete→failed of one generation share the id. const backgroundCheckpointId = (watermark: number): string => `${COMPACTION_CHECKPOINT_ID}-bg-${watermark}` diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 48dacb32e6..2e55fb6307 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -156,25 +156,14 @@ type StepValue = { model_provider?: string model_id?: string duration_ms?: number - // Token usage for this step as reported by the provider's - // end-of-message `usage` payload. Populated on `onStepEnd` when the - // adapter has the data — older events without these fields stay - // valid (both optional), so this is a strictly additive change. - // `input_tokens` is the *uncached* input side (fresh tokens plus - // cache writes; cache reads excluded) — the cache-inclusive total - // would re-count the whole conversation on every step. + // Uncached input side (fresh tokens + cache writes; cache reads excluded, so + // it doesn't re-count the conversation every warm turn). input_tokens?: number output_tokens?: number - // Cache-inclusive prompt size for this step (`input + cacheRead + - // cacheWrite`) — i.e. how many tokens of the model's context window the - // request actually occupied. Unlike `input_tokens` (which excludes cache - // reads so budget accounting doesn't re-count the conversation every warm - // turn) this is the right number for a context-fullness gauge. Optional and - // additive, so older events without it stay valid. + // Cache-inclusive prompt size (input + cacheRead + cacheWrite) — how much of + // the window the request occupied; the number a fullness gauge needs. context_input_tokens?: number - // The model's context window at the time of this step. Persisted alongside - // the usage so consumers can render a fraction without resolving the model - // registry themselves. + // The model's context window for this step. context_window?: number } type TextValue = { diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index 6be216f9b9..bd0b268970 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -1,31 +1,21 @@ /** - * Token accounting for context-window usage. - * - * This is the single source of truth for turning a step's reported token usage - * into a "% of the context window used" figure. Both the runtime (telemetry + - * the model-facing budget notice) and the UI (the composer usage gauge) compute - * usage through here, so the number the user sees is exactly the number later - * compaction phases will act on. If this drifts, everything downstream drifts - * with it. - * - * Phase 0 reads it for display; Phase 1 surfaces it to the model as a budget - * notice; later phases drive background compaction and the hard ceiling off the - * same thresholds below. + * Token accounting for context-window usage — the single source of truth for + * turning a step's reported tokens into a "% of the context window used" figure. + * The runtime triggers and the UI gauge both compute through here, so the number + * the user sees is the number compaction acts on. */ import { approxTokens, formatTokenCount } from './token-budget' import type { LLMMessage } from './types' /** - * Fractions of the context window at which the compaction system changes - * behaviour. Kept here (not scattered across call sites) so the UI gauge and - * the runtime triggers share one set of numbers. + * Fractions of the context window at which compaction changes behaviour, kept + * here so the UI gauge and the runtime triggers share one set of numbers. * - * - `AWARENESS` (25/50/75%): inject a budget notice so the model can pace - * itself (Phase 1). - * - `BACKGROUND_START` (85%): kick off background compaction (Phase 3). - * - `HARD_CEILING` (90%): compact synchronously before the next model call - * (Phase 2). Matches Codex's auto-compaction threshold. + * - `AWARENESS` (25/50/75%): inject a budget notice so the model can pace itself. + * - `BACKGROUND_START` (85%): kick off background compaction. + * - `HARD_CEILING` (90%): compact synchronously before the next model call. + * Matches Codex's auto-compaction threshold. */ export const CONTEXT_USAGE_AWARENESS_THRESHOLDS = [0.25, 0.5, 0.75] as const export const CONTEXT_USAGE_BACKGROUND_START = 0.85 diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx index 666f786b5c..4ebc6e2e01 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx @@ -11,12 +11,9 @@ import { Tooltip } from '../ui/Tooltip' import styles from './ContextUsageIndicator.module.css' /** - * Phase-0 context-window gauge for the composer footer (à la Claude Code's - * "X% used"). It reads the *same* numbers the runtime persists for compaction - * — the latest step's cache-inclusive prompt size and the model's context - * window — and divides them through `computeContextUsage`, the shared source of - * truth. Nothing acts on the ratio yet; this is purely observational and lets - * us eyeball-validate the token accounting before later phases trigger off it. + * Context-window gauge for the composer footer ("X% used"). Reads the same + * numbers the runtime persists for compaction (the latest step's cache-inclusive + * prompt size and the model's window) through `computeContextUsage`. */ interface StepRow { From 3d6fd6183f22bcbe7f44a98ab3db0789ffadf8ce Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:29:16 +0200 Subject: [PATCH 029/105] refactor(agents): clearer idle background-apply loop + trim comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `for (;;)` + breaks with a `do…while (appliedBackgroundDuringIdle)` so the loop's actual condition (re-idle while background compactions keep settling) is explicit, and trim the over-long `pendingBackgroundCompaction` comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/process-wake.ts | 54 ++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 54fae51235..abeb3bf2f1 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -591,19 +591,16 @@ export async function processWake( let idleTimer: ReturnType | null = null let idleController: AbortController | null = null let runAbortController: AbortController | null = null - // Turn-end background compaction: a detached summarization started after a - // turn whose result (or failure) is applied as a checkpoint either at the - // *next* turn's start, or — if the summarize finishes while the entity is - // idle — immediately by waking the idle loop (so the "Compacting…" indicator - // never lingers past completion). The checkpoint write always stays inside a - // handler/idle drain. Persists across loop iterations. + // Turn-end background compaction: a detached summarize, applied as a + // checkpoint at the next turn's start — or immediately if it settles while + // idle. Persists across loop iterations. let pendingBackgroundCompaction: { watermark: number status: `pending` | `ready` | `failed` summary: string } | null = null - // Set by the detached summarize when it settles while idle; signals the idle - // loop to apply the checkpoint without running the agent handler. + // Set when the detached summarize settles while idle; tells the idle loop to + // apply the checkpoint without running the handler. let backgroundApplyRequestedDuringIdle = false let activeSignalHandler: | (( @@ -2462,9 +2459,15 @@ export async function processWake( break } + // Idle until a fresh wake resumes us or we genuinely go idle — re-entering + // each time a detached background compaction settles while idle, so its + // checkpoint is written (without an agent run) and the indicator doesn't + // linger. let resumedFromIdle = false let idleReason = `handler returned` - for (;;) { + let appliedBackgroundDuringIdle = false + do { + appliedBackgroundDuringIdle = false const resumed = await awaitIdleForFreshWork( `${idleReason}, entering idle (${idleTimeout / 1000}s timeout)` ) @@ -2472,29 +2475,26 @@ export async function processWake( resumedFromIdle = true break } - // Woke from idle because a detached background compaction settled with - // no turn to apply it at. Write its checkpoint now (NO agent run) so the - // indicator clears and the next turn starts compacted, then re-idle. const settled = backgroundApplyRequestedDuringIdle ? pendingBackgroundCompaction : null backgroundApplyRequestedDuringIdle = false - if (!settled || settled.status === `pending`) { - break - } - if (settled.status === `ready`) { - writeBackgroundCheckpoint(settled.watermark, settled.summary) - log.info(`background compaction applied during idle`) - } else { - failBackgroundCheckpoint(settled.watermark) - log.info(`background compaction failed; cleared during idle`) + if (settled && settled.status !== `pending`) { + if (settled.status === `ready`) { + writeBackgroundCheckpoint(settled.watermark, settled.summary) + log.info(`background compaction applied during idle`) + } else { + failBackgroundCheckpoint(settled.watermark) + log.info(`background compaction failed; cleared during idle`) + } + pendingBackgroundCompaction = null + await drainAllPendingWrites() + await wakeSession.commitManifestEntries() + await flushProducedWrites() + idleReason = `background compaction applied` + appliedBackgroundDuringIdle = true } - pendingBackgroundCompaction = null - await drainAllPendingWrites() - await wakeSession.commitManifestEntries() - await flushProducedWrites() - idleReason = `background compaction applied` - } + } while (appliedBackgroundDuringIdle) if (resumedFromIdle) { continue } From 295d9ab6ce7e30da7e8e2863d8d5824dba2f297c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:35:37 +0200 Subject: [PATCH 030/105] docs(agents): trim more excessive compaction comments Tighten the over-long comments in the compaction indicator helper + component and the reconstruction watermark block, dropping a redundant inline restatement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/timeline-context.ts | 4 +--- .../src/components/CompactionIndicator.tsx | 18 ++++----------- .../src/lib/compactionIndicator.ts | 23 ++++++------------- 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/agents-runtime/src/timeline-context.ts b/packages/agents-runtime/src/timeline-context.ts index b8fdf84415..8c5498f720 100644 --- a/packages/agents-runtime/src/timeline-context.ts +++ b/packages/agents-runtime/src/timeline-context.ts @@ -497,10 +497,8 @@ export function timelineMessages( if (item.at < since) { continue } - // The watermark is the order of the last summarized item (the snapshot - // head), so everything at or below it is folded into the summary. if (item.at <= compactionWatermark) { - continue + continue // folded into the summary } // Place the summary at the watermark boundary, before the first kept item. emitSummary() diff --git a/packages/agents-server-ui/src/components/CompactionIndicator.tsx b/packages/agents-server-ui/src/components/CompactionIndicator.tsx index 7e0ad43712..b134ffb26e 100644 --- a/packages/agents-server-ui/src/components/CompactionIndicator.tsx +++ b/packages/agents-server-ui/src/components/CompactionIndicator.tsx @@ -8,19 +8,11 @@ import { import styles from './CompactionIndicator.module.css' /** - * Live indicator shown while a context compaction is in flight. - * - * The runtime writes a compaction checkpoint row with `attrs.status: "running"` - * before summarizing and supersedes it with a `complete` (or `failed`) row when - * done. We read the latest such row reactively; while it's `running` we show - * "Compacting context…" so the user understands the pause. It clears the moment - * compaction completes. - * - * A summarize is bounded by a hard timeout (~120s) after which a terminal row is - * always written, so a `running` row that lingers well past that is orphaned — - * its process crashed before writing the terminal row. We stop showing the - * spinner for such a row (and self-clear via a timer so it disappears even with - * no further events). See `lib/compactionIndicator` for the staleness rule. + * Spinner shown while a context compaction is in flight. The runtime writes a + * `running` compaction checkpoint before summarizing and supersedes it with + * `complete`/`failed` when done; we show the spinner while the latest is + * `running`. Orphaned (crashed) running rows are cleared after a timeout — see + * `lib/compactionIndicator`. */ interface CheckpointRow { diff --git a/packages/agents-server-ui/src/lib/compactionIndicator.ts b/packages/agents-server-ui/src/lib/compactionIndicator.ts index 03fd7851f6..9c78ee3c45 100644 --- a/packages/agents-server-ui/src/lib/compactionIndicator.ts +++ b/packages/agents-server-ui/src/lib/compactionIndicator.ts @@ -1,23 +1,14 @@ -/** - * Staleness logic for the "Compacting…" indicator, extracted so it can be unit - * tested without a DOM/render harness (this package has no React-render test - * setup). - */ +// Staleness logic for the "Compacting…" indicator, split out so it's unit +// testable without a render harness. -/** - * Beyond this age a still-`running` compaction checkpoint is treated as orphaned - * (its process crashed before writing a terminal `complete`/`failed` row). A - * summarize is bounded by a ~120s hard timeout after which a terminal row is - * always written, so 150s comfortably clears only genuinely-crashed runs. - */ +/** A `running` checkpoint older than this is orphaned (its process crashed + * before a terminal row); comfortably above the summarize timeout. */ export const STALE_RUNNING_MS = 150_000 /** - * Whether a `running` checkpoint with the given `timestamp` (ISO string) should - * be treated as orphaned at `now` (ms). A missing or unparseable timestamp is - * treated as NOT orphaned — we can't prove staleness, and hiding a genuinely - * in-flight compaction is worse than briefly over-showing one. (insertContext - * always stamps a timestamp, so this only guards against a schema regression.) + * Whether a `running` checkpoint with `timestamp` (ISO) is orphaned at `now` + * (ms). A missing/unparseable timestamp counts as live — we can't prove + * staleness, and hiding an in-flight compaction is worse than over-showing one. */ export function isRunningCheckpointOrphaned( timestamp: string | undefined, From 28ce457983488fd6a6b1641dd3903c5825323bd1 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 18 Jun 2026 12:19:21 +0200 Subject: [PATCH 031/105] test(agents): add missing doUnobserve to a context config fixture The runAgent budget-notice fixture predated main's required doUnobserve field on HandlerContextConfig; the rebase auto-merged without flagging it, breaking the agents-runtime typecheck. --- packages/agents-runtime/test/context-factory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agents-runtime/test/context-factory.test.ts b/packages/agents-runtime/test/context-factory.test.ts index 6fba774c82..e8e4f7a4b8 100644 --- a/packages/agents-runtime/test/context-factory.test.ts +++ b/packages/agents-runtime/test/context-factory.test.ts @@ -598,6 +598,7 @@ describe(`createHandlerContext`, () => { tags: {}, doSetTag: vi.fn().mockResolvedValue(undefined), doDeleteTag: vi.fn().mockResolvedValue(undefined), + doUnobserve: vi.fn().mockResolvedValue(undefined), }) let captured: unknown = null From 99936cfab7309a665904eefe451009d16af4bdb0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 16:27:45 +0200 Subject: [PATCH 032/105] feat(agents-server-ui): circular ring gauge for the context usage indicator Replace the flat dot before the "X%" label with a small SVG donut whose arc fills proportionally to the context-window usage. Track + progress arc both use currentColor, so the existing normal/warning/critical level colour tints the ring without extra wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ContextUsageIndicator.module.css | 19 ++++-- .../src/components/ContextUsageIndicator.tsx | 3 +- .../src/components/ContextUsageRing.tsx | 58 +++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/agents-server-ui/src/components/ContextUsageRing.tsx diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css index ec502c41d2..6a5fff37f2 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.module.css @@ -15,13 +15,20 @@ color: inherit; } -.dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - opacity: 0.6; +.ring { + display: block; flex-shrink: 0; + overflow: visible; +} + +.ringTrack { + stroke: currentColor; + opacity: 0.22; +} + +.ringProgress { + stroke: currentColor; + transition: stroke-dasharray 0.3s ease; } /* Levels mirror the compaction thresholds: warning once background diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx index 4ebc6e2e01..ed144711e0 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx @@ -8,6 +8,7 @@ import { } from '@electric-ax/agents-runtime/client' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' import { Tooltip } from '../ui/Tooltip' +import { ContextUsageRing } from './ContextUsageRing' import styles from './ContextUsageIndicator.module.css' /** @@ -83,7 +84,7 @@ export function ContextUsageIndicator({ className={[styles.indicator, styles[level]].filter(Boolean).join(` `)} aria-label={`Context used: ${percent} (${tooltip})`} > -
diff --git a/packages/agents-server-ui/src/components/ContextUsageRing.tsx b/packages/agents-server-ui/src/components/ContextUsageRing.tsx new file mode 100644 index 0000000000..722981dbe4 --- /dev/null +++ b/packages/agents-server-ui/src/components/ContextUsageRing.tsx @@ -0,0 +1,58 @@ +import styles from './ContextUsageIndicator.module.css' + +interface ContextUsageRingProps { + /** Fraction of the context window used, 0–1. */ + ratio: number + /** Outer diameter in px. */ + size?: number + /** Ring thickness in px. */ + strokeWidth?: number +} + +/** + * A tiny circular gauge ("donut") whose arc fills proportionally to `ratio`. + * Both the track and the progress arc use `currentColor`, so the surrounding + * indicator's level colour (normal/warning/critical) tints it for free. + */ +export function ContextUsageRing({ + ratio, + size = 14, + strokeWidth = 2.5, +}: ContextUsageRingProps): React.ReactElement { + const clamped = Math.min(1, Math.max(0, ratio)) + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const filled = circumference * clamped + const center = size / 2 + + return ( + + ) +} From b716cc12806892c7c43fb327b2e52d2c71a4e107 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 16:34:52 +0200 Subject: [PATCH 033/105] feat(agents): context-usage breakdown popover (system / tools / messages / free) Hovering the usage indicator now reveals a per-part composition of the prompt, modelled on Claude Code's `/context`: a stacked bar + legend showing how much of the window the system prompt, tool definitions, conversation messages, and free space each occupy. The runtime persists an approximate decomposition of the stable request parts: pi-adapter estimates `{ system, tools }` token cost (char/4 via approxTokens) once per call and writes it to the step as `context_breakdown` alongside the cache-inclusive `context_input_tokens`. The UI derives the "messages" bucket as the real total minus those estimates, so the segments always sum to the gauge even though the part figures are approximate. New shared helpers `computeContextBreakdown` / `parseContextBreakdown` in token-accountant keep the math testable and out of the component. - entity-schema: additive `context_breakdown` string column on steps. - outbound-bridge / pi-adapter: compute + persist the estimate. - token-accountant: breakdown helpers + types, exported from the client entry. - UI: HoverCard popover (ContextUsageDetails) with a composition bar + legend. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/client.ts | 5 + packages/agents-runtime/src/entity-schema.ts | 7 ++ .../agents-runtime/src/outbound-bridge.ts | 8 ++ packages/agents-runtime/src/pi-adapter.ts | 10 ++ .../agents-runtime/src/token-accountant.ts | 74 ++++++++++++ .../test/token-accountant.test.ts | 48 ++++++++ .../components/ContextUsageDetails.module.css | 113 ++++++++++++++++++ .../src/components/ContextUsageDetails.tsx | 85 +++++++++++++ .../src/components/ContextUsageIndicator.tsx | 60 ++++++---- 9 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 packages/agents-server-ui/src/components/ContextUsageDetails.module.css create mode 100644 packages/agents-server-ui/src/components/ContextUsageDetails.tsx diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 40bcec94e9..30a2538c3f 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -50,6 +50,8 @@ export { CONTEXT_USAGE_BACKGROUND_START, CONTEXT_USAGE_HARD_CEILING, computeContextUsage, + computeContextBreakdown, + parseContextBreakdown, contextUsageLevel, formatContextUsagePercent, } from './token-accountant' @@ -57,6 +59,9 @@ export type { ContextUsage, ContextUsageInput, ContextUsageLevel, + ContextBreakdownKey, + ContextBreakdownParts, + ContextBreakdownSegment, } from './token-accountant' export type { GoalCommand } from './goal-command' diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 2e55fb6307..606c480d6b 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -165,6 +165,12 @@ type StepValue = { context_input_tokens?: number // The model's context window for this step. context_window?: number + // JSON-encoded estimate of how the prompt decomposes across the stable + // request parts — `{ system, tools }` token counts (approximate; char/4). + // The UI derives the remaining "messages" bucket as the real cache-inclusive + // total minus these, so the breakdown always sums to the gauge. Optional and + // additive; older events without it stay valid. + context_breakdown?: string } type TextValue = { key?: string @@ -522,6 +528,7 @@ function createStepSchema(): Schema { duration_ms: z.number().int().optional(), input_tokens: z.number().int().nonnegative().optional(), output_tokens: z.number().int().nonnegative().optional(), + context_breakdown: z.string().optional(), }) } diff --git a/packages/agents-runtime/src/outbound-bridge.ts b/packages/agents-runtime/src/outbound-bridge.ts index 9d589f34f9..9e78a5d780 100644 --- a/packages/agents-runtime/src/outbound-bridge.ts +++ b/packages/agents-runtime/src/outbound-bridge.ts @@ -163,6 +163,9 @@ export interface OutboundBridge { // Model context window for this step, persisted as `context_window`. contextWindow?: number durationMs?: number + // Approx token decomposition of the stable request parts (system + tools), + // persisted as `context_breakdown` for the usage-details popover. + tokenBreakdown?: { system: number; tools: number } }) => void onTextStart: () => void onTextDelta: (delta: string) => void @@ -332,6 +335,8 @@ export function createOutboundBridge( tokenContext?: number contextWindow?: number durationMs?: number + /** Approximate token decomposition of the stable request parts. */ + tokenBreakdown?: { system: number; tools: number } }) { if (!currentStepKey) return writeEvent( @@ -357,6 +362,9 @@ export function createOutboundBridge( ...(opts?.contextWindow !== undefined && { context_window: opts.contextWindow, }), + ...(opts?.tokenBreakdown !== undefined && { + context_breakdown: JSON.stringify(opts.tokenBreakdown), + }), } as never, }) as ChangeEvent ) diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 863c584386..0bdc20628d 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -318,6 +318,15 @@ export function createPiAgentAdapter( const modelContextWindow = typeof model.contextWindow === `number` ? model.contextWindow : 0 + // Approximate token cost of the stable, non-message request parts. These + // are constant for the whole call, so estimate once and persist on each + // step; the UI derives the "messages" bucket as the real cache-inclusive + // total minus these (see token-accountant computeContextBreakdown). + const tokenBreakdown = { + system: approxTokens(opts.systemPrompt), + tools: approxTokens(opts.tools), + } + const transformContext = opts.onCompactContext && modelContextWindow > 0 ? async ( @@ -627,6 +636,7 @@ export function createPiAgentAdapter( tokenContext: usageContext, }), ...(contextWindow !== undefined && { contextWindow }), + tokenBreakdown, }) if (isError) { diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index bd0b268970..0dce6ede4a 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -65,6 +65,80 @@ export function computeContextUsage( return { usedTokens, contextWindow: input.contextWindow, ratio } } +export type ContextBreakdownKey = `system` | `tools` | `messages` | `free` + +export interface ContextBreakdownSegment { + key: ContextBreakdownKey + label: string + tokens: number + /** Fraction of the whole context window, [0, 1]. */ + ratio: number +} + +/** Estimated token cost of the stable request parts (from `context_breakdown`). */ +export interface ContextBreakdownParts { + systemTokens?: number + toolsTokens?: number +} + +const CONTEXT_BREAKDOWN_LABELS: Record = { + system: `System prompt`, + tools: `Tools`, + messages: `Messages`, + free: `Free space`, +} + +/** + * Decompose a step's context usage into display segments: the *estimated* + * stable parts (system prompt + tools, char/4 approximations persisted on the + * step) plus the real "messages" remainder and the free space. The used + * segments (everything but `free`) sum to `usage.usedTokens` and all four sum + * to the window — so the breakdown always agrees with the gauge even though the + * part estimates are approximate. Modelled on Claude Code's `/context` view. + */ +export function computeContextBreakdown( + usage: ContextUsage, + parts: ContextBreakdownParts = {} +): Array { + const window = usage.contextWindow + const used = Math.max(0, Math.min(usage.usedTokens, window)) + const system = Math.max(0, Math.min(parts.systemTokens ?? 0, used)) + const tools = Math.max(0, Math.min(parts.toolsTokens ?? 0, used - system)) + const messages = Math.max(0, used - system - tools) + const free = Math.max(0, window - used) + const segment = ( + key: ContextBreakdownKey, + tokens: number + ): ContextBreakdownSegment => ({ + key, + label: CONTEXT_BREAKDOWN_LABELS[key], + tokens, + ratio: window > 0 ? tokens / window : 0, + }) + return [ + segment(`system`, system), + segment(`tools`, tools), + segment(`messages`, messages), + segment(`free`, free), + ] +} + +/** Parse the persisted `context_breakdown` JSON into estimate parts (tolerant). */ +export function parseContextBreakdown( + raw: string | undefined | null +): ContextBreakdownParts { + if (!raw) return {} + try { + const obj = JSON.parse(raw) as { system?: unknown; tools?: unknown } + return { + ...(typeof obj.system === `number` && { systemTokens: obj.system }), + ...(typeof obj.tools === `number` && { toolsTokens: obj.tools }), + } + } catch { + return {} + } +} + export type ContextUsageLevel = `normal` | `warning` | `critical` /** diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts index 2ddfa97ca7..f0a9bb11a8 100644 --- a/packages/agents-runtime/test/token-accountant.test.ts +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -3,6 +3,8 @@ import { CONTEXT_USAGE_BACKGROUND_START, CONTEXT_USAGE_HARD_CEILING, computeContextUsage, + computeContextBreakdown, + parseContextBreakdown, contextUsageLevel, formatContextUsagePercent, formatContextBudgetNotice, @@ -13,6 +15,52 @@ import { } from '../src/token-accountant' import type { LLMMessage } from '../src/types' +describe(`computeContextBreakdown`, () => { + const usage = { usedTokens: 50_000, contextWindow: 100_000, ratio: 0.5 } + + it(`splits into system / tools / messages / free that sum to the window`, () => { + const segs = computeContextBreakdown(usage, { + systemTokens: 5_000, + toolsTokens: 15_000, + }) + expect(segs.map((s) => [s.key, s.tokens])).toEqual([ + [`system`, 5_000], + [`tools`, 15_000], + [`messages`, 30_000], // used (50k) − system − tools + [`free`, 50_000], // window − used + ]) + // Used segments sum to usedTokens; all four sum to the window. + expect(segs.slice(0, 3).reduce((n, s) => n + s.tokens, 0)).toBe(50_000) + expect(segs.reduce((n, s) => n + s.tokens, 0)).toBe(100_000) + expect(segs[2].ratio).toBeCloseTo(0.3) + }) + + it(`defaults missing parts to zero (messages absorbs the whole used total)`, () => { + const segs = computeContextBreakdown(usage) + expect(segs.find((s) => s.key === `messages`)?.tokens).toBe(50_000) + expect(segs.find((s) => s.key === `system`)?.tokens).toBe(0) + }) + + it(`never produces negative messages when estimates exceed the real total`, () => { + const segs = computeContextBreakdown( + { usedTokens: 1_000, contextWindow: 100_000, ratio: 0.01 }, + { systemTokens: 5_000, toolsTokens: 15_000 } + ) + // System is capped at used; tools/messages can't go below zero. + expect(segs.find((s) => s.key === `messages`)?.tokens).toBe(0) + expect(segs.every((s) => s.tokens >= 0)).toBe(true) + }) + + it(`parseContextBreakdown reads persisted JSON and tolerates junk`, () => { + expect(parseContextBreakdown(`{"system":12,"tools":34}`)).toEqual({ + systemTokens: 12, + toolsTokens: 34, + }) + expect(parseContextBreakdown(undefined)).toEqual({}) + expect(parseContextBreakdown(`not json`)).toEqual({}) + }) +}) + describe(`computeContextUsage`, () => { it(`sums input + output against the window`, () => { const usage = computeContextUsage({ diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.module.css b/packages/agents-server-ui/src/components/ContextUsageDetails.module.css new file mode 100644 index 0000000000..3279dd19b3 --- /dev/null +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.module.css @@ -0,0 +1,113 @@ +.panel { + min-width: 230px; + max-width: 280px; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-variant-numeric: tabular-nums; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text, inherit); +} + +.headline { + font-size: 0.8rem; + font-weight: 600; + color: var(--text, inherit); +} + +.subhead { + font-size: 0.72rem; + color: var(--text-muted, rgba(0, 0, 0, 0.55)); + margin-top: -0.25rem; +} + +/* Stacked composition bar. */ +.bar { + display: flex; + width: 100%; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--surface-sunken, rgba(0, 0, 0, 0.06)); +} + +.barSeg { + height: 100%; + display: block; +} + +.legend { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.legendRow { + display: grid; + grid-template-columns: 0.6rem 1fr auto auto; + align-items: center; + gap: 0.4rem; + font-size: 0.72rem; +} + +.swatch { + width: 0.6rem; + height: 0.6rem; + border-radius: 2px; + flex-shrink: 0; +} + +.legendLabel { + color: var(--text, inherit); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.legendTokens { + color: var(--text-muted, rgba(0, 0, 0, 0.55)); + text-align: right; +} + +.legendPercent { + color: var(--text-muted, rgba(0, 0, 0, 0.55)); + text-align: right; + min-width: 2.4rem; +} + +.note { + font-size: 0.66rem; + color: var(--text-muted, rgba(0, 0, 0, 0.45)); + font-style: italic; +} + +/* Per-segment colours, shared by the bar fills and the legend swatches. */ +.system { + background: var(--accent-9, #4c6ef5); +} + +.tools { + background: var(--violet-9, #7048e8); +} + +.messages { + background: var(--teal-9, #0ca678); +} + +.free { + background: var(--surface-sunken, rgba(0, 0, 0, 0.12)); +} diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx new file mode 100644 index 0000000000..66d5f7552f --- /dev/null +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx @@ -0,0 +1,85 @@ +import { + formatContextUsagePercent, + formatTokenCount, +} from '@electric-ax/agents-runtime/client' +import type { + ContextBreakdownSegment, + ContextUsage, +} from '@electric-ax/agents-runtime/client' +import styles from './ContextUsageDetails.module.css' + +interface ContextUsageDetailsProps { + usage: ContextUsage + segments: ReadonlyArray + modelId?: string +} + +/** + * The hover/click popover body for the context-usage indicator — a stacked + * composition bar plus a legend of how each part of the prompt fills the + * window (à la Claude Code's `/context`). System-prompt and tools figures are + * approximate; the "Messages" bucket is the real cache-inclusive remainder, so + * the segments always sum to the gauge. + */ +export function ContextUsageDetails({ + usage, + segments, + modelId, +}: ContextUsageDetailsProps): React.ReactElement { + const usedRatio = + usage.contextWindow > 0 + ? Math.min(1, usage.usedTokens / usage.contextWindow) + : 0 + + return ( +
+
+ Context usage + + {formatContextUsagePercent(usedRatio)} + +
+
+ {formatTokenCount(usage.usedTokens)} /{` `} + {formatTokenCount(usage.contextWindow)} tokens + {modelId ? ` · ${modelId}` : ``} +
+ +
+ {segments.map((seg) => + seg.ratio > 0 ? ( + + ) : null + )} +
+ +
    + {segments.map((seg) => ( +
  • +
  • + ))} +
+ +
System & tools are estimates.
+
+ ) +} diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx index ed144711e0..83686e2c22 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx @@ -2,19 +2,21 @@ import { useMemo } from 'react' import { useLiveQuery } from '@tanstack/react-db' import { computeContextUsage, + computeContextBreakdown, + parseContextBreakdown, contextUsageLevel, formatContextUsagePercent, - formatTokenCount, } from '@electric-ax/agents-runtime/client' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' -import { Tooltip } from '../ui/Tooltip' +import { HoverCard } from '../ui/HoverCard' import { ContextUsageRing } from './ContextUsageRing' +import { ContextUsageDetails } from './ContextUsageDetails' import styles from './ContextUsageIndicator.module.css' /** - * Context-window gauge for the composer footer ("X% used"). Reads the same - * numbers the runtime persists for compaction (the latest step's cache-inclusive - * prompt size and the model's window) through `computeContextUsage`. + * Context-window gauge for the composer footer ("X% used") — a ring + percent + * over the latest step's usage (computeContextUsage). Hovering reveals a + * per-part composition breakdown. */ interface StepRow { @@ -23,6 +25,7 @@ interface StepRow { context_window?: number output_tokens?: number model_id?: string + context_breakdown?: string } interface ContextUsageIndicatorProps { @@ -64,29 +67,44 @@ export function ContextUsageIndicator({ outputTokens: latest.output_tokens, contextWindow: latest.context_window as number, }) - return computed ? { ...computed, modelId: latest.model_id } : null + if (!computed) return null + return { + ...computed, + modelId: latest.model_id, + segments: computeContextBreakdown( + computed, + parseContextBreakdown(latest.context_breakdown) + ), + } }, [steps]) if (!usage) return null const level = contextUsageLevel(usage.ratio) const percent = formatContextUsagePercent(usage.ratio) - const tokensLabel = `${formatTokenCount(usage.usedTokens)} / ${formatTokenCount( - usage.contextWindow - )} tokens` - const tooltip = usage.modelId - ? `${tokensLabel} · ${usage.modelId}` - : tokensLabel return ( - - - - {percent} - - + + + + {percent} + + } + /> + + + + ) } From 2dbc891e02d61df7fd2ce7f9349feba644f4d497 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 16:55:39 +0200 Subject: [PATCH 034/105] chore: changeset for context-usage breakdown --- .changeset/context-usage-breakdown.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/context-usage-breakdown.md diff --git a/.changeset/context-usage-breakdown.md b/.changeset/context-usage-breakdown.md new file mode 100644 index 0000000000..5621fdc96d --- /dev/null +++ b/.changeset/context-usage-breakdown.md @@ -0,0 +1,13 @@ +--- +'@electric-ax/agents-runtime': minor +'@electric-ax/agents-server-ui': patch +--- + +Context-usage indicator: a circular ring gauge plus a hover breakdown popover +showing how the prompt decomposes across system prompt, tools, messages, and +free space (à la Claude Code's `/context`). The runtime now persists an +approximate `context_breakdown` of the stable request parts (system + tools) on +each step alongside the cache-inclusive total, and exports new +`computeContextBreakdown` / `parseContextBreakdown` helpers from the client +entry; the UI derives the "messages" bucket as the real remainder so the +segments always sum to the gauge. From 06604b2131bf6e2348935a20f3969e6636153de3 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 16 Jun 2026 17:03:14 +0200 Subject: [PATCH 035/105] chore: downgrade context-usage breakdown changeset to patch --- .changeset/context-usage-breakdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/context-usage-breakdown.md b/.changeset/context-usage-breakdown.md index 5621fdc96d..5e77018d30 100644 --- a/.changeset/context-usage-breakdown.md +++ b/.changeset/context-usage-breakdown.md @@ -1,5 +1,5 @@ --- -'@electric-ax/agents-runtime': minor +'@electric-ax/agents-runtime': patch '@electric-ax/agents-server-ui': patch --- From 7afdf20ab8253e7e594242488d4e8ed47e4d16c6 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:04:22 +0200 Subject: [PATCH 036/105] fix(agents): estimate the tools breakdown from serialized schemas [review #4596] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `approxTokens(opts.tools)` hit approxTokens' array branch, which charges a flat ~64 per non-text block — so the "Tools" segment was ~64×toolCount regardless of how large each tool's name/description/parameter schema actually is, and the shortfall silently inflated the "Messages" remainder. Serialize the tool array first so the estimate reflects what really occupies the prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/pi-adapter.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 0bdc20628d..6c403a47aa 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -324,7 +324,12 @@ export function createPiAgentAdapter( // total minus these (see token-accountant computeContextBreakdown). const tokenBreakdown = { system: approxTokens(opts.systemPrompt), - tools: approxTokens(opts.tools), + // Serialize first: approxTokens' array branch charges a flat ~64 per + // non-text block, so passing the AgentTool[] directly would estimate + // ~64×toolCount regardless of the real schema size. JSON.stringify + // captures each tool's name + description + parameter schema (functions + // drop out), which is what actually occupies the prompt. + tools: approxTokens(JSON.stringify(opts.tools)), } const transformContext = From 0fca4c44ada30b4764d49a9d2ccc08d0c49920ee Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:05:01 +0200 Subject: [PATCH 037/105] refactor(agents-server-ui): use usage.ratio in the breakdown header [review #4596] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover header recomputed `min(1, usedTokens/contextWindow)` locally — exactly what `computeContextUsage` already stored as `usage.ratio` (and what the trigger gauge uses). Use it directly so the two can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ContextUsageDetails.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx index 66d5f7552f..a0e1d6c309 100644 --- a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx @@ -26,17 +26,12 @@ export function ContextUsageDetails({ segments, modelId, }: ContextUsageDetailsProps): React.ReactElement { - const usedRatio = - usage.contextWindow > 0 - ? Math.min(1, usage.usedTokens / usage.contextWindow) - : 0 - return (
Context usage - {formatContextUsagePercent(usedRatio)} + {formatContextUsagePercent(usage.ratio)}
From 1d83207674fba881de29e11acc9f30138e80602d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:05:34 +0200 Subject: [PATCH 038/105] fix(agents-server-ui): show "<1%" for tiny non-zero breakdown segments [review #4596] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A segment with a small but non-zero share rendered a coloured swatch + bar sliver labelled "0%", which reads as a contradiction. Label any non-zero segment that rounds to 0% as "<1%" instead. (Per-segment rounding can still make the four rows not sum to exactly 100% — acceptable for a composition popover; exact apportionment isn't worth the complexity here.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ContextUsageDetails.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx index a0e1d6c309..ac8fa3aa45 100644 --- a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx @@ -14,6 +14,16 @@ interface ContextUsageDetailsProps { modelId?: string } +/** + * Percent label for a legend row. A non-zero segment that would round to `0%` + * shows `<1%` instead, so a visibly-coloured swatch/bar isn't labelled "0%". + */ +function formatSegmentPercent(ratio: number): string { + return ratio > 0 && Math.round(ratio * 100) === 0 + ? `<1%` + : formatContextUsagePercent(ratio) +} + /** * The hover/click popover body for the context-usage indicator — a stacked * composition bar plus a legend of how each part of the prompt fills the @@ -68,7 +78,7 @@ export function ContextUsageDetails({ {formatTokenCount(seg.tokens)} - {formatContextUsagePercent(seg.ratio)} + {formatSegmentPercent(seg.ratio)} ))} From c94d005f3692dfb641c7137023498b538442dc21 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:06:09 +0200 Subject: [PATCH 039/105] test(agents): cover the over-window clamp in computeContextBreakdown [review #4596] The breakdown clamps `used` to the window so the segments still sum to the window and `free` can't go negative when a step reports usage above the window. That path was exercised but untested; lock it in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/token-accountant.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts index f0a9bb11a8..e121c192c5 100644 --- a/packages/agents-runtime/test/token-accountant.test.ts +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -51,6 +51,24 @@ describe(`computeContextBreakdown`, () => { expect(segs.every((s) => s.tokens >= 0)).toBe(true) }) + it(`clamps usedTokens to the window when the step is over the window`, () => { + const segs = computeContextBreakdown( + // A step can report usage above the window; usedTokens stays un-clamped on + // ContextUsage but the breakdown must clamp it so the segments still sum to + // the window and `free` doesn't go negative. + { usedTokens: 120_000, contextWindow: 100_000, ratio: 1 }, + { systemTokens: 5_000, toolsTokens: 15_000 } + ) + const tokens = Object.fromEntries(segs.map((s) => [s.key, s.tokens])) + expect(tokens).toEqual({ + system: 5_000, + tools: 15_000, + messages: 80_000, // window (100k) − system − tools + free: 0, + }) + expect(segs.reduce((n, s) => n + s.tokens, 0)).toBe(100_000) + }) + it(`parseContextBreakdown reads persisted JSON and tolerates junk`, () => { expect(parseContextBreakdown(`{"system":12,"tools":34}`)).toEqual({ systemTokens: 12, From 53424665646136b0d696046a19f19e20d1e89c80 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:53:14 +0200 Subject: [PATCH 040/105] fix(agents-server-ui): keep token/model detail in the gauge's aria-label [review #4596] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the indicator to a HoverCard moved the token/window/model detail into hover-only popover content, so the trigger's aria-label dropped to just the percent — a small accessibility regression for keyboard/screen-reader users, who could previously read those numbers from the old Tooltip's label. Restore the ` / tokens · ` summary to the trigger's own aria-label. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ContextUsageIndicator.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx index 83686e2c22..7b960aa0c6 100644 --- a/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageIndicator.tsx @@ -6,6 +6,7 @@ import { parseContextBreakdown, contextUsageLevel, formatContextUsagePercent, + formatTokenCount, } from '@electric-ax/agents-runtime/client' import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' import { HoverCard } from '../ui/HoverCard' @@ -82,6 +83,15 @@ export function ContextUsageIndicator({ const level = contextUsageLevel(usage.ratio) const percent = formatContextUsagePercent(usage.ratio) + // Keep the essential numbers in the trigger's own label: the breakdown popover + // is hover-only (Base UI PreviewCard), so keyboard/screen-reader users would + // otherwise get only the percent from the trigger. + const tokensLabel = `${formatTokenCount(usage.usedTokens)} / ${formatTokenCount( + usage.contextWindow + )} tokens` + const ariaLabel = `Context used: ${percent} (${tokensLabel}${ + usage.modelId ? ` · ${usage.modelId}` : `` + }) — hover for breakdown` return ( @@ -91,7 +101,7 @@ export function ContextUsageIndicator({ className={[styles.indicator, styles[level]] .filter(Boolean) .join(` `)} - aria-label={`Context used: ${percent} — hover for breakdown`} + aria-label={ariaLabel} > {percent} From ae99cbec95b43d996c98ccf8f438a89eedd7e0c8 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 12:54:04 +0200 Subject: [PATCH 041/105] fix(agents-server-ui): omit empty buckets from the breakdown legend [review #4596] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacked bar already skips zero-ratio segments, but the legend rendered all four rows unconditionally — so a step with no persisted breakdown (older events) showed noisy "System prompt — 0 — 0%" / "Tools — 0 — 0%" rows. Gate the legend rows on `tokens > 0` to match the bar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ContextUsageDetails.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx index ac8fa3aa45..845a350e79 100644 --- a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx @@ -67,21 +67,25 @@ export function ContextUsageDetails({
    - {segments.map((seg) => ( -
  • -
  • - ))} + {/* Omit empty buckets (e.g. system/tools for older steps with no + persisted breakdown), matching the stacked bar above. */} + {segments + .filter((seg) => seg.tokens > 0) + .map((seg) => ( +
  • +
  • + ))}
System & tools are estimates.
From 900f46520b067163d721832f86736c5e12b9a586 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:37:37 +0200 Subject: [PATCH 042/105] docs(agents): trim the breakdown comments (schema + adapter) Tighten the `context_breakdown` column comment and the tokenBreakdown intro to be brief and to the point. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/entity-schema.ts | 7 ++----- packages/agents-runtime/src/pi-adapter.ts | 13 +++++-------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 606c480d6b..69536e1707 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -165,11 +165,8 @@ type StepValue = { context_input_tokens?: number // The model's context window for this step. context_window?: number - // JSON-encoded estimate of how the prompt decomposes across the stable - // request parts — `{ system, tools }` token counts (approximate; char/4). - // The UI derives the remaining "messages" bucket as the real cache-inclusive - // total minus these, so the breakdown always sums to the gauge. Optional and - // additive; older events without it stay valid. + // JSON estimate of the prompt's stable parts — `{ system, tools }` token + // counts (char/4). The UI derives "messages" as the real total minus these. context_breakdown?: string } type TextValue = { diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 6c403a47aa..4d4df6c899 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -318,17 +318,14 @@ export function createPiAgentAdapter( const modelContextWindow = typeof model.contextWindow === `number` ? model.contextWindow : 0 - // Approximate token cost of the stable, non-message request parts. These - // are constant for the whole call, so estimate once and persist on each - // step; the UI derives the "messages" bucket as the real cache-inclusive - // total minus these (see token-accountant computeContextBreakdown). + // Stable request parts (constant for the call), estimated once and persisted + // per step; the UI derives "messages" as the real total minus these. const tokenBreakdown = { system: approxTokens(opts.systemPrompt), // Serialize first: approxTokens' array branch charges a flat ~64 per - // non-text block, so passing the AgentTool[] directly would estimate - // ~64×toolCount regardless of the real schema size. JSON.stringify - // captures each tool's name + description + parameter schema (functions - // drop out), which is what actually occupies the prompt. + // non-text block, so the raw AgentTool[] would estimate ~64×toolCount + // regardless of real schema size. JSON.stringify captures each tool's + // name + description + parameter schema, which is what fills the prompt. tools: approxTokens(JSON.stringify(opts.tools)), } From ed13b5659819b2cb6de3efd80e49e2010d1542e0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:41:57 +0200 Subject: [PATCH 043/105] docs(agents-server-ui): tighten breakdown doc comments Trim the computeContextBreakdown JSDoc, the ContextUsageDetails component doc, and an over-long test comment to be brief and to the point. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/token-accountant.ts | 10 ++++------ packages/agents-runtime/test/token-accountant.test.ts | 5 ++--- .../src/components/ContextUsageDetails.tsx | 8 +++----- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/agents-runtime/src/token-accountant.ts b/packages/agents-runtime/src/token-accountant.ts index 0dce6ede4a..e83b08b7c3 100644 --- a/packages/agents-runtime/src/token-accountant.ts +++ b/packages/agents-runtime/src/token-accountant.ts @@ -89,12 +89,10 @@ const CONTEXT_BREAKDOWN_LABELS: Record = { } /** - * Decompose a step's context usage into display segments: the *estimated* - * stable parts (system prompt + tools, char/4 approximations persisted on the - * step) plus the real "messages" remainder and the free space. The used - * segments (everything but `free`) sum to `usage.usedTokens` and all four sum - * to the window — so the breakdown always agrees with the gauge even though the - * part estimates are approximate. Modelled on Claude Code's `/context` view. + * Decompose a step's usage into display segments: the estimated stable parts + * (system + tools) plus the real "messages" remainder and free space. The used + * segments sum to `usedTokens` and all four to the window, so the breakdown + * always agrees with the gauge. Modelled on Claude Code's `/context`. */ export function computeContextBreakdown( usage: ContextUsage, diff --git a/packages/agents-runtime/test/token-accountant.test.ts b/packages/agents-runtime/test/token-accountant.test.ts index e121c192c5..c0eabfde16 100644 --- a/packages/agents-runtime/test/token-accountant.test.ts +++ b/packages/agents-runtime/test/token-accountant.test.ts @@ -53,9 +53,8 @@ describe(`computeContextBreakdown`, () => { it(`clamps usedTokens to the window when the step is over the window`, () => { const segs = computeContextBreakdown( - // A step can report usage above the window; usedTokens stays un-clamped on - // ContextUsage but the breakdown must clamp it so the segments still sum to - // the window and `free` doesn't go negative. + // A step can report usage above the window; the breakdown clamps `used` so + // the segments still sum to the window and `free` can't go negative. { usedTokens: 120_000, contextWindow: 100_000, ratio: 1 }, { systemTokens: 5_000, toolsTokens: 15_000 } ) diff --git a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx index 845a350e79..b40689a87d 100644 --- a/packages/agents-server-ui/src/components/ContextUsageDetails.tsx +++ b/packages/agents-server-ui/src/components/ContextUsageDetails.tsx @@ -25,11 +25,9 @@ function formatSegmentPercent(ratio: number): string { } /** - * The hover/click popover body for the context-usage indicator — a stacked - * composition bar plus a legend of how each part of the prompt fills the - * window (à la Claude Code's `/context`). System-prompt and tools figures are - * approximate; the "Messages" bucket is the real cache-inclusive remainder, so - * the segments always sum to the gauge. + * Popover body for the context-usage indicator: a stacked composition bar + + * legend (à la Claude Code's `/context`). System/tools are estimates; "Messages" + * is the real remainder, so the segments always sum to the gauge. */ export function ContextUsageDetails({ usage, From ac19f7dffc43c9ac346b39134ece23ba9973e824 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 17 Jun 2026 14:49:15 +0200 Subject: [PATCH 044/105] docs(agents): drop the confusing serialize-first tools comment Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/pi-adapter.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 4d4df6c899..a45de6d955 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -322,10 +322,6 @@ export function createPiAgentAdapter( // per step; the UI derives "messages" as the real total minus these. const tokenBreakdown = { system: approxTokens(opts.systemPrompt), - // Serialize first: approxTokens' array branch charges a flat ~64 per - // non-text block, so the raw AgentTool[] would estimate ~64×toolCount - // regardless of real schema size. JSON.stringify captures each tool's - // name + description + parameter schema, which is what fills the prompt. tools: approxTokens(JSON.stringify(opts.tools)), } From eb6189c5ff23c474fb6e18c32841032e68bd3d0f Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 18 Jun 2026 12:35:16 +0100 Subject: [PATCH 045/105] Fix electric-ax observe stopped rerenders Co-authored-by: Cursor --- packages/electric-ax/src/observe-ui.tsx | 13 ++-- packages/electric-ax/test/observe-ui.test.tsx | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/electric-ax/src/observe-ui.tsx b/packages/electric-ax/src/observe-ui.tsx index 0ad145310d..b043ebc3c7 100644 --- a/packages/electric-ax/src/observe-ui.tsx +++ b/packages/electric-ax/src/observe-ui.tsx @@ -566,11 +566,11 @@ function AgentRunView({ isStreaming: boolean }): React.ReactElement { const { data: items = [] } = useLiveQuery( - (q) => (run.items ? q.from({ item: run.items }) : undefined), + (q) => (run.items ? q.from({ item: run.items as any }) : undefined), [run.items] ) const { data: errors = [] } = useLiveQuery( - (q) => (run.errors ? q.from({ error: run.errors }) : undefined), + (q) => (run.errors ? q.from({ error: run.errors as any }) : undefined), [run.errors] ) @@ -664,7 +664,7 @@ export function ObserveExitHotkey({ return null } -function ObserveView({ +export function ObserveView({ db, entityUrl, baseUrl, @@ -686,8 +686,11 @@ function ObserveView({ timelineRowsToDisplayRows(timelineRows as Array), [timelineRows] ) - - const closed = db.collections.entityStopped.toArray.length > 0 + const { data: entityStoppedRows = [] } = useLiveQuery( + (q) => q.from({ stopped: db.collections.entityStopped as any }), + [db.collections.entityStopped] + ) + const closed = entityStoppedRows.length > 0 const lastAgentIndex = useMemo(() => { for (let i = displayRows.length - 1; i >= 0; i--) { diff --git a/packages/electric-ax/test/observe-ui.test.tsx b/packages/electric-ax/test/observe-ui.test.tsx index 7006d2bcc0..a07c295cb9 100644 --- a/packages/electric-ax/test/observe-ui.test.tsx +++ b/packages/electric-ax/test/observe-ui.test.tsx @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'ink-testing-library' +import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' import { AgentTextView, + ObserveView, ObserveExitHotkey, ToolCallView, ToolResultView, @@ -13,10 +15,37 @@ import { truncate, } from '../src/observe-ui' +const runtimeMocks = vi.hoisted(() => ({ + timelineRows: undefined as any, +})) + +vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + createEntityTimelineQuery: () => (q: any) => + q.from({ row: runtimeMocks.timelineRows }), + } +}) + // ============================================================================ // Helper functions // ============================================================================ +function createLocalCollection( + id: string, + getKey: (row: TRow) => string +): any { + return createCollection( + localOnlyCollectionOptions({ + id: `test-${id}-${Math.random().toString(36).slice(2)}`, + getKey: getKey as any, + initialData: [], + }) + ) +} + describe(`formatTime`, () => { it(`returns empty string for undefined`, () => { expect(formatTime(undefined)).toBe(``) @@ -66,6 +95,47 @@ describe(`ObserveExitHotkey`, () => { }) }) +describe(`ObserveView`, () => { + it(`rerenders when an entity stopped event arrives without timeline events`, async () => { + runtimeMocks.timelineRows = createLocalCollection( + `timeline`, + (row: Record) => String(row.$key ?? row.key ?? ``) + ) + const entityStopped = createLocalCollection( + `stopped`, + (row: Record) => String(row.key) + ) + const db = { + collections: { + entityStopped, + }, + } as any + const { lastFrame, unmount } = render( + + ) + + expect(lastFrame()).toContain(`Waiting for events...`) + expect(lastFrame()).not.toContain(`Stream closed`) + + entityStopped.insert({ + key: `stop-1`, + timestamp: new Date().toISOString(), + reason: `done`, + }) + + await expect + .poll(() => lastFrame(), { timeout: 1000 }) + .toContain(`Stream closed`) + + unmount() + }) +}) + describe(`createMessageSendBody`, () => { it(`omits legacy from attribution`, () => { expect(createMessageSendBody(`hello`)).toEqual({ From 0376e4e3176f521ea5b358c299eddcb333b80d4d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 18 Jun 2026 13:12:23 +0100 Subject: [PATCH 046/105] Tighten observe stopped test types Co-authored-by: Cursor --- packages/electric-ax/src/observe-ui.tsx | 6 ++--- packages/electric-ax/test/observe-ui.test.tsx | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/electric-ax/src/observe-ui.tsx b/packages/electric-ax/src/observe-ui.tsx index b043ebc3c7..26e20b515f 100644 --- a/packages/electric-ax/src/observe-ui.tsx +++ b/packages/electric-ax/src/observe-ui.tsx @@ -566,11 +566,11 @@ function AgentRunView({ isStreaming: boolean }): React.ReactElement { const { data: items = [] } = useLiveQuery( - (q) => (run.items ? q.from({ item: run.items as any }) : undefined), + (q) => (run.items ? q.from({ item: run.items }) : undefined), [run.items] ) const { data: errors = [] } = useLiveQuery( - (q) => (run.errors ? q.from({ error: run.errors as any }) : undefined), + (q) => (run.errors ? q.from({ error: run.errors }) : undefined), [run.errors] ) @@ -687,7 +687,7 @@ export function ObserveView({ [timelineRows] ) const { data: entityStoppedRows = [] } = useLiveQuery( - (q) => q.from({ stopped: db.collections.entityStopped as any }), + (q) => q.from({ stopped: db.collections.entityStopped }), [db.collections.entityStopped] ) const closed = entityStoppedRows.length > 0 diff --git a/packages/electric-ax/test/observe-ui.test.tsx b/packages/electric-ax/test/observe-ui.test.tsx index a07c295cb9..59bd1e3c1c 100644 --- a/packages/electric-ax/test/observe-ui.test.tsx +++ b/packages/electric-ax/test/observe-ui.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'ink-testing-library' import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' +import type { Collection, InitialQueryBuilder } from '@tanstack/db' import { AgentTextView, ObserveView, @@ -14,9 +15,17 @@ import { formatTime, truncate, } from '../src/observe-ui' +import type { EntityStreamDB } from '../src/entity-stream-db' + +type TimelineTestRow = { key: string } +type EntityStoppedTestRow = { + key: string + timestamp: string + reason?: string +} const runtimeMocks = vi.hoisted(() => ({ - timelineRows: undefined as any, + timelineRows: undefined as Collection | undefined, })) vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { @@ -24,8 +33,8 @@ vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { await importOriginal() return { ...actual, - createEntityTimelineQuery: () => (q: any) => - q.from({ row: runtimeMocks.timelineRows }), + createEntityTimelineQuery: () => (q: InitialQueryBuilder) => + q.from({ row: runtimeMocks.timelineRows! }), } }) @@ -36,11 +45,11 @@ vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { function createLocalCollection( id: string, getKey: (row: TRow) => string -): any { +): Collection { return createCollection( localOnlyCollectionOptions({ id: `test-${id}-${Math.random().toString(36).slice(2)}`, - getKey: getKey as any, + getKey, initialData: [], }) ) @@ -99,17 +108,17 @@ describe(`ObserveView`, () => { it(`rerenders when an entity stopped event arrives without timeline events`, async () => { runtimeMocks.timelineRows = createLocalCollection( `timeline`, - (row: Record) => String(row.$key ?? row.key ?? ``) + (row: TimelineTestRow) => row.key ) - const entityStopped = createLocalCollection( + const entityStopped = createLocalCollection( `stopped`, - (row: Record) => String(row.key) + (row) => row.key ) const db = { collections: { entityStopped, }, - } as any + } as unknown as EntityStreamDB const { lastFrame, unmount } = render( Date: Thu, 18 Jun 2026 15:33:00 +0100 Subject: [PATCH 047/105] Fix observe test import type syntax Co-authored-by: Cursor --- packages/electric-ax/test/observe-ui.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/electric-ax/test/observe-ui.test.tsx b/packages/electric-ax/test/observe-ui.test.tsx index 59bd1e3c1c..97d960b2a1 100644 --- a/packages/electric-ax/test/observe-ui.test.tsx +++ b/packages/electric-ax/test/observe-ui.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'ink-testing-library' import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' import type { Collection, InitialQueryBuilder } from '@tanstack/db' +import type * as AgentsRuntime from '@electric-ax/agents-runtime' import { AgentTextView, ObserveView, @@ -29,8 +30,7 @@ const runtimeMocks = vi.hoisted(() => ({ })) vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { - const actual = - await importOriginal() + const actual = await importOriginal() return { ...actual, createEntityTimelineQuery: () => (q: InitialQueryBuilder) => From df9eba6bef6d1286a52b1f7698f3cf0ff98d53a5 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 10:46:22 -0600 Subject: [PATCH 048/105] Fix reasoning section elapsed timers --- .../src/components/AgentResponse.tsx | 1 - .../src/components/ReasoningSection.test.tsx | 92 +++++++++++++++++++ .../src/components/ReasoningSection.tsx | 32 +++---- 3 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 packages/agents-server-ui/src/components/ReasoningSection.test.tsx diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index 34fd4d2a29..877523603f 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -666,7 +666,6 @@ export const AgentResponseLive = memo(function AgentResponseLive({ key={entry.key} entry={entry.reasoning} isStreaming={isStreaming} - timestamp={timestamp} expanded={Boolean(expandedReasoning[entry.key])} onToggle={toggleReasoning} /> diff --git a/packages/agents-server-ui/src/components/ReasoningSection.test.tsx b/packages/agents-server-ui/src/components/ReasoningSection.test.tsx new file mode 100644 index 0000000000..6e178cc7b1 --- /dev/null +++ b/packages/agents-server-ui/src/components/ReasoningSection.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { ReasoningBlock, type ReasoningEntry } from './ReasoningSection' + +beforeAll(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true +}) + +const streamingEntry: ReasoningEntry = { + key: `reasoning-1`, + order: `1`, + content: `Inspecting the code.`, + status: `streaming`, + summary_title: `Inspecting code`, +} + +function renderReasoningBlock( + root: Root, + entry: ReasoningEntry, + isStreaming: boolean +): void { + act(() => { + root.render( + {}} + /> + ) + }) +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe(`ReasoningBlock`, () => { + it(`anchors live thinking elapsed time to the reasoning block start, not the run start`, () => { + vi.useFakeTimers() + try { + const runStartedAt = new Date(`2026-01-01T00:00:00.000Z`).getTime() + vi.setSystemTime(runStartedAt + 5 * 60 * 1000) + + const markup = renderToStaticMarkup( + {}} + /> + ) + + expect(markup).toContain(`Elapsed time: 0s`) + expect(markup).not.toContain(`Elapsed time: 5m`) + } finally { + vi.useRealTimers() + } + }) + + it(`snapshots the reasoning duration when a live block completes`, () => { + vi.useFakeTimers() + const container = document.createElement(`div`) + document.body.appendChild(container) + const root = createRoot(container) + + try { + const startedAt = new Date(`2026-01-01T00:00:00.000Z`).getTime() + vi.setSystemTime(startedAt) + renderReasoningBlock(root, streamingEntry, true) + + vi.setSystemTime(startedAt + 3_000) + renderReasoningBlock( + root, + { ...streamingEntry, status: `completed` }, + false + ) + + expect(container.textContent).toContain(`Thought for 3s`) + } finally { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + } + }) +}) diff --git a/packages/agents-server-ui/src/components/ReasoningSection.tsx b/packages/agents-server-ui/src/components/ReasoningSection.tsx index 35ec725f31..3aa01eb53e 100644 --- a/packages/agents-server-ui/src/components/ReasoningSection.tsx +++ b/packages/agents-server-ui/src/components/ReasoningSection.tsx @@ -8,7 +8,7 @@ import { import { Stack, Text } from '../ui' import { ThinkingIndicator } from './ThinkingIndicator' import { ElapsedTime } from './ElapsedTime' -import { formatElapsedDuration, toMillis } from '../lib/formatTime' +import { formatElapsedDuration } from '../lib/formatTime' import styles from './ReasoningSection.module.css' /** @@ -63,13 +63,11 @@ export type ReasoningEntry = { export function ReasoningBlock({ entry, isStreaming, - timestamp, expanded, onToggle, }: { entry: ReasoningEntry isStreaming: boolean - timestamp?: number | null expanded: boolean onToggle: (key: string) => void }): React.ReactElement { @@ -79,26 +77,24 @@ export function ReasoningBlock({ [entry.key, onToggle] ) - // Snapshot the elapsed duration at the moment streaming flips to - // `completed`, the same `sawStreamingRef` trick used for "done in - // Xs" on `AgentResponse`. For reasoning rows that were already - // settled on first mount (page reload, scrollback into older - // turns) we don't have a real end timestamp, so the closure stays - // a bare "Thought" without a duration — better than printing a - // wildly-wrong number from `now() - userMessageTime`. - const sawStreamingRef = useRef(isLive) - if (isLive) sawStreamingRef.current = true + // Capture this reasoning row's first live render time. Later rows + // may start after tool calls, so using the parent run timestamp + // overstates their duration. Rows mounted already completed keep a + // bare "Thought" label because we do not know their actual end time. + const liveStartedAtMsRef = useRef(null) + if (isLive && liveStartedAtMsRef.current == null) { + liveStartedAtMsRef.current = Date.now() + } const [finalDurationMs, setFinalDurationMs] = useState(null) useEffect(() => { if ( entry.status === `completed` && - sawStreamingRef.current && - timestamp != null && + liveStartedAtMsRef.current != null && finalDurationMs == null ) { - setFinalDurationMs(Math.max(0, Date.now() - toMillis(timestamp))) + setFinalDurationMs(Math.max(0, Date.now() - liveStartedAtMsRef.current)) } - }, [entry.status, timestamp, finalDurationMs]) + }, [entry.status, finalDurationMs]) // Redacted thinking — opaque payload, nothing to render. if (entry.encrypted && entry.content.trim().length === 0) { @@ -126,12 +122,12 @@ export function ReasoningBlock({ )} - {timestamp != null && ( + {liveStartedAtMsRef.current != null && ( <> · - + )} From 9eb8e691445b2cc27ad62d21e5cb3e1b3241f7ab Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 10:47:12 -0600 Subject: [PATCH 049/105] Add changeset for reasoning timer fix --- .changeset/fix-reasoning-timers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-reasoning-timers.md diff --git a/.changeset/fix-reasoning-timers.md b/.changeset/fix-reasoning-timers.md new file mode 100644 index 0000000000..281180ed2d --- /dev/null +++ b/.changeset/fix-reasoning-timers.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-server-ui': patch +--- + +Fix reasoning section elapsed timers so each thinking block measures from when that block starts, rather than from the beginning of the run. From 3a3492f409cc7aa1f7e463e3fac898cd1d50524e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 13:56:59 -0600 Subject: [PATCH 050/105] Fix deferred pull wake delivery --- .../agents-runtime/src/pull-wake-runner.ts | 45 +++++++++++---- .../test/pull-wake-runner.test.ts | 55 +++++++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 04175fff6f..6a0a323eb9 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -108,6 +108,7 @@ export function createPullWakeRunner( let heartbeatTimer: ReturnType | null = null let eventHeartbeatTimer: ReturnType | null = null let heartbeatInFlight: Promise | null = null + let heartbeatInFlightSignal: AbortSignal | null = null let heartbeatPending = false let currentOffset = config.offset ?? `-1` let startedAt: string | null = null @@ -128,7 +129,7 @@ export function createPullWakeRunner( let stopPromise: Promise | null = null const claimActors = new Set>() const claimingStreamPaths = new Set() - const deferredWakeEventsByStreamPath = new Map() + const deferredWakeEventsByStreamPath = new Map>() const deferredWakeTimersByStreamPath = new Map< string, ReturnType @@ -235,10 +236,15 @@ export function createPullWakeRunner( const requestHeartbeat = (signal: AbortSignal): void => { if (signal.aborted) return heartbeatPending = true - if (heartbeatInFlight) return - heartbeatInFlight = flushHeartbeats(signal).finally(() => { - heartbeatInFlight = null + if (heartbeatInFlight && heartbeatInFlightSignal === signal) return + const inFlight = flushHeartbeats(signal).finally(() => { + if (heartbeatInFlight === inFlight) { + heartbeatInFlight = null + heartbeatInFlightSignal = null + } }) + heartbeatInFlight = inFlight + heartbeatInFlightSignal = signal } const flushHeartbeats = async (signal: AbortSignal): Promise => { @@ -358,28 +364,32 @@ export function createPullWakeRunner( deferredWakeEventsByStreamPath.clear() } - const scheduleDeferredWakeClaim = ( - event: PullWakeEvent, + const scheduleDeferredWakeRetry = ( + streamPath: string, signal: AbortSignal ): void => { - const streamPath = normalizeStreamPath(event.stream) - deferredWakeEventsByStreamPath.set(streamPath, event) if (deferredWakeTimersByStreamPath.has(streamPath)) return const retry = (): void => { deferredWakeTimersByStreamPath.delete(streamPath) - const deferredEvent = deferredWakeEventsByStreamPath.get(streamPath) - if (!deferredEvent || signal.aborted || !isRunningState()) { + const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) + if (!deferredEvents?.length || signal.aborted || !isRunningState()) { deferredWakeEventsByStreamPath.delete(streamPath) return } if (hasActiveStreamClaim(streamPath)) { - scheduleDeferredWakeClaim(deferredEvent, signal) + scheduleDeferredWakeRetry(streamPath, signal) return } - deferredWakeEventsByStreamPath.delete(streamPath) + const deferredEvent = deferredEvents.shift()! + if (deferredEvents.length === 0) { + deferredWakeEventsByStreamPath.delete(streamPath) + } spawnClaimActor(deferredEvent, signal) + if (deferredEvents.length > 0) { + scheduleDeferredWakeRetry(streamPath, signal) + } } const timer = setTimeout(retry, DEFERRED_WAKE_RETRY_MS) @@ -387,6 +397,17 @@ export function createPullWakeRunner( deferredWakeTimersByStreamPath.set(streamPath, timer) } + const scheduleDeferredWakeClaim = ( + event: PullWakeEvent, + signal: AbortSignal + ): void => { + const streamPath = normalizeStreamPath(event.stream) + const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) ?? [] + deferredEvents.push(event) + deferredWakeEventsByStreamPath.set(streamPath, deferredEvents) + scheduleDeferredWakeRetry(streamPath, signal) + } + const claimWake = async ( event: PullWakeEvent, signal: AbortSignal diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 519a9c86f2..0d03b5f7fc 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -278,6 +278,61 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) + it(`retries every wake skipped while the same stream is already being claimed`, async () => { + const events = [ + { ...wakeEvent(`parent`), generation: 1 }, + { ...wakeEvent(`parent`), generation: 2 }, + { ...wakeEvent(`parent`), generation: 3 }, + ] + const claims = [`one`, `two`, `three`].map(notification) + const firstClaimResponse = deferred() + const fetchMock = vi + .fn() + .mockImplementationOnce( + (_input: RequestInfo | URL) => firstClaimResponse.promise + ) + .mockImplementationOnce(async (_input: RequestInfo | URL) => + Response.json(claims[1]) + ) + .mockImplementationOnce(async (_input: RequestInfo | URL) => + Response.json(claims[2]) + ) + vi.stubGlobal(`fetch`, fetchMock) + const testRuntime = runtime() + const streamFactory = vi.fn(async () => ({ + offset: `42`, + async *jsonStream() { + yield* events + }, + closed: Promise.resolve(), + })) + + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: testRuntime, + heartbeatIntervalMs: 0, + eventHeartbeatThrottleMs: 0, + streamFactory, + }) + + runner.start() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + expect(testRuntime.dispatchWake).not.toHaveBeenCalled() + + firstClaimResponse.resolve(Response.json(claims[0])) + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + await waitFor(() => { + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(3) + }) + + await runner.stop() + }) + it(`skips stale wake events when claim returns no pending work`, async () => { const event = wakeEvent(`one`) const fetchMock = vi.fn(async (_input: RequestInfo | URL) => From 370a761ebad4d9a99e9e44af1bd55a116bd9186a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 13:57:45 -0600 Subject: [PATCH 051/105] Add changeset for pull wake delivery fix --- .changeset/fix-deferred-pull-wakes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-deferred-pull-wakes.md diff --git a/.changeset/fix-deferred-pull-wakes.md b/.changeset/fix-deferred-pull-wakes.md new file mode 100644 index 0000000000..e7f6ae5f5d --- /dev/null +++ b/.changeset/fix-deferred-pull-wakes.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Fix pull-wake delivery so multiple deferred wakes for the same entity stream are preserved and delivered instead of being overwritten while an earlier wake is still active. From 2cc6c26565f771e449175f32396181039cbe0bd2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 15:53:12 -0600 Subject: [PATCH 052/105] Reload wake registrations on runFinished cache miss --- .changeset/fix-deferred-pull-wakes.md | 3 +- packages/agents-server/src/entity-manager.ts | 22 +++- .../agents-server/test/wake-registry.test.ts | 110 ++++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-deferred-pull-wakes.md b/.changeset/fix-deferred-pull-wakes.md index e7f6ae5f5d..0d583fd2d9 100644 --- a/.changeset/fix-deferred-pull-wakes.md +++ b/.changeset/fix-deferred-pull-wakes.md @@ -1,5 +1,6 @@ --- '@electric-ax/agents-runtime': patch +'@electric-ax/agents-server': patch --- -Fix pull-wake delivery so multiple deferred wakes for the same entity stream are preserved and delivered instead of being overwritten while an earlier wake is still active. +Fix child wake delivery so multiple deferred wakes for the same entity stream are preserved and runFinished wake evaluation recovers from a stale server-side registration cache. diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index 200f518661..4573024ece 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -81,6 +81,18 @@ type SpawnPersistResult = [ PromiseSettledResult, ] type SpawnPersistJob = () => Promise + +function isRunFinishedWakeEvent(event: Record): boolean { + if (event.type !== `run`) return false + const value = event.value as Record | undefined + const headers = event.headers as Record | undefined + const status = value?.status as string | undefined + const operation = headers?.operation as string | undefined + return ( + operation === `update` && (status === `completed` || status === `failed`) + ) +} + type WriteTokenValidator = ( entity: ElectricAgentsEntity, token: string @@ -3292,11 +3304,11 @@ export class EntityManager { ): Promise { return await withSpan(`electric_agents.evaluateWakes`, async (span) => { span.setAttribute(ATTR.WAKE_SOURCE, sourceUrl) - const results = this.wakeRegistry.evaluate( - sourceUrl, - event, - this.tenantId - ) + let results = this.wakeRegistry.evaluate(sourceUrl, event, this.tenantId) + if (results.length === 0 && isRunFinishedWakeEvent(event)) { + await this.wakeRegistry.loadRegistrations() + results = this.wakeRegistry.evaluate(sourceUrl, event, this.tenantId) + } span.setAttribute(`electric_agents.wake.subscriber_count`, results.length) const settled = await Promise.allSettled( results.map((result) => this.deliverWakeResult(result)) diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index c879a124ca..c2fdf3a16e 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -694,6 +694,45 @@ describe(`Wake Registry`, () => { ).toBe(2) }) + it(`reloads wake registrations and retries runFinished evaluation on cache miss`, async () => { + const wakeResult = { + tenantId: `default`, + subscriberUrl: `/parent/p1`, + registrationDbId: 12, + sourceEventKey: `run-1`, + wakeMessage: { + source: `/child/c1`, + timeout: false, + changes: [{ collection: `runs`, kind: `update`, key: `run-1` }], + }, + runFinishedStatus: `completed`, + } + const wakeRegistry = { + evaluate: vi + .fn() + .mockReturnValueOnce([]) + .mockReturnValueOnce([wakeResult]), + loadRegistrations: vi.fn().mockResolvedValue(undefined), + } + const deliverWakeResult = vi.fn().mockResolvedValue(undefined) + const manager = { + tenantId: `default`, + wakeRegistry, + deliverWakeResult, + } as any + + await EntityManager.prototype.evaluateWakes.call(manager, `/child/c1`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(wakeRegistry.loadRegistrations).toHaveBeenCalledTimes(1) + expect(wakeRegistry.evaluate).toHaveBeenCalledTimes(2) + expect(deliverWakeResult).toHaveBeenCalledWith(wakeResult) + }) + it(`deliverWakeResult keys fanout by registrationDbId and sourceEventKey`, async () => { const appendIdempotent = vi.fn().mockResolvedValue(undefined) const manager = { @@ -968,6 +1007,77 @@ describe(`Wake Registry Integration`, () => { expect(subRes.status).toBeLessThan(300) } + it(`delivers concurrent runFinished wakes from multiple children to the same parent`, async () => { + const ts = Date.now() + const typeName = `wakemulti${ts}` + + const typeRes = await fetch(`${baseUrl}/_electric/entity-types`, { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ + name: typeName, + description: `multiple child runFinished wake test`, + }), + }) + expect(typeRes.status).toBe(201) + + const parentRes = await fetch( + `${baseUrl}/_electric/entities/${typeName}/parent-${ts}`, + { + method: `PUT`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({}), + } + ) + expect(parentRes.status).toBe(201) + const parent = (await parentRes.json()) as { + url: string + streams: { main: string } + } + + const manager = getElectricAgentsManager() + const children: Array<{ url: string; streams: { main: string } }> = [] + for (const childName of [`a`, `b`, `c`]) { + const childRes = await fetch( + `${baseUrl}/_electric/entities/${typeName}/child-${childName}-${ts}`, + { + method: `PUT`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ parent: parent.url }), + } + ) + expect(childRes.status).toBe(201) + const child = (await childRes.json()) as { + url: string + streams: { main: string } + } + children.push(child) + await manager.wakeRegistry.register({ + subscriberUrl: parent.url, + sourceUrl: child.url, + condition: `runFinished`, + oneShot: false, + }) + } + + await Promise.all( + children.map((child, index) => + manager.evaluateWakes(child.url, { + type: `run`, + key: `run-${index}`, + value: { status: `completed`, result: `done-${index}` }, + headers: { operation: `update` }, + }) + ) + ) + + const wakeEvents = await waitForWakeEvents(parent.streams.main, 3) + expect(wakeEvents).toHaveLength(3) + expect(new Set(wakeEvents.map((event) => event.value?.source))).toEqual( + new Set(children.map((child) => child.url)) + ) + }, 15_000) + it(`spawn with wake registers condition and delivers wake on child run completion`, async () => { const ts = Date.now() const typeName = `wakerf${ts}` From 6ac447b2b61dda11c78f32f88d1ef14b046dd1e2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 20:19:07 -0600 Subject: [PATCH 053/105] Design TanStack DB wake registry refactor --- ...-03-23-wake-registry-tanstack-db-design.md | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-23-wake-registry-tanstack-db-design.md diff --git a/docs/superpowers/specs/2026-03-23-wake-registry-tanstack-db-design.md b/docs/superpowers/specs/2026-03-23-wake-registry-tanstack-db-design.md new file mode 100644 index 0000000000..ffd8119e8e --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-wake-registry-tanstack-db-design.md @@ -0,0 +1,370 @@ +# Wake Registry TanStack DB Design + +## Context + +The current child wake delivery fix compensates for stale or incomplete wake registry state by reloading `wake_registrations` from Postgres when `runFinished` evaluation misses the in-memory cache. That fixes one symptom, but the underlying architecture still has two flawed state paths: + +1. Tests imperatively manage an in-memory cache. +2. Runtime manually syncs a Postgres table into that cache with `Shape` / `ShapeStream`. + +The desired replacement is to use TanStack DB as the wake registry state engine. + +## Goals + +- Make TanStack DB collection state the only in-memory source of wake registration rows. +- Use an Electric-backed collection in runtime so Postgres changes sync through Electric. +- Use a local-only collection only in unit tests. +- Use optimistic actions for every wake registry mutation. +- Use `queryOnce` for one-shot evaluation reads. +- Use `createEffect` for wake timeout timer side effects. +- Remove manual Shape subscription/cache code and the reload-on-miss workaround. + +## Non-goals + +- No `wake_registrations` schema migration. +- No custom TanStack DB adapter. +- No pull-wake runner changes. +- No changes to persisted wake row payloads. +- No broad `EntityManager` refactor beyond async wake evaluation call sites. + +## Architecture + +`WakeRegistry` owns one TanStack DB collection and one effect handle: + +```ts +private registrationsCollection: Collection | null +private registrationsEffect: { dispose(): Promise } | null +``` + +It no longer owns an authoritative `registrationCache`, `Shape`, `ShapeStream`, shape unsubscribe callback, or shape recovery promise. + +The existing runtime delivery state remains in `WakeRegistry` because it is not registration row state: + +- debounce timers +- debounce buffers +- latest debounced run status +- timeout timers +- timeout-delivered tracking +- timeout/debounce callbacks + +### Collection row shape + +Use camelCase fields internally: + +```ts +type WakeRegistrationCollectionRow = { + id: number + tenantId: string + subscriberUrl: string + sourceUrl: string + condition: WakeRegistration['condition'] + debounceMs: number + timeoutMs: number + oneShot: boolean + timeoutConsumed: boolean + includeResponse: boolean + manifestKey: string | null + createdAt: Date +} +``` + +`id` is both the TanStack DB collection key and the Postgres `wake_registrations.id`. Persisted rows already have a unique serial id, so the collection should use it directly rather than inventing a separate deterministic key. + +For newly registered rows, the action input must include an id before `onMutate` runs, because optimistic actions apply collection changes synchronously. Runtime can preallocate the id from the Postgres sequence and then insert with that explicit id in the action `mutationFn`. Local-only tests can allocate ids from an in-memory counter. + +## Runtime collection + +`startSync(electricUrl, electricSecret)` creates an Electric collection over `wake_registrations`: + +```ts +createCollection( + electricCollectionOptions({ + id: `wake-registrations:${tenantId ?? 'all'}`, + getKey: (row) => row.id, + schema, + shapeOptions: { + url: electricUrlWithPath(electricUrl, '/v1/shape').toString(), + params: { + table: 'wake_registrations', + ...(tenantId + ? { where: `tenant_id = ${sqlStringLiteral(tenantId)}` } + : {}), + columns: [ + 'id', + 'tenant_id', + 'subscriber_url', + 'source_url', + 'condition', + 'debounce_ms', + 'timeout_ms', + 'one_shot', + 'timeout_consumed', + 'include_response', + 'manifest_key', + 'created_at', + ], + replica: 'full', + ...(electricSecret ? { secret: electricSecret } : {}), + }, + parser: { + timestamptz: (value: string) => new Date(value), + }, + columnMapper: snakeCamelMapper(), + }, + onInsert, + onUpdate, + onDelete, + }) +) +``` + +Use Electric's built-in `snakeCamelMapper()` in `shapeOptions.columnMapper` to map snake_case Postgres columns to camelCase collection fields. `created_at` must still parse to `Date` on the sync path via the `timestamptz` parser. + +Startup fails if the collection cannot preload. Running with an empty registry after failed sync would silently drop terminal wake events. + +## Local-only collection + +Runtime requires Electric. There is no no-Electric Postgres fallback and no replacement for the old `loadRegistrations()` rebuild path. + +For unit tests only, create or replace a local-only collection: + +```ts +createCollection( + localOnlyCollectionOptions({ + id: `wake-registrations-local:${tenantId ?? 'all'}`, + getKey: (row) => row.id, + initialData: [], + }) +) +``` + +Unit tests should not need Postgres or Electric just to exercise registry logic. They can start with an empty local-only collection and create rows through the same optimistic actions used by runtime. + +Integration tests that need real persistence or cross-process sync should use Postgres + Electric and `startSync(...)`, not a local collection seeded from Postgres. Server startup without an Electric URL should fail rather than silently rebuilding local state from Postgres. + +## Mutation model + +All `WakeRegistry` mutations use `createOptimisticAction`. Public registry methods do not call `collection.insert`, `collection.update`, or `collection.delete` directly. + +Actions: + +- `registerAction` +- `unregisterByManifestKeyAction` +- `unregisterBySubscriberAction` +- `unregisterBySourceAction` +- `unregisterBySubscriberAndSourceAction` +- `markTimeoutConsumedAction` +- `consumeMatchedRegistrationsAction` or equivalent one-shot cleanup action + +Each action has two responsibilities: + +1. `onMutate`: synchronously apply optimistic changes to the collection. +2. `mutationFn`: persist the intent to Postgres in runtime mode and return/await a txid. In local-only mode, this is a no-op or minimal test hook. + +### Register + +`register(reg)` resolves the tenant, normalizes defaults, invokes `registerAction`, and awaits `tx.isPersisted.promise`. + +`registerAction.onMutate` inserts or upserts the normalized row into the collection. + +`registerAction.mutationFn` performs the existing insert: + +- insert into `wake_registrations` +- `ON CONFLICT DO NOTHING` +- insert with the preallocated id +- if conflicted, fetch the existing row and transaction id + +The transaction id comes from the same Postgres transaction: + +```sql +SELECT pg_current_xact_id()::xid::text AS txid +``` + +The Electric collection should hold optimistic state until that txid syncs back. Because the action preallocates the numeric id, `WakeEvalResult.registrationDbId` can use the collection key directly. + +### Bulk unregister actions + +Bulk unregister methods are domain intents, not repeated ad-hoc row deletes. Each uses one optimistic action: + +- `unregisterByManifestKey(subscriberUrl, manifestKey, tenantId?)` +- `unregisterBySubscriber(subscriberUrl, tenantId?)` +- `unregisterBySource(sourceUrl, tenantId?)` +- `unregisterBySubscriberAndSource(subscriberUrl, sourceUrl, tenantId?)` + +Each action’s `onMutate` deletes all currently matching collection rows synchronously. + +Each action’s `mutationFn` runs one SQL/Drizzle delete statement for the same predicate and returns/awaits the resulting txid. + +Public methods await `tx.isPersisted.promise` because callers expect unregister completion. + +### Timeout consumed + +When a wake timeout is delivered or a matching event clears a timeout, `markTimeoutConsumedAction` updates the row: + +```ts +draft.timeoutConsumed = true +``` + +Its runtime `mutationFn` persists `timeout_consumed = true` and returns/awaits a txid. + +Current fire-and-forget semantics can stay fire-and-forget, but failures must be logged with tenant id, registration id, source url, and subscriber url. + +### One-shot cleanup + +When `evaluate()` matches one-shot registrations, it invokes a cleanup action that deletes the matched rows optimistically before `evaluate()` returns. This prevents immediate repeated evaluation from double-delivering. + +Persistence can remain fire-and-forget if the current path is fire-and-forget, with logged failures. + +## Evaluation reads + +`evaluate()` becomes async: + +```ts +async evaluate( + sourceUrl: string, + event: Record, + tenantId?: string +): Promise> +``` + +It reads current registrations with `queryOnce`: + +```ts +const regs = await queryOnce((q) => + q + .from({ reg: this.requireRegistrationsCollection() }) + .where(({ reg }) => + and(eq(reg.tenantId, resolvedTenantId), eq(reg.sourceUrl, sourceUrl)) + ) +) +``` + +`queryOnce` is async because a source collection may need preload. In the registry’s normal runtime path the collection is already loaded, so this should usually be a cheap microtick async boundary. + +The existing condition matching and wake result construction can remain mostly unchanged: + +- `runFinished` matches terminal run updates +- collection-change conditions match collection names and ops +- immediate matches return `WakeEvalResult[]` +- debounced matches append to debounce buffers +- timeout timers are cleared when the expected event arrives first + +No code path should reload all registrations from Postgres on an evaluation miss. + +## Timeout wake side effects + +Wake registration `timeoutMs` measures how long a registration has been waiting for its matching source event. If that event does not arrive before `createdAt + timeoutMs`, the subscriber receives a timeout wake: + +```ts +wakeMessage: { + source: reg.sourceUrl, + timeout: true, + changes: [], +} +``` + +Use `createEffect` over the registration collection to manage these Node timers: + +```ts +createEffect({ + query: (q) => q.from({ reg: registrationsCollection }), + skipInitial: false, + onEnter: ({ value }) => syncTimeoutTimer(value), + onUpdate: ({ value }) => syncTimeoutTimer(value), + onExit: ({ value }) => clearRegistrationState(value), +}) +``` + +Timer behavior: + +- On enter/update, if `timeoutMs <= 0` or `timeoutConsumed`, ensure no timer is active. +- If `createdAt + timeoutMs` is in the future, schedule one Node timer. +- If the deadline has already passed and the timeout has not been delivered, deliver the timeout wake once if a tenant callback is registered. +- On exit, clear timeout/debounce state and remove the row from `timeoutDelivered`. + +When a timeout fires, it delivers the existing timeout wake result and invokes `markTimeoutConsumedAction`. + +## Error handling + +- Startup fails if the Electric collection preload fails. +- Local-only unit-test initialization should not perform DB I/O. +- Runtime startup without an Electric URL fails clearly; there is no Postgres-seeded fallback. +- Awaited public mutation methods reject if persistence fails; TanStack DB rolls back optimistic state. +- Fire-and-forget action failures are logged with enough context to diagnose the row and intent. +- Electric collection owns Shape lifecycle and retry behavior. `WakeRegistry` should not reimplement Shape recovery. +- Evaluation misses are not retried by reloading all rows from Postgres. + +## Call-site changes + +- Update every `wakeRegistry.evaluate(...)` call to `await wakeRegistry.evaluate(...)`. +- Remove `EntityManager.evaluateWakes()` retry/reload-on-cache-miss logic. +- Remove `loadRegistrations()` and update startup paths to require `startSync(...)` for runtime. +- Keep `flushDebounce(...)` synchronous unless it starts reading registration rows. It currently only drains debounce buffers. + +## Test plan + +Update existing wake registry unit tests: + +- Convert direct `registry.evaluate(...)` assertions to `await registry.evaluate(...)`. +- Preserve coverage for: + - tenant scoping + - `runFinished` matching + - collection-change matching + - debounce coalescing + - timeout delivery + - timeout not consumed before callback registration + - one-shot cleanup + - unregister variants + - `includeResponse` + - concurrent child `runFinished` delivery + +Remove or replace tests that only cover manual Shape cache machinery: + +- direct `applyShapeMessage` tests +- malformed Shape message handling +- custom Shape recovery behavior + +Add TanStack DB-specific tests: + +- `registerAction` preallocates an id, inserts into the local collection, and `evaluate()` sees it through `queryOnce`. +- Each bulk unregister action removes all matching rows optimistically. +- One-shot cleanup removes matched rows before a second immediate evaluation. +- `markTimeoutConsumedAction` prevents repeat timeout delivery. +- Local-only unit tests require no Postgres, Electric, or Electric mock. + +Add or keep an integration-style Electric test if practical: + +- registry A registers a wake through Postgres/Electric collection +- registry B observes the row through Electric collection +- a terminal child run event evaluated by registry B produces the parent wake without any reload-on-miss fallback + +## Files likely touched + +- `packages/agents-server/package.json` + - add `@tanstack/db` + - add `@tanstack/electric-db-collection` +- `packages/agents-server/src/wake-registry.ts` + - main refactor +- `packages/agents-server/src/entity-manager.ts` + - await async evaluation and remove reload-on-miss fallback +- `packages/agents-server/test/wake-registry.test.ts` + - update async assertions and action expectations +- `packages/agents-server/test/wake-registry-sync.test.ts` + - remove/replace manual Shape tests with Postgres + Electric integration coverage where needed +- `.changeset/*` + - update package changeset if implementation changes dependencies or behavior + +## Acceptance criteria + +- `WakeRegistry` has no authoritative `Map` cache. +- `WakeRegistry` does not instantiate `Shape` or `ShapeStream` directly. +- Runtime registration state comes from an Electric TanStack DB collection. +- Unit-test registration state comes from a local-only TanStack DB collection. +- Runtime startup without Electric fails instead of calling `loadRegistrations()`. +- All public registry mutations invoke optimistic actions. +- Bulk unregister operations are one action per domain intent. +- Runtime mutation handlers return/await real Postgres txids. +- Wake evaluation uses `queryOnce` over the collection. +- Timeout wake timers are driven by `createEffect` over collection rows. +- `EntityManager` no longer reloads registrations on terminal evaluation miss. +- Existing wake registry behavior tests pass after async/action updates. From 65b084a9687e975af0c4f768d248d361eb3e2a9a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 10:19:33 -0600 Subject: [PATCH 054/105] Add local TanStack wake registry foundation --- .../2026-06-19-wake-registry-tanstack-db.md | 1525 +++++++++++++++++ packages/agents-server/package.json | 2 + packages/agents-server/src/entity-manager.ts | 12 +- packages/agents-server/src/wake-registry.ts | 242 ++- .../test/pg-sync-wake-delivery.test.ts | 36 +- .../test/wake-registry-sync.test.ts | 4 +- .../agents-server/test/wake-registry.test.ts | 84 +- pnpm-lock.yaml | 25 +- 8 files changed, 1878 insertions(+), 52 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-19-wake-registry-tanstack-db.md diff --git a/docs/superpowers/plans/2026-06-19-wake-registry-tanstack-db.md b/docs/superpowers/plans/2026-06-19-wake-registry-tanstack-db.md new file mode 100644 index 0000000000..fc67c1a38f --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-wake-registry-tanstack-db.md @@ -0,0 +1,1525 @@ +# Wake Registry TanStack DB Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `WakeRegistry`'s manual Postgres row cache and ShapeStream sync with TanStack DB collections, optimistic actions, `queryOnce`, and collection effects. + +**Architecture:** `WakeRegistry` will own one TanStack DB collection of `wake_registrations` rows. Runtime uses `electricCollectionOptions` over the Postgres table; unit tests use a local-only collection. All mutations go through `createOptimisticAction`; each action persists to Postgres in its `mutationFn`, then awaits the Electric collection txid. Evaluation reads with `queryOnce`, and timeout wake timers are driven by `createEffect` over collection rows. + +**Tech Stack:** TypeScript, Vitest, Drizzle ORM, Postgres, Electric `@electric-sql/client`, TanStack DB `@tanstack/db`, TanStack Electric collection `@tanstack/electric-db-collection`. + +## Global Constraints + +- Runtime requires Electric; there is no no-Electric Postgres fallback. +- No `wake_registrations` schema migration. +- No custom TanStack DB adapter. +- No pull-wake runner changes. +- No changes to persisted wake row payloads. +- All public `WakeRegistry` mutations invoke optimistic actions. +- Bulk unregister operations are one action per domain intent. +- Wake evaluation uses `queryOnce` over the collection. +- Timeout wake timers are driven by `createEffect` over collection rows. +- `WakeRegistry` must not instantiate `Shape` or `ShapeStream` directly. +- Before running package typecheck/tests in this worktree, run `pnpm install` from the repository root if workspace links are missing. + +--- + +## File Structure + +- `packages/agents-server/package.json` + - Add runtime dependencies: `@tanstack/db` and `@tanstack/electric-db-collection`. +- `packages/agents-server/src/wake-registry.ts` + - Main refactor. Owns TanStack DB collection, actions, query-based evaluation, and timeout effects. +- `packages/agents-server/src/entity-manager.ts` + - Await async `wakeRegistry.evaluate(...)`, remove reload-on-miss fallback, and require `startSync(...)` for runtime startup. +- `packages/agents-server/src/host.ts` + - Require `electricUrl` for host startup; remove `loadRegistrations()` fallback. +- `packages/agents-server/src/standalone-runtime.ts` + - Keep passing Electric URL into `rebuildWakeRegistry(...)`; missing Electric URL should fail through `EntityManager.rebuildWakeRegistry(...)`. +- `packages/agents-server/test/wake-registry.test.ts` + - Convert unit tests to local-only collection startup and async evaluation. +- `packages/agents-server/test/wake-registry-sync.test.ts` + - Replace manual ShapeStream mock tests with Postgres + Electric collection integration tests or remove if covered elsewhere. +- `packages/agents-server/test/server-start.test.ts` + - Update mocked `WakeRegistry` API and assert missing Electric URL fails when applicable. +- `.changeset/fix-deferred-pull-wakes.md` + - Update if package dependencies/behavior summary changes. + +--- + +### Task 1: Add TanStack DB Dependencies and Local Collection Foundation + +**Files:** + +- Modify: `packages/agents-server/package.json:46-67` +- Modify: `packages/agents-server/src/wake-registry.ts:1-180` +- Test: `packages/agents-server/test/wake-registry.test.ts:32-61` + +**Interfaces:** + +- Consumes: existing `WakeRegistration`, `WakeEvalResult`, `DrizzleDB`, `DEFAULT_TENANT_ID`. +- Produces: + - `interface WakeRegistrationCollectionRow` + - `WakeRegistry.startLocalForTests(): Promise` + - `WakeRegistry.evaluate(...): Promise>` + - `WakeRegistry.requireCollection(): Collection` + +- [ ] **Step 1: Add dependencies** + +Add these dependencies to `packages/agents-server/package.json` under `dependencies`: + +```json +"@tanstack/db": "^0.6.7", +"@tanstack/electric-db-collection": "^0.3.5" +``` + +Run from repo root: + +```bash +pnpm install +``` + +Expected: lockfile updates cleanly and package workspace links are materialized. + +- [ ] **Step 2: Write failing local-collection evaluation test** + +In `packages/agents-server/test/wake-registry.test.ts`, add this test near the first simple `Wake Registry` tests. This test intentionally uses a local collection helper that does not exist yet: + +```ts +it(`evaluates registrations from local TanStack DB collection`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + }) + + const results = await registry.evaluate(`/child/c1`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(1) + expect(results[0]!.subscriberUrl).toBe(`/parent/p1`) + expect(results[0]!.registrationDbId).toBe(1) + expect(results[0]!.sourceEventKey).toBe(`update:run-1`) +}) +``` + +- [ ] **Step 3: Run the focused test to verify it fails** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "evaluates registrations from local TanStack DB collection" --run +``` + +Expected: FAIL with TypeScript/runtime error that `startLocalForTests` does not exist or `evaluate` is not awaitable yet. + +- [ ] **Step 4: Add collection foundation alongside the legacy runtime cache** + +Task 1 is an incremental local-only foundation. Keep the existing `registrationCache`, `ShapeStream`, `startSync(...)`, and runtime cache code in place until Task 4 removes them. In `packages/agents-server/src/wake-registry.ts`, add TanStack DB imports at the top while keeping imports that are still needed by the legacy runtime path: + +```ts +import { + and as dbAnd, + createCollection, + createOptimisticAction, + eq as dbEq, + localOnlyCollectionOptions, + queryOnce, +} from '@tanstack/db' +import { and, eq } from 'drizzle-orm' +import { wakeRegistrations } from './db/schema.js' +import { DEFAULT_TENANT_ID } from './tenant.js' +import type { Collection } from '@tanstack/db' +import type { DrizzleDB } from './db/index.js' +``` + +Do not remove the existing `ShapeStream`, `serverLog`, `electricUrlWithPath`, `Row`, or `Value` imports in Task 1 if they are still used by the legacy runtime path. Task 4 removes manual ShapeStream runtime sync. + +Add this row type and local test state after `WakeDebounceCallback`: + +```ts +interface WakeRegistrationCollectionRow { + id: number + tenantId: string + subscriberUrl: string + sourceUrl: string + condition: WakeRegistration[`condition`] + debounceMs: number + timeoutMs: number + oneShot: boolean + timeoutConsumed: boolean + includeResponse: boolean + manifestKey: string | null + createdAt: Date +} + +type WakeRegistryMode = `unstarted` | `local-test` | `electric` +``` + +In `WakeRegistry`, add these fields next to the existing legacy cache/sync fields. Do not remove the old cache fields in Task 1 because the existing runtime `startSync(...)` path still uses them until Task 4. + +```ts +private registrationsCollection: Collection | null = null +private mode: WakeRegistryMode = `unstarted` +private nextLocalId = 1 +``` + +Add these helpers inside `WakeRegistry`: + +```ts +private requireCollection(): Collection { + if (!this.registrationsCollection) { + throw new Error(`WakeRegistry has not been started`) + } + return this.registrationsCollection +} + +async startLocalForTests(): Promise { + if (this.registrationsCollection) return + this.mode = `local-test` + this.registrationsCollection = createCollection( + localOnlyCollectionOptions({ + id: `wake-registrations-local:${this.tenantId ?? `all`}`, + getKey: (row) => row.id, + initialData: [], + }) + ) + await this.registrationsCollection.preload() +} + +private allocateLocalId(): number { + return this.nextLocalId++ +} + +private normalizeRegistration( + reg: WakeRegistration, + tenantId: string, + id: number +): WakeRegistrationCollectionRow { + return { + id, + tenantId, + subscriberUrl: reg.subscriberUrl, + sourceUrl: reg.sourceUrl, + condition: reg.condition, + debounceMs: reg.debounceMs ?? 0, + timeoutMs: reg.timeoutMs ?? 0, + oneShot: reg.oneShot, + timeoutConsumed: false, + includeResponse: reg.includeResponse !== false, + manifestKey: reg.manifestKey ?? null, + createdAt: new Date(), + } +} +``` + +- [ ] **Step 5: Implement local register action and async queryOnce evaluation** + +Add this action field and initializer inside `WakeRegistry`. The local action is enough for this task; runtime persistence is added later. + +```ts +private registerAction = createOptimisticAction({ + onMutate: (row) => { + this.requireCollection().insert(row) + }, + mutationFn: async () => { + if (this.mode === `local-test`) return + throw new Error(`WakeRegistry registerAction runtime persistence is not initialized`) + }, +}) +``` + +Replace `register(reg: WakeRegistration): Promise` with this minimal local implementation: + +```ts +async register(reg: WakeRegistration): Promise { + const tenantId = this.resolveTenantId(reg.tenantId) + const id = this.mode === `local-test` ? this.allocateLocalId() : this.allocateLocalId() + const tx = this.registerAction(this.normalizeRegistration(reg, tenantId, id)) + await tx.isPersisted.promise +} +``` + +Replace the start of `evaluate(...)` with an async collection query. Keep the existing matching/debounce/result code, but iterate over `regs` from `queryOnce` instead of the old `registrationCache` array: + +```ts +async evaluate( + sourceUrl: string, + event: Record, + tenantId?: string +): Promise> { + const resolvedTenantId = this.resolveTenantId(tenantId) + const regs = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd(dbEq(reg.tenantId, resolvedTenantId), dbEq(reg.sourceUrl, sourceUrl)) + ) + ) + if (regs.length === 0) return [] + + const results: Array = [] + const oneShotRows: Array = [] + + for (const reg of regs) { + const match = this.matchCondition(reg, event) + if (!match) continue + + const timerKey = this.registrationKey(reg) + const timeoutTimer = this.timeoutTimers.get(timerKey) + if (timeoutTimer) { + clearTimeout(timeoutTimer) + this.timeoutTimers.delete(timerKey) + void this.markTimeoutConsumed(reg.id, reg.tenantId) + } + + if (reg.debounceMs > 0) { + const buffer = this.debounceBuffers.get(timerKey) ?? [] + buffer.push(match.change) + this.debounceBuffers.set(timerKey, buffer) + if (match.runFinishedStatus) { + this.debounceRunStatus.set(timerKey, match.runFinishedStatus) + } + const existing = this.debounceTimers.get(timerKey) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + this.debounceTimers.delete(timerKey) + const flushed = this.debounceBuffers.get(timerKey) + if (flushed && flushed.length > 0) { + this.debounceBuffers.delete(timerKey) + const runStatus = this.debounceRunStatus.get(timerKey) + this.debounceRunStatus.delete(timerKey) + this.deliverDebounce({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: flushed[flushed.length - 1]!.key, + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: flushed, + }, + runFinishedStatus: runStatus, + includeResponse: reg.includeResponse, + }) + } + }, reg.debounceMs) + this.debounceTimers.set(timerKey, timer) + } else { + results.push({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: wakeSourceEventId(event), + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: [match.change], + }, + runFinishedStatus: match.runFinishedStatus, + includeResponse: reg.includeResponse, + }) + } + + if (reg.oneShot) oneShotRows.push(reg) + } + + for (const reg of oneShotRows) { + this.clearRegistrationState(reg) + this.timeoutDelivered.delete(reg.id) + this.requireCollection().delete(reg.id) + } + + return results +} +``` + +Update `registrationKey`, `clearRegistrationState`, `matchCondition`, and timeout helper parameter types from `CachedWakeRegistration` to `WakeRegistrationCollectionRow` where needed: + +```ts +private registrationKey(reg: WakeRegistrationCollectionRow): string +private clearRegistrationState(reg: WakeRegistrationCollectionRow): void +private matchCondition(reg: WakeRegistrationCollectionRow, event: Record): ... +``` + +- [ ] **Step 6: Run the focused test to verify it passes** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "evaluates registrations from local TanStack DB collection" --run +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/agents-server/package.json pnpm-lock.yaml packages/agents-server/src/wake-registry.ts packages/agents-server/test/wake-registry.test.ts +git commit -m "Introduce TanStack DB wake registry collection" +``` + +--- + +### Task 2: Convert All Unit-Test Registry Mutations to Optimistic Actions + +**Files:** + +- Modify: `packages/agents-server/src/wake-registry.ts:298-530, 730-910` +- Modify: `packages/agents-server/test/wake-registry.test.ts` + +**Interfaces:** + +- Consumes: `WakeRegistrationCollectionRow`, `WakeRegistry.requireCollection()`, async `WakeRegistry.evaluate(...)`. +- Produces: + - `unregisterByManifestKeyAction` + - `unregisterBySubscriberAction` + - `unregisterBySourceAction` + - `unregisterBySubscriberAndSourceAction` + - `markTimeoutConsumedAction` + - `consumeMatchedRegistrationsAction` + +- [ ] **Step 1: Write failing tests for bulk unregister actions** + +Add these tests to `packages/agents-server/test/wake-registry.test.ts` near existing unregister tests: + +```ts +it(`unregisterBySource removes all matching local collection rows`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/a`, + sourceUrl: `/source/1`, + condition: { on: `change` }, + oneShot: false, + }) + await registry.register({ + subscriberUrl: `/parent/b`, + sourceUrl: `/source/1`, + condition: { on: `change` }, + oneShot: false, + }) + await registry.register({ + subscriberUrl: `/parent/c`, + sourceUrl: `/source/2`, + condition: { on: `change` }, + oneShot: false, + }) + + await registry.unregisterBySource(`/source/1`) + + expect( + await registry.evaluate(`/source/1`, { + type: `texts`, + key: `t1`, + value: {}, + headers: { operation: `insert` }, + }) + ).toHaveLength(0) + expect( + await registry.evaluate(`/source/2`, { + type: `texts`, + key: `t2`, + value: {}, + headers: { operation: `insert` }, + }) + ).toHaveLength(1) +}) + +it(`oneShot match removes row before a second immediate evaluation`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: true, + }) + + const event = { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + } + + expect(await registry.evaluate(`/child/c1`, event)).toHaveLength(1) + expect(await registry.evaluate(`/child/c1`, event)).toHaveLength(0) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "unregisterBySource removes|oneShot match removes" --run +``` + +Expected: FAIL if unregisters or one-shot cleanup still use old cache/DB paths. + +- [ ] **Step 3: Add query helpers for matching rows** + +Add these helpers to `WakeRegistry`: + +```ts +private async rowsByPredicate( + predicate: (row: WakeRegistrationCollectionRow) => boolean +): Promise> { + const rows = await queryOnce((q) => q.from({ reg: this.requireCollection() })) + return rows.filter(predicate) +} + +private async rowsForSource( + tenantId: string, + sourceUrl: string +): Promise> { + return await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd(dbEq(reg.tenantId, tenantId), dbEq(reg.sourceUrl, sourceUrl)) + ) + ) +} +``` + +This task may use `rowsByPredicate` for local actions because action `onMutate` must be synchronous. Capture the rows before invoking the action and pass their ids into the action input. + +- [ ] **Step 4: Implement unregister optimistic actions** + +Add action input types near `WakeRegistryMode`: + +```ts +type DeleteRowsInput = { + rows: Array + persist: + | { + kind: `manifestKey` + tenantId: string + subscriberUrl: string + manifestKey: string + } + | { + kind: `subscriber` + tenantId: string + subscriberUrl: string + } + | { + kind: `source` + tenantId: string + sourceUrl: string + } + | { + kind: `subscriberAndSource` + tenantId: string + subscriberUrl: string + sourceUrl: string + } + | { + kind: `oneShot` + } +} +``` + +Add one shared delete action: + +```ts +private deleteRowsAction = createOptimisticAction({ + onMutate: ({ rows }) => { + const collection = this.requireCollection() + for (const row of rows) { + this.clearRegistrationState(row) + this.timeoutDelivered.delete(row.id) + collection.delete(row.id) + } + }, + mutationFn: async ({ persist }) => { + if (this.mode === `local-test` || persist.kind === `oneShot`) return + throw new Error(`WakeRegistry deleteRowsAction runtime persistence is not initialized`) + }, +}) +``` + +Replace unregister methods with wrappers that query rows, invoke the action, and await persistence: + +```ts +async unregisterBySource(sourceUrl: string, tenantId?: string): Promise { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsForSource(resolvedTenantId, sourceUrl) + const tx = this.deleteRowsAction({ + rows, + persist: { kind: `source`, tenantId: resolvedTenantId, sourceUrl }, + }) + await tx.isPersisted.promise +} +``` + +Implement the other three unregister methods with the same pattern: + +```ts +async unregisterBySubscriber(subscriberUrl: string, tenantId?: string): Promise { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => row.tenantId === resolvedTenantId && row.subscriberUrl === subscriberUrl + ) + const tx = this.deleteRowsAction({ + rows, + persist: { kind: `subscriber`, tenantId: resolvedTenantId, subscriberUrl }, + }) + await tx.isPersisted.promise +} + +async unregisterByManifestKey( + subscriberUrl: string, + manifestKey: string, + tenantId?: string +): Promise { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => + row.tenantId === resolvedTenantId && + row.subscriberUrl === subscriberUrl && + row.manifestKey === manifestKey + ) + const tx = this.deleteRowsAction({ + rows, + persist: { kind: `manifestKey`, tenantId: resolvedTenantId, subscriberUrl, manifestKey }, + }) + await tx.isPersisted.promise +} + +async unregisterBySubscriberAndSource( + subscriberUrl: string, + sourceUrl: string, + tenantId?: string +): Promise { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => + row.tenantId === resolvedTenantId && + row.subscriberUrl === subscriberUrl && + row.sourceUrl === sourceUrl + ) + const tx = this.deleteRowsAction({ + rows, + persist: { kind: `subscriberAndSource`, tenantId: resolvedTenantId, subscriberUrl, sourceUrl }, + }) + await tx.isPersisted.promise +} +``` + +- [ ] **Step 5: Implement mark-timeout-consumed action** + +Add this action: + +```ts +private markTimeoutConsumedAction = createOptimisticAction<{ + row: WakeRegistrationCollectionRow +}>({ + onMutate: ({ row }) => { + this.requireCollection().update(row.id, (draft) => { + draft.timeoutConsumed = true + }) + }, + mutationFn: async () => { + if (this.mode === `local-test`) return + throw new Error(`WakeRegistry markTimeoutConsumedAction runtime persistence is not initialized`) + }, +}) +``` + +Replace `markTimeoutConsumed(dbId, tenantId)` with: + +```ts +private async markTimeoutConsumed(dbId: number, tenantId: string): Promise { + const row = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => dbAnd(dbEq(reg.tenantId, tenantId), dbEq(reg.id, dbId))) + .findOne() + ) + if (!row) return + const tx = this.markTimeoutConsumedAction({ row }) + await tx.isPersisted.promise +} +``` + +- [ ] **Step 6: Route one-shot cleanup through the shared action** + +In `evaluate(...)`, replace direct `collection.delete(...)` one-shot cleanup with: + +```ts +if (oneShotRows.length > 0) { + const tx = this.deleteRowsAction({ + rows: oneShotRows, + persist: { kind: `oneShot` }, + }) + void tx.isPersisted.promise.catch((error) => { + console.warn(`[wake-registry] failed to persist one-shot cleanup:`, error) + }) +} +``` + +- [ ] **Step 7: Run focused tests** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "unregisterBySource removes|oneShot match removes|evaluates registrations from local" --run +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/agents-server/src/wake-registry.ts packages/agents-server/test/wake-registry.test.ts +git commit -m "Move wake registry mutations to optimistic actions" +``` + +--- + +### Task 3: Drive Timeout Wakes with TanStack DB Effects + +**Files:** + +- Modify: `packages/agents-server/src/wake-registry.ts:104-177, 562-728` +- Modify: `packages/agents-server/test/wake-registry.test.ts` + +**Interfaces:** + +- Consumes: `WakeRegistrationCollectionRow`, local collection startup, optimistic timeout action. +- Produces: + - `private registrationsEffect: { dispose(): Promise } | null` + - `private startRegistrationEffect(): void` + - timeout timers synchronized from collection rows via `createEffect` + +- [ ] **Step 1: Write failing timeout effect tests** + +Add this test near existing timeout tests: + +```ts +it(`timeout effect delivers timeout wake once and marks row consumed`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + const delivered: Array = [] + registry.setTimeoutCallback((result) => delivered.push(result)) + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + timeoutMs: 25, + }) + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(delivered).toHaveLength(1) + expect(delivered[0]!.wakeMessage.timeout).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(delivered).toHaveLength(1) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "timeout effect delivers" --run +``` + +Expected: FAIL until the collection effect starts timers from row enter/update events. + +- [ ] **Step 3: Import `createEffect` and add effect field** + +Update imports from `@tanstack/db`: + +```ts +import { + and as dbAnd, + createCollection, + createEffect, + createOptimisticAction, + eq as dbEq, + localOnlyCollectionOptions, + queryOnce, +} from '@tanstack/db' +``` + +Add field: + +```ts +private registrationsEffect: { dispose(): Promise } | null = null +``` + +- [ ] **Step 4: Start the effect after collection preload** + +Add this method: + +```ts +private startRegistrationEffect(): void { + if (this.registrationsEffect) return + const collection = this.requireCollection() + this.registrationsEffect = createEffect({ + query: (q) => q.from({ reg: collection }), + skipInitial: false, + onEnter: ({ value }) => { + this.syncTimeoutTimer(value) + }, + onUpdate: ({ value }) => { + this.syncTimeoutTimer(value) + }, + onExit: ({ value }) => { + this.clearRegistrationState(value) + this.timeoutDelivered.delete(value.id) + }, + }) +} +``` + +Call it at the end of `startLocalForTests()` after `await this.registrationsCollection.preload()`: + +```ts +this.startRegistrationEffect() +``` + +- [ ] **Step 5: Update timeout helpers to collection row type** + +Replace old timeout helper signatures with: + +```ts +private startTimeoutTimer(reg: WakeRegistrationCollectionRow): void +private startTimeoutTimerWithDuration(reg: WakeRegistrationCollectionRow, durationMs: number): void +private syncTimeoutTimer(reg: WakeRegistrationCollectionRow): void +private deliverTimeoutForRegistration(reg: WakeRegistrationCollectionRow): void +private timeoutWakeResult(reg: WakeRegistrationCollectionRow): WakeEvalResult +``` + +Use `reg.id` instead of `dbId` inside these helpers. The key bodies should be: + +```ts +private startTimeoutTimer(reg: WakeRegistrationCollectionRow): void { + if (reg.timeoutMs <= 0) return + this.startTimeoutTimerWithDuration(reg, reg.timeoutMs) +} + +private startTimeoutTimerWithDuration( + reg: WakeRegistrationCollectionRow, + durationMs: number +): void { + const timerKey = this.registrationKey(reg) + const timer = setTimeout(() => { + this.timeoutTimers.delete(timerKey) + this.deliverTimeoutForRegistration(reg) + }, durationMs) + this.timeoutTimers.set(timerKey, timer) +} + +private syncTimeoutTimer(reg: WakeRegistrationCollectionRow): void { + const timerKey = this.registrationKey(reg) + + if (reg.timeoutConsumed || reg.timeoutMs <= 0) { + this.clearTimeoutState(timerKey) + return + } + + if (this.timeoutTimers.has(timerKey)) return + + const remaining = reg.createdAt.getTime() + reg.timeoutMs - Date.now() + if (remaining > 0) { + this.startTimeoutTimerWithDuration(reg, remaining) + return + } + + if (this.timeoutDelivered.has(reg.id)) return + this.deliverTimeoutForRegistration(reg) +} + +private deliverTimeoutForRegistration(reg: WakeRegistrationCollectionRow): void { + if (this.deliverTimeout(this.timeoutWakeResult(reg))) { + this.timeoutDelivered.add(reg.id) + void this.markTimeoutConsumed(reg.id, reg.tenantId).catch((error) => { + console.warn(`[wake-registry] failed to mark timeout consumed:`, error) + }) + } +} + +private timeoutWakeResult(reg: WakeRegistrationCollectionRow): WakeEvalResult { + return { + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: `timeout`, + wakeMessage: { + source: reg.sourceUrl, + timeout: true, + changes: [], + }, + } +} +``` + +- [ ] **Step 6: Stop effect and clear timers on shutdown** + +Replace `stopSync()` with a general cleanup that still keeps the public method name for current call sites: + +```ts +async stopSync(): Promise { + await this.registrationsEffect?.dispose() + this.registrationsEffect = null + this.registrationsCollection = null + this.mode = `unstarted` + this.resetRuntimeState() +} + +private resetRuntimeState(): void { + for (const timer of this.debounceTimers.values()) clearTimeout(timer) + this.debounceTimers.clear() + this.debounceBuffers.clear() + this.debounceRunStatus.clear() + for (const timer of this.timeoutTimers.values()) clearTimeout(timer) + this.timeoutTimers.clear() + this.timeoutDelivered.clear() +} +``` + +- [ ] **Step 7: Run timeout tests** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts -t "timeout" --run +``` + +Expected: timeout tests PASS or fail only where assertions still assume old synchronous cache behavior. + +- [ ] **Step 8: Commit** + +```bash +git add packages/agents-server/src/wake-registry.ts packages/agents-server/test/wake-registry.test.ts +git commit -m "Drive wake timeouts from registry collection effects" +``` + +--- + +### Task 4: Implement Runtime Electric Collection and Postgres Txid Mutation Handlers + +**Files:** + +- Modify: `packages/agents-server/src/wake-registry.ts:1-360` +- Test: `packages/agents-server/test/wake-registry-sync.test.ts` + +**Interfaces:** + +- Consumes: local collection/action architecture from Tasks 1-3. +- Produces: + - Runtime `WakeRegistry.startSync(electricUrl, electricSecret?)` + - Electric collection with `snakeCamelMapper()` + - runtime `registerAction`, `deleteRowsAction`, `markTimeoutConsumedAction` persistence + - no direct `Shape` or `ShapeStream` usage in `WakeRegistry` + +- [ ] **Step 1: Write failing integration test for Electric-backed registry sync** + +Replace `packages/agents-server/test/wake-registry-sync.test.ts` with this integration test skeleton. It uses the existing managed test backend. + +```ts +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { createDb } from '../src/db' +import { wakeRegistrations } from '../src/db/schema' +import { WakeRegistry } from '../src/wake-registry' +import { + TEST_ELECTRIC_URL, + TEST_POSTGRES_URL, + resetElectricAgentsTestBackend, +} from './test-backend' + +const db = createDb(TEST_POSTGRES_URL) + +describe(`WakeRegistry Electric collection sync`, () => { + beforeAll(async () => { + await resetElectricAgentsTestBackend() + }) + + afterAll(async () => { + await db.end?.() + }) + + it(`syncs a registered wake through Postgres and Electric`, async () => { + const registry = new WakeRegistry(db as any) + await registry.startSync(TEST_ELECTRIC_URL) + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + }) + + const rows = await db + .select() + .from(wakeRegistrations) + .where(eq(wakeRegistrations.sourceUrl, `/child/c1`)) + + expect(rows).toHaveLength(1) + + const results = await registry.evaluate(`/child/c1`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(1) + expect(results[0]!.registrationDbId).toBe(rows[0]!.id) + + await registry.stopSync() + }) +}) +``` + +If `createDb(TEST_POSTGRES_URL)` does not expose `end`, remove the `afterAll` block and follow the existing DB helper pattern in nearby integration tests. + +- [ ] **Step 2: Run integration test to verify it fails** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry-sync.test.ts --run +``` + +Expected: FAIL because `startSync` still uses manual ShapeStream or runtime actions do not persist and await Electric txids yet. + +- [ ] **Step 3: Import Electric collection and column mapper** + +Update imports in `wake-registry.ts`: + +```ts +import { snakeCamelMapper } from '@electric-sql/client' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' +import { sql } from 'drizzle-orm' +import { electricUrlWithPath } from './utils/electric-url.js' +import { serverLog } from './utils/log.js' +``` + +Keep Drizzle `and`/`eq` imports for mutation predicates: + +```ts +import { and, eq, sql } from 'drizzle-orm' +``` + +- [ ] **Step 4: Implement sequence id allocation and txid helper** + +Add these helpers to `WakeRegistry`: + +```ts +private async allocateRuntimeId(): Promise { + const rows = await this.db.execute(sql<{ id: string }>`select nextval('wake_registrations_id_seq')::text as id`) + const value = Array.isArray(rows) ? rows[0]?.id : (rows as any)[0]?.id + const id = Number(value) + if (!Number.isInteger(id)) { + throw new Error(`Failed to allocate wake registration id`) + } + return id +} + +private async currentTxid(): Promise { + const rows = await this.db.execute(sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`) + const value = Array.isArray(rows) ? rows[0]?.txid : (rows as any)[0]?.txid + const txid = Number(value) + if (!Number.isInteger(txid)) { + throw new Error(`Failed to read Postgres transaction id`) + } + return txid +} +``` + +If `DrizzleDB.execute(...)` returns `{ rows }` rather than an array in this codebase, adapt the extraction to: + +```ts +const result = await this.db.execute(...) +const value = result.rows[0]?.txid +``` + +Use the actual shape observed by TypeScript/tests; keep the method returning `number`. + +- [ ] **Step 5: Implement Electric `startSync`** + +Replace old `startSync(...)` with an Electric collection that syncs rows but does not persist writes itself. Persistence belongs to the optimistic action `mutationFn` methods in later steps. + +```ts +async startSync(electricUrl: string, electricSecret?: string): Promise { + if (this.registrationsCollection) { + await this.registrationsCollection.preload() + return + } + + this.mode = `electric` + this.registrationsCollection = createCollection( + electricCollectionOptions({ + id: `wake-registrations:${this.tenantId ?? `all`}`, + getKey: (row) => row.id, + shapeOptions: { + url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(), + params: { + table: `wake_registrations`, + ...(this.tenantId + ? { where: `tenant_id = ${sqlStringLiteral(this.tenantId)}` } + : {}), + ...(electricSecret ? { secret: electricSecret } : {}), + columns: [ + `id`, + `tenant_id`, + `subscriber_url`, + `source_url`, + `condition`, + `debounce_ms`, + `timeout_ms`, + `one_shot`, + `timeout_consumed`, + `include_response`, + `manifest_key`, + `created_at`, + ], + replica: `full`, + }, + parser: { + timestamptz: (value: string) => new Date(value), + }, + columnMapper: snakeCamelMapper(), + }, + }) + ) + + await this.registrationsCollection.preload() + this.startRegistrationEffect() +} +``` + +- [ ] **Step 6: Implement runtime persistence helpers** + +Add these helpers: + +```ts +private async persistInsert(row: WakeRegistrationCollectionRow): Promise { + const result = await this.db.transaction(async (tx) => { + await tx + .insert(wakeRegistrations) + .values({ + id: row.id, + tenantId: row.tenantId, + subscriberUrl: row.subscriberUrl, + sourceUrl: row.sourceUrl, + condition: row.condition, + debounceMs: row.debounceMs, + timeoutMs: row.timeoutMs, + oneShot: row.oneShot, + timeoutConsumed: row.timeoutConsumed, + includeResponse: row.includeResponse, + manifestKey: row.manifestKey, + createdAt: row.createdAt, + }) + .onConflictDoNothing() + const rows = await tx.execute(sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`) + return Number(Array.isArray(rows) ? rows[0]!.txid : (rows as any)[0]!.txid) + }) + return result +} + +private async persistTimeoutConsumed(row: WakeRegistrationCollectionRow): Promise { + return await this.db.transaction(async (tx) => { + await tx + .update(wakeRegistrations) + .set({ timeoutConsumed: row.timeoutConsumed }) + .where(and(eq(wakeRegistrations.tenantId, row.tenantId), eq(wakeRegistrations.id, row.id))) + const rows = await tx.execute(sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`) + return Number(Array.isArray(rows) ? rows[0]!.txid : (rows as any)[0]!.txid) + }) +} + +private async persistDeleteRows(rows: Array): Promise { + return await this.db.transaction(async (tx) => { + for (const row of rows) { + await tx + .delete(wakeRegistrations) + .where(and(eq(wakeRegistrations.tenantId, row.tenantId), eq(wakeRegistrations.id, row.id))) + } + const txRows = await tx.execute(sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`) + return Number(Array.isArray(txRows) ? txRows[0]!.txid : (txRows as any)[0]!.txid) + }) +} +``` + +If Drizzle's transaction type does not accept `.execute` in this form, follow the pattern already used in `packages/agents-server/src/entity-registry.ts` for `pg_current_xact_id()::xid::text`. + +- [ ] **Step 7: Wire runtime actions to persistence** + +Update `register(...)` to allocate runtime ids from Postgres: + +```ts +async register(reg: WakeRegistration): Promise { + const tenantId = this.resolveTenantId(reg.tenantId) + const id = this.mode === `electric` ? await this.allocateRuntimeId() : this.allocateLocalId() + const tx = this.registerAction(this.normalizeRegistration(reg, tenantId, id)) + await tx.isPersisted.promise +} +``` + +Update `registerAction.mutationFn` so the action persists once, then waits for Electric to sync that txid into the collection: + +```ts +mutationFn: async (row) => { + if (this.mode === `local-test`) return + if (this.mode === `electric`) { + const txid = await this.persistInsert(row) + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } + throw new Error(`WakeRegistry registerAction called before startup`) +} +``` + +Update `deleteRowsAction.mutationFn`: + +```ts +mutationFn: async ({ rows }) => { + if (this.mode === `local-test`) return + if (this.mode === `electric`) { + const txid = await this.persistDeleteRows(rows) + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } + throw new Error(`WakeRegistry deleteRowsAction called before startup`) +} +``` + +Update `markTimeoutConsumedAction.mutationFn`: + +```ts +mutationFn: async ({ row }) => { + if (this.mode === `local-test`) return + if (this.mode === `electric`) { + const txid = await this.persistTimeoutConsumed({ + ...row, + timeoutConsumed: true, + }) + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } + throw new Error( + `WakeRegistry markTimeoutConsumedAction called before startup` + ) +} +``` + +- [ ] **Step 8: Remove manual Shape code** + +Delete these obsolete items from `wake-registry.ts`: + +```ts +interface WakeRegistrationShapeRow ... +normalizeShapeRow(...) +shapeRowMatchesRegistration(...) +waitForRegistrationInShape(...) +recoverSync(...) +loadRegistrations(...) +replaceCachedRegistrations(...) +upsertCachedRegistration(...) +removeCachedRegistrationByDbId(...) +findCachedRegistration(...) +resetCachedRegistrations(...) +``` + +Also remove all direct imports/usages of `Shape`, `ShapeStream`, `Row`, and `Value`. + +- [ ] **Step 9: Run sync integration test** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry-sync.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add packages/agents-server/src/wake-registry.ts packages/agents-server/test/wake-registry-sync.test.ts +git commit -m "Use Electric collection for wake registrations" +``` + +--- + +### Task 5: Update EntityManager, Host Startup, and Async Evaluation Call Sites + +**Files:** + +- Modify: `packages/agents-server/src/entity-manager.ts:430-440, 3291-3310` +- Modify: `packages/agents-server/src/host.ts:111-120` +- Modify: `packages/agents-server/src/standalone-runtime.ts:151-157` +- Modify: `packages/agents-server/test/server-start.test.ts` +- Modify: `packages/agents-server/test/wake-registry.test.ts` + +**Interfaces:** + +- Consumes: async `WakeRegistry.evaluate(...)`, `WakeRegistry.startSync(...)`, removed `loadRegistrations()`. +- Produces: runtime startup fails without Electric URL, no reload-on-miss fallback, all tests await evaluation. + +- [ ] **Step 1: Write failing startup test for missing Electric URL** + +In `packages/agents-server/test/server-start.test.ts`, update or add a test with this assertion: + +```ts +it(`fails host startup without Electric URL for wake registry sync`, async () => { + const host = createTestHost({ electricUrl: undefined }) + + await expect(host.start()).rejects.toThrow( + `WakeRegistry runtime requires an Electric URL` + ) +}) +``` + +If `createTestHost` does not exist, follow the existing host construction helper in this file and assert the same error around `host.start()`. + +- [ ] **Step 2: Run startup test to verify it fails** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/server-start.test.ts -t "fails host startup without Electric URL" --run +``` + +Expected: FAIL because host still calls `loadRegistrations()` or the test helper needs updating. + +- [ ] **Step 3: Update `EntityManager.rebuildWakeRegistry`** + +Replace `rebuildWakeRegistry(...)` in `packages/agents-server/src/entity-manager.ts` with: + +```ts +async rebuildWakeRegistry( + electricUrl?: string, + electricSecret?: string +): Promise { + if (!electricUrl) { + throw new Error(`WakeRegistry runtime requires an Electric URL`) + } + await this.wakeRegistry.startSync(electricUrl, electricSecret) +} +``` + +- [ ] **Step 4: Await async registry evaluation and remove reload-on-miss workaround** + +In `evaluateWakes(...)`, replace: + +```ts +const results = this.wakeRegistry.evaluate(sourceUrl, event, this.tenantId) +``` + +with: + +```ts +const results = await this.wakeRegistry.evaluate( + sourceUrl, + event, + this.tenantId +) +``` + +Ensure no code remains that calls `wakeRegistry.loadRegistrations()` or retries `evaluate(...)` after a miss. + +- [ ] **Step 5: Update `AgentsHost.start`** + +Replace the wake registry startup block in `packages/agents-server/src/host.ts` with: + +```ts +if (!this.electricUrl) { + throw new Error(`WakeRegistry runtime requires an Electric URL`) +} +await this.wakeRegistry.startSync(this.electricUrl, this.electricSecret) +``` + +- [ ] **Step 6: Update test mocks** + +In `packages/agents-server/test/server-start.test.ts`, remove mocked `loadRegistrations()` from `MockWakeRegistry`. Keep: + +```ts +startSync(): Promise { + return Promise.resolve() +} +``` + +If tests need a local-only registry, use `startLocalForTests()` directly in wake registry unit tests, not host startup tests. + +- [ ] **Step 7: Convert direct unit-test evaluations to await** + +In `packages/agents-server/test/wake-registry.test.ts`, replace patterns like: + +```ts +const results = registry.evaluate(`/child/c1`, event) +``` + +with: + +```ts +const results = await registry.evaluate(`/child/c1`, event) +``` + +For inline assertions, replace: + +```ts +expect(registry.evaluate(`/child/c1`, event)).toHaveLength(1) +``` + +with: + +```ts +await expect(registry.evaluate(`/child/c1`, event)).resolves.toHaveLength(1) +``` + +- [ ] **Step 8: Run affected tests** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/server-start.test.ts test/wake-registry.test.ts --run +``` + +Expected: PASS or only failures from tests still asserting deleted manual Shape/cache behavior. + +- [ ] **Step 9: Commit** + +```bash +git add packages/agents-server/src/entity-manager.ts packages/agents-server/src/host.ts packages/agents-server/src/standalone-runtime.ts packages/agents-server/test/server-start.test.ts packages/agents-server/test/wake-registry.test.ts +git commit -m "Require Electric for wake registry runtime" +``` + +--- + +### Task 6: Remove Obsolete Manual Cache Tests and Verify Full Wake Registry Behavior + +**Files:** + +- Modify: `packages/agents-server/test/wake-registry.test.ts` +- Modify: `packages/agents-server/test/wake-registry-sync.test.ts` +- Modify: `.changeset/fix-deferred-pull-wakes.md` + +**Interfaces:** + +- Consumes: completed TanStack DB-backed registry and async call sites. +- Produces: passing targeted test suite and updated changeset summary. + +- [ ] **Step 1: Delete obsolete manual Shape/cache tests** + +Remove tests whose only subject is deleted internals: + +```ts +it(`removes cached registrations from shape delete old_value ids`, ...) +it(`ignores malformed shape messages without headers while waiting for up-to-date`, ...) +it(`hydrates and updates the cache from shape changes`, ...) +it(`reloads wake registrations ... cache miss`, ...) +``` + +Do not delete behavior tests for tenant scoping, wake matching, debounce, timeout, one-shot, unregister, or end-to-end wake delivery. + +- [ ] **Step 2: Ensure every unit test starts local registry explicitly** + +For every unit test that creates `new WakeRegistry(createMockDb())`, add: + +```ts +await registry.startLocalForTests() +``` + +before `register(...)` or `evaluate(...)`. + +Do not add `startLocalForTests()` to integration tests that use `ElectricAgentsServer`, `EntityManager`, or real `TEST_ELECTRIC_URL`; those should exercise runtime `startSync(...)`. + +- [ ] **Step 3: Run all wake registry tests** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/wake-registry.test.ts test/wake-registry-sync.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 4: Run broader affected agent-server tests** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server test test/server-start.test.ts test/horton-pull-wake-e2e.test.ts test/pg-sync-wake-delivery.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Run typecheck** + +Run: + +```bash +pnpm --filter @electric-ax/agents-server typecheck +``` + +Expected: PASS with no TypeScript errors. + +- [ ] **Step 6: Update changeset** + +Edit `.changeset/fix-deferred-pull-wakes.md` so the `@electric-ax/agents-server` entry mentions the TanStack DB registry refactor. Keep the existing runtime changeset text for `@electric-ax/agents-runtime` intact. + +Use wording like: + +```md +Refactor the server wake registry to use TanStack DB collections and optimistic actions over `wake_registrations`, removing the manual ShapeStream-backed registration cache and stale-cache reload fallback. +``` + +- [ ] **Step 7: Validate changeset coverage** + +Run: + +```bash +GITHUB_BASE_REF=main node scripts/check-changeset.mjs +``` + +Expected: success message that changesets cover affected packages. + +- [ ] **Step 8: Commit** + +```bash +git add packages/agents-server/test/wake-registry.test.ts packages/agents-server/test/wake-registry-sync.test.ts .changeset/fix-deferred-pull-wakes.md +git commit -m "Verify TanStack DB wake registry behavior" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- Collection as only in-memory state: Tasks 1, 4, 6 remove `registrationCache`, `Shape`, and Shape tests. +- Runtime Electric collection: Task 4. +- Unit-test local-only collection: Tasks 1, 2, 6. +- Optimistic actions for all mutations: Task 2 and runtime persistence in Task 4. +- `queryOnce` evaluation: Task 1, call sites in Task 5. +- `createEffect` timeout side effects: Task 3. +- Remove reload-on-miss and `loadRegistrations()`: Tasks 4 and 5. +- Tests and verification: Tasks 1-6 include red/green commands and final typecheck. + +**Placeholder scan:** No `TBD`, `TODO`, `implement later`, or `similar to Task N` placeholders remain. The one conditional instruction about Drizzle execute shape gives exact fallback code paths and a required return type. + +**Type consistency:** The plan consistently uses `WakeRegistrationCollectionRow.id: number` as the TanStack DB key, `WakeRegistry.startLocalForTests()`, async `WakeRegistry.evaluate(...)`, and shared optimistic actions. Later tasks consume names introduced in earlier tasks. diff --git a/packages/agents-server/package.json b/packages/agents-server/package.json index 9fcdbae14a..6c74c5c5e9 100644 --- a/packages/agents-server/package.json +++ b/packages/agents-server/package.json @@ -53,6 +53,8 @@ "@mariozechner/pi-agent-core": "^0.70.2", "@opentelemetry/api": "^1.9.1", "@sinclair/typebox": "^0.34.48", + "@tanstack/db": "^0.6.7", + "@tanstack/electric-db-collection": "^0.3.5", "@whatwg-node/server": "^0.10.18", "ajv": "^8.18.0", "cron-parser": "^5.5.0", diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index 4573024ece..a77627c0c4 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -3304,10 +3304,18 @@ export class EntityManager { ): Promise { return await withSpan(`electric_agents.evaluateWakes`, async (span) => { span.setAttribute(ATTR.WAKE_SOURCE, sourceUrl) - let results = this.wakeRegistry.evaluate(sourceUrl, event, this.tenantId) + let results = await this.wakeRegistry.evaluate( + sourceUrl, + event, + this.tenantId + ) if (results.length === 0 && isRunFinishedWakeEvent(event)) { await this.wakeRegistry.loadRegistrations() - results = this.wakeRegistry.evaluate(sourceUrl, event, this.tenantId) + results = await this.wakeRegistry.evaluate( + sourceUrl, + event, + this.tenantId + ) } span.setAttribute(`electric_agents.wake.subscriber_count`, results.length) const settled = await Promise.allSettled( diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index ff8ef6e75b..13001f15ba 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -3,6 +3,14 @@ import { isChangeMessage, isControlMessage, } from '@electric-sql/client' +import { + and as dbAnd, + createCollection, + createOptimisticAction, + eq as dbEq, + localOnlyCollectionOptions, + queryOnce, +} from '@tanstack/db' import { and, eq } from 'drizzle-orm' import { wakeRegistrations } from './db/schema.js' import { serverLog } from './utils/log.js' @@ -10,6 +18,7 @@ import { electricUrlWithPath } from './utils/electric-url.js' import { DEFAULT_TENANT_ID } from './tenant.js' import type { DrizzleDB } from './db/index.js' import type { Message, Row, Value } from '@electric-sql/client' +import type { Collection } from '@tanstack/db' export interface WakeRegistration { tenantId?: string @@ -58,6 +67,23 @@ export interface WakeEvalResult { export type WakeTimeoutCallback = (result: WakeEvalResult) => void export type WakeDebounceCallback = (result: WakeEvalResult) => void +export interface WakeRegistrationCollectionRow { + id: number + tenantId: string + subscriberUrl: string + sourceUrl: string + condition: WakeRegistration[`condition`] + debounceMs: number + timeoutMs: number + oneShot: boolean + timeoutConsumed: boolean + includeResponse: boolean + manifestKey: string | null + createdAt: Date +} + +type WakeRegistryMode = `unstarted` | `local-test` | `electric` + interface CachedWakeRegistration extends WakeRegistration { tenantId: string dbId: number @@ -107,6 +133,13 @@ function sqlStringLiteral(value: string): string { export class WakeRegistry { private db: DrizzleDB + private registrationsCollection: Collection< + WakeRegistrationCollectionRow, + number, + any + > | null = null + private mode: WakeRegistryMode = `unstarted` + private nextLocalId = 1 private registrationCache = new Map>() private debounceTimers = new Map() private debounceBuffers = new Map< @@ -132,6 +165,87 @@ export class WakeRegistry { this.db = db } + requireCollection(): Collection { + if (!this.registrationsCollection) { + throw new Error(`WakeRegistry has not been started`) + } + return this.registrationsCollection + } + + async startLocalForTests(): Promise { + if (this.registrationsCollection) return + this.mode = `local-test` + this.registrationsCollection = createCollection( + localOnlyCollectionOptions({ + id: `wake-registrations-local:${this.tenantId ?? `all`}`, + getKey: (row) => row.id, + initialData: [], + }) + ) + await this.requireCollection().preload() + } + + private allocateLocalId(): number { + return this.nextLocalId++ + } + + private normalizeRegistration( + reg: WakeRegistration, + tenantId: string, + id: number + ): WakeRegistrationCollectionRow { + return { + id, + tenantId, + subscriberUrl: reg.subscriberUrl, + sourceUrl: reg.sourceUrl, + condition: reg.condition, + debounceMs: reg.debounceMs ?? 0, + timeoutMs: reg.timeoutMs ?? 0, + oneShot: reg.oneShot, + timeoutConsumed: false, + includeResponse: reg.includeResponse !== false, + manifestKey: reg.manifestKey ?? null, + createdAt: new Date(), + } + } + + private collectionRowAsCached( + row: WakeRegistrationCollectionRow + ): CachedWakeRegistration { + return { + tenantId: row.tenantId, + subscriberUrl: row.subscriberUrl, + sourceUrl: row.sourceUrl, + condition: row.condition, + debounceMs: row.debounceMs || undefined, + timeoutMs: row.timeoutMs || undefined, + oneShot: row.oneShot, + includeResponse: row.includeResponse === false ? false : undefined, + manifestKey: row.manifestKey ?? undefined, + dbId: row.id, + createdAt: row.createdAt, + timeoutConsumed: row.timeoutConsumed, + } + } + + private registerAction = + createOptimisticAction({ + onMutate: (row) => { + this.requireCollection().insert(row) + this.syncTimeoutTimer(this.collectionRowAsCached(row)) + }, + mutationFn: async (_row, { transaction }) => { + if (this.mode === `local-test`) { + this.requireCollection().utils.acceptMutations(transaction) + return + } + throw new Error( + `WakeRegistry registerAction runtime persistence is not initialized` + ) + }, + }) + setTimeoutCallback(cb: WakeTimeoutCallback, tenantId?: string): void { const resolvedTenantId = this.resolveTenantId(tenantId) this.timeoutCallbacks.set(resolvedTenantId, cb) @@ -152,7 +266,9 @@ export class WakeRegistry { return `${tenantId}:${sourceUrl}` } - private registrationKey(reg: CachedWakeRegistration): string { + private registrationKey( + reg: CachedWakeRegistration | WakeRegistrationCollectionRow + ): string { return [ reg.tenantId, reg.subscriberUrl, @@ -327,6 +443,14 @@ export class WakeRegistry { async register(reg: WakeRegistration): Promise { const tenantId = this.resolveTenantId(reg.tenantId) + if (this.registrationsCollection) { + const id = this.allocateLocalId() + const tx = this.registerAction( + this.normalizeRegistration(reg, tenantId, id) + ) + await tx.isPersisted.promise + return + } const result = await this.db .insert(wakeRegistrations) .values({ @@ -580,6 +704,15 @@ export class WakeRegistry { this.registrationCache.clear() } + private replaceCachedRegistrations( + registrations: Array + ): void { + this.resetCachedRegistrations() + for (const registration of registrations) { + this.upsertCachedRegistration(registration) + } + } + private findCachedRegistration( dbId: number ): { cacheKey: string; index: number; reg: CachedWakeRegistration } | null { @@ -762,12 +895,113 @@ export class WakeRegistry { this.upsertCachedRegistration(this.normalizeShapeRow(message.value)) } - evaluate( + async evaluate( sourceUrl: string, event: Record, tenantId?: string - ): Array { + ): Promise> { const resolvedTenantId = this.resolveTenantId(tenantId) + if (this.registrationsCollection) { + const queriedRegs = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd( + dbEq(reg.tenantId, resolvedTenantId), + dbEq(reg.sourceUrl, sourceUrl) + ) + ) + ) + const regs = queriedRegs.map( + (queriedReg) => + ((queriedReg as { reg?: WakeRegistrationCollectionRow }).reg ?? + queriedReg) as WakeRegistrationCollectionRow + ) + if (regs.length === 0) return [] + + const results: Array = [] + const oneShotIds: Array = [] + + for (const reg of regs) { + const match = this.matchCondition(reg, event) + if (!match) continue + + const timerKey = this.registrationKey(reg) + const timeoutTimer = this.timeoutTimers.get(timerKey) + if (timeoutTimer) { + clearTimeout(timeoutTimer) + this.timeoutTimers.delete(timerKey) + void this.markTimeoutConsumed(reg.id, reg.tenantId) + } + + if (reg.debounceMs > 0) { + const buffer = this.debounceBuffers.get(timerKey) ?? [] + buffer.push(match.change) + this.debounceBuffers.set(timerKey, buffer) + + if (match.runFinishedStatus) { + this.debounceRunStatus.set(timerKey, match.runFinishedStatus) + } + + const existing = this.debounceTimers.get(timerKey) + if (existing) clearTimeout(existing) + + const timer = setTimeout(() => { + this.debounceTimers.delete(timerKey) + const flushed = this.debounceBuffers.get(timerKey) + if (flushed && flushed.length > 0) { + this.debounceBuffers.delete(timerKey) + const runStatus = this.debounceRunStatus.get(timerKey) + this.debounceRunStatus.delete(timerKey) + this.deliverDebounce({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: flushed[flushed.length - 1]!.key, + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: flushed, + }, + runFinishedStatus: runStatus, + includeResponse: reg.includeResponse, + }) + } + }, reg.debounceMs) + this.debounceTimers.set(timerKey, timer) + } else { + results.push({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: wakeSourceEventId(event), + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: [match.change], + }, + runFinishedStatus: match.runFinishedStatus, + includeResponse: reg.includeResponse, + }) + } + + if (reg.oneShot) { + oneShotIds.push(reg.id) + } + } + + for (let j = oneShotIds.length - 1; j >= 0; j--) { + const id = oneShotIds[j]! + const removed = regs.find((reg) => reg.id === id) + if (removed) { + this.clearRegistrationState(this.collectionRowAsCached(removed)) + this.timeoutDelivered.delete(removed.id) + this.requireCollection().delete(removed.id) + } + } + + return results + } const cacheKey = this.cacheKey(resolvedTenantId, sourceUrl) const regs = this.registrationCache.get(cacheKey) if (!regs || regs.length === 0) return [] @@ -906,7 +1140,7 @@ export class WakeRegistry { } private matchCondition( - reg: WakeRegistration, + reg: WakeRegistration | WakeRegistrationCollectionRow, event: Record ): { change: WakeEvalResult[`wakeMessage`][`changes`][number] diff --git a/packages/agents-server/test/pg-sync-wake-delivery.test.ts b/packages/agents-server/test/pg-sync-wake-delivery.test.ts index 5a0b70d60a..11085294b7 100644 --- a/packages/agents-server/test/pg-sync-wake-delivery.test.ts +++ b/packages/agents-server/test/pg-sync-wake-delivery.test.ts @@ -38,7 +38,7 @@ describe(`pgSync wake delivery matching`, () => { oneShot: false, }) - const insertResults = registry.evaluate( + const insertResults = await registry.evaluate( `/_electric/pg-sync/test`, event(`insert`), `default` @@ -49,7 +49,11 @@ describe(`pgSync wake delivery matching`, () => { oldValue: { id: `entity-1`, status: `spawning` }, }) expect( - registry.evaluate(`/_electric/pg-sync/test`, event(`delete`), `default`) + await registry.evaluate( + `/_electric/pg-sync/test`, + event(`delete`), + `default` + ) ).toEqual([]) }) @@ -69,14 +73,22 @@ describe(`pgSync wake delivery matching`, () => { }) expect( - registry - .evaluate(`/_electric/pg-sync/test`, event(`insert`), `default`) - .map((r) => r.subscriberUrl) + ( + await registry.evaluate( + `/_electric/pg-sync/test`, + event(`insert`), + `default` + ) + ).map((r) => r.subscriberUrl) ).toEqual([`/horton/a`]) expect( - registry - .evaluate(`/_electric/pg-sync/test`, event(`delete`), `default`) - .map((r) => r.subscriberUrl) + ( + await registry.evaluate( + `/_electric/pg-sync/test`, + event(`delete`), + `default` + ) + ).map((r) => r.subscriberUrl) ).toEqual([`/horton/b`]) }) @@ -90,10 +102,14 @@ describe(`pgSync wake delivery matching`, () => { }) expect( - registry.evaluate(`/_electric/pg-sync/test`, event(`insert`), `default`) + await registry.evaluate( + `/_electric/pg-sync/test`, + event(`insert`), + `default` + ) ).toHaveLength(1) expect( - registry.evaluate( + await registry.evaluate( `/_electric/pg-sync/test`, { ...event(`insert`), type: `other` }, `default` diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index 1e60ac71a5..d2998ac25c 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -123,7 +123,7 @@ describe(`WakeRegistry Electric sync`, () => { await startPromise expect( - registry.evaluate(`/child/c1`, { + await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -141,7 +141,7 @@ describe(`WakeRegistry Electric sync`, () => { ]) expect( - registry.evaluate(`/child/c1`, { + await registry.evaluate(`/child/c1`, { type: `run`, key: `run-2`, value: { status: `completed` }, diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index c2fdf3a16e..8ccb85fa52 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -61,6 +61,30 @@ function createMockDb(): any { } describe(`Wake Registry`, () => { + it(`evaluates registrations from local TanStack DB collection`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + }) + + const results = await registry.evaluate(`/child/c1`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(1) + expect(results[0]!.subscriberUrl).toBe(`/parent/p1`) + expect(results[0]!.registrationDbId).toBe(1) + expect(results[0]!.sourceEventKey).toBe(`update:run-1`) + }) + it(`keeps shared registrations scoped by tenant`, async () => { const registry = new WakeRegistry(createMockDb(), null) await registry.register({ @@ -84,8 +108,8 @@ describe(`Wake Registry`, () => { value: {}, headers: { operation: `insert` }, } - const tenantA = registry.evaluate(`/source/shared`, event, `tenant-a`) - const tenantB = registry.evaluate(`/source/shared`, event, `tenant-b`) + const tenantA = await registry.evaluate(`/source/shared`, event, `tenant-a`) + const tenantB = await registry.evaluate(`/source/shared`, event, `tenant-b`) expect(tenantA).toHaveLength(1) expect(tenantA[0]!.tenantId).toBe(`tenant-a`) @@ -163,7 +187,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/child/c1`, { + const results = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -189,7 +213,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/_cron/abc`, { + const results = await registry.evaluate(`/_cron/abc`, { type: `cron_tick`, key: `tick-7`, value: {}, @@ -210,7 +234,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const before = registry.evaluate(`/_cron/abc`, { + const before = await registry.evaluate(`/_cron/abc`, { type: `cron_tick`, key: `tick-7`, value: {}, @@ -224,7 +248,7 @@ describe(`Wake Registry`, () => { headers: { operation: `delete` }, }) - const after = registry.evaluate(`/_cron/abc`, { + const after = await registry.evaluate(`/_cron/abc`, { type: `cron_tick`, key: `tick-8`, value: {}, @@ -248,7 +272,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/_cron/abc`, { + const results = await registry.evaluate(`/_cron/abc`, { type: `cron_tick`, key: `tick-7`, value: {}, @@ -271,7 +295,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const first = registry.evaluate(`/child/c1`, { + const first = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -279,7 +303,7 @@ describe(`Wake Registry`, () => { }) expect(first).toHaveLength(1) - const second = registry.evaluate(`/child/c1`, { + const second = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-2`, value: { status: `completed` }, @@ -297,7 +321,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/source/s1`, { + const results = await registry.evaluate(`/source/s1`, { type: `texts`, key: `text-1`, value: { content: `hello` }, @@ -318,7 +342,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/agent/self`, { + const results = await registry.evaluate(`/agent/self`, { type: `inbox`, key: `msg-1`, value: { @@ -355,7 +379,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/source/s1`, { + const results = await registry.evaluate(`/source/s1`, { type: `toolCalls`, key: `tc-1`, value: {}, @@ -378,13 +402,13 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const insertResults = registry.evaluate(`/source/s1`, { + const insertResults = await registry.evaluate(`/source/s1`, { type: `members`, key: `/task/a`, value: { url: `/task/a` }, headers: { operation: `insert` }, }) - const deleteResults = registry.evaluate(`/source/s1`, { + const deleteResults = await registry.evaluate(`/source/s1`, { type: `members`, key: `/task/a`, old_value: { url: `/task/a` }, @@ -409,7 +433,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate( + const results = await registry.evaluate( `/_electric/pg-sync/default/pg-source-1`, { type: `pg_sync_change`, @@ -451,13 +475,13 @@ describe(`Wake Registry`, () => { await registry.unregisterBySubscriber(`/parent/p1`) - const r1 = registry.evaluate(`/child/c1`, { + const r1 = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, headers: { operation: `update` }, }) - const r2 = registry.evaluate(`/child/c2`, { + const r2 = await registry.evaluate(`/child/c2`, { type: `texts`, key: `t-1`, value: {}, @@ -485,13 +509,13 @@ describe(`Wake Registry`, () => { await registry.unregisterBySubscriberAndSource(`/parent/p1`, `/child/c1`) - const r1 = registry.evaluate(`/child/c1`, { + const r1 = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, headers: { operation: `update` }, }) - const r2 = registry.evaluate(`/child/c2`, { + const r2 = await registry.evaluate(`/child/c2`, { type: `texts`, key: `t-1`, value: {}, @@ -540,7 +564,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const runResults = registry.evaluate(`/child/c1`, { + const runResults = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -549,7 +573,7 @@ describe(`Wake Registry`, () => { expect(runResults).toHaveLength(1) expect(runResults[0]!.subscriberUrl).toBe(`/parent/p1`) - const changeResults = registry.evaluate(`/source/s1`, { + const changeResults = await registry.evaluate(`/source/s1`, { type: `texts`, key: `t-1`, value: {}, @@ -568,7 +592,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/_electric/shared-state/board-1`, { + const results = await registry.evaluate(`/_electric/shared-state/board-1`, { type: `texts`, key: `t-1`, value: {}, @@ -620,7 +644,7 @@ describe(`Wake Registry`, () => { }) for (let i = 0; i < 3; i++) { - const immediate = registry.evaluate(`/source/s1`, { + const immediate = await registry.evaluate(`/source/s1`, { type: `texts`, key: `text-${i}`, value: { content: `msg-${i}` }, @@ -661,7 +685,7 @@ describe(`Wake Registry`, () => { }) expect( - registry.evaluate(`/source/s1`, { + await registry.evaluate(`/source/s1`, { type: `texts`, key: `text-1`, value: { content: `hello` }, @@ -670,7 +694,7 @@ describe(`Wake Registry`, () => { ).toHaveLength(0) expect( - registry.evaluate(`/source/s1`, { + await registry.evaluate(`/source/s1`, { type: `toolCalls`, key: `tool-1`, value: { name: `search` }, @@ -784,7 +808,7 @@ describe(`Wake Registry`, () => { includeResponse: false, }) - const results = registry.evaluate(`/child/c1`, { + const results = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -804,7 +828,7 @@ describe(`Wake Registry`, () => { oneShot: false, }) - const results = registry.evaluate(`/child/c1`, { + const results = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -832,7 +856,7 @@ describe(`Wake Registry`, () => { }) // Immediate evaluate should return nothing (debounced) - const results = registry.evaluate(`/child/c1`, { + const results = await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -866,7 +890,7 @@ describe(`Wake Registry`, () => { }) // run-1 completes - registry.evaluate(`/child/c1`, { + await registry.evaluate(`/child/c1`, { type: `run`, key: `run-1`, value: { status: `completed` }, @@ -874,7 +898,7 @@ describe(`Wake Registry`, () => { }) // run-2 fails in the same debounce window - registry.evaluate(`/child/c1`, { + await registry.evaluate(`/child/c1`, { type: `run`, key: `run-2`, value: { status: `failed` }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f939c41c..78a910c6b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1946,6 +1946,12 @@ importers: '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.49 + '@tanstack/db': + specifier: ^0.6.7 + version: 0.6.7(typescript@5.8.3) + '@tanstack/electric-db-collection': + specifier: ^0.3.5 + version: 0.3.5(typescript@5.8.3) '@whatwg-node/server': specifier: ^0.10.18 version: 0.10.18 @@ -4768,8 +4774,8 @@ packages: '@electric-sql/client@1.2.0': resolution: {integrity: sha512-K/MEjti3UF4aPKJJqO6Tp4f5noqc2/3icU1NPdpKfQaHwbzGtEX2aJmL2vxTEUJbfyrISkPKbOPnrz/lAvw1Vg==} - '@electric-sql/client@1.5.20': - resolution: {integrity: sha512-Ftf+ze/rZK9dBzvZqW7wrS74aQBToUlhQeUFm7KCTfOCkNJZ+7iZMm9JW/NjyIQjPOz4ApXg3VWOpeCbzsU3IQ==} + '@electric-sql/client@1.5.21': + resolution: {integrity: sha512-Aize0Aa2ZyTZySQMfMSTDPP/EJWcR1HfH7qE1DgMhI5f6dDxm1B74CN4o1fnIQeW/ZLg9TdbTgFYo1tsUg7mXg==} hasBin: true '@electric-sql/d2mini@0.1.8': @@ -25333,7 +25339,7 @@ snapshots: optionalDependencies: '@rollup/rollup-darwin-arm64': 4.46.1 - '@electric-sql/client@1.5.20': + '@electric-sql/client@1.5.21': dependencies: '@microsoft/fetch-event-source': 2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188) optionalDependencies: @@ -31426,9 +31432,20 @@ snapshots: - supports-color - typescript + '@tanstack/electric-db-collection@0.3.5(typescript@5.8.3)': + dependencies: + '@electric-sql/client': 1.5.21 + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.6.7(typescript@5.8.3) + '@tanstack/store': 0.9.3 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + - typescript + '@tanstack/electric-db-collection@0.3.5(typescript@5.9.3)': dependencies: - '@electric-sql/client': 1.5.20 + '@electric-sql/client': 1.5.21 '@standard-schema/spec': 1.1.0 '@tanstack/db': 0.6.7(typescript@5.9.3) '@tanstack/store': 0.9.3 From a0ab414c8f60ef00e057272df88948084ce523a2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 10:45:06 -0600 Subject: [PATCH 055/105] Move wake registry mutations to optimistic actions --- packages/agents-server/src/wake-registry.ts | 221 +++++++++++++++++- .../agents-server/test/wake-registry.test.ts | 65 ++++++ 2 files changed, 275 insertions(+), 11 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 13001f15ba..c50a6c57a5 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -84,6 +84,26 @@ export interface WakeRegistrationCollectionRow { type WakeRegistryMode = `unstarted` | `local-test` | `electric` +type DeleteRowsInput = { + rows: Array + persist: + | { + kind: `manifestKey` + tenantId: string + subscriberUrl: string + manifestKey: string + } + | { kind: `subscriber`; tenantId: string; subscriberUrl: string } + | { kind: `source`; tenantId: string; sourceUrl: string } + | { + kind: `subscriberAndSource` + tenantId: string + subscriberUrl: string + sourceUrl: string + } + | { kind: `oneShot` } +} + interface CachedWakeRegistration extends WakeRegistration { tenantId: string dbId: number @@ -229,6 +249,49 @@ export class WakeRegistry { } } + private normalizeQueriedRows( + rows: Array< + WakeRegistrationCollectionRow | { reg: WakeRegistrationCollectionRow } + > + ): Array { + return rows.map( + (row) => + ((row as { reg?: WakeRegistrationCollectionRow }).reg ?? + row) as WakeRegistrationCollectionRow + ) + } + + private async rowsByPredicate( + predicate: (row: WakeRegistrationCollectionRow) => boolean + ): Promise> { + const rows = await queryOnce((q) => + q.from({ reg: this.requireCollection() }) + ) + return this.normalizeQueriedRows( + rows as Array< + WakeRegistrationCollectionRow | { reg: WakeRegistrationCollectionRow } + > + ).filter(predicate) + } + + private async rowsForSource( + tenantId: string, + sourceUrl: string + ): Promise> { + const rows = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd(dbEq(reg.tenantId, tenantId), dbEq(reg.sourceUrl, sourceUrl)) + ) + ) + return this.normalizeQueriedRows( + rows as Array< + WakeRegistrationCollectionRow | { reg: WakeRegistrationCollectionRow } + > + ) + } + private registerAction = createOptimisticAction({ onMutate: (row) => { @@ -246,6 +309,45 @@ export class WakeRegistry { }, }) + private deleteRowsAction = createOptimisticAction({ + onMutate: ({ rows }) => { + const collection = this.requireCollection() + for (const row of rows) { + this.clearRegistrationState(row) + this.timeoutDelivered.delete(row.id) + collection.delete(row.id) + } + }, + mutationFn: async ({ persist }, { transaction }) => { + if (this.mode === `local-test` || persist.kind === `oneShot`) { + this.requireCollection().utils.acceptMutations(transaction) + return + } + throw new Error( + `WakeRegistry deleteRowsAction runtime persistence is not initialized` + ) + }, + }) + + private markTimeoutConsumedAction = createOptimisticAction<{ + row: WakeRegistrationCollectionRow + }>({ + onMutate: ({ row }) => { + this.requireCollection().update(row.id, (draft) => { + draft.timeoutConsumed = true + }) + }, + mutationFn: async (_input, { transaction }) => { + if (this.mode === `local-test`) { + this.requireCollection().utils.acceptMutations(transaction) + return + } + throw new Error( + `WakeRegistry markTimeoutConsumedAction runtime persistence is not initialized` + ) + }, + }) + setTimeoutCallback(cb: WakeTimeoutCallback, tenantId?: string): void { const resolvedTenantId = this.resolveTenantId(tenantId) this.timeoutCallbacks.set(resolvedTenantId, cb) @@ -493,6 +595,26 @@ export class WakeRegistry { dbId: number, tenantId: string ): Promise { + if (this.registrationsCollection) { + const row = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd(dbEq(reg.tenantId, tenantId), dbEq(reg.id, dbId)) + ) + .findOne() + ) + const normalized = (( + row as + | { reg?: WakeRegistrationCollectionRow } + | WakeRegistrationCollectionRow + | undefined + )?.reg ?? row) as WakeRegistrationCollectionRow | undefined + if (!normalized) return + const tx = this.markTimeoutConsumedAction({ row: normalized }) + await tx.isPersisted.promise + return + } await this.db .update(wakeRegistrations) .set({ timeoutConsumed: true }) @@ -509,6 +631,27 @@ export class WakeRegistry { manifestKey: string, tenantId?: string ): Promise { + if (this.registrationsCollection) { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => + row.tenantId === resolvedTenantId && + row.subscriberUrl === subscriberUrl && + row.manifestKey === manifestKey + ) + const tx = this.deleteRowsAction({ + rows, + persist: { + kind: `manifestKey`, + tenantId: resolvedTenantId, + subscriberUrl, + manifestKey, + }, + }) + await tx.isPersisted.promise + return + } + const resolvedTenantId = this.resolveTenantId(tenantId) await this.db .delete(wakeRegistrations) @@ -541,6 +684,25 @@ export class WakeRegistry { subscriberUrl: string, tenantId?: string ): Promise { + if (this.registrationsCollection) { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => + row.tenantId === resolvedTenantId && + row.subscriberUrl === subscriberUrl + ) + const tx = this.deleteRowsAction({ + rows, + persist: { + kind: `subscriber`, + tenantId: resolvedTenantId, + subscriberUrl, + }, + }) + await tx.isPersisted.promise + return + } + const resolvedTenantId = this.resolveTenantId(tenantId) await this.db .delete(wakeRegistrations) @@ -570,6 +732,17 @@ export class WakeRegistry { sourceUrl: string, tenantId?: string ): Promise { + if (this.registrationsCollection) { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsForSource(resolvedTenantId, sourceUrl) + const tx = this.deleteRowsAction({ + rows, + persist: { kind: `source`, tenantId: resolvedTenantId, sourceUrl }, + }) + await tx.isPersisted.promise + return + } + const resolvedTenantId = this.resolveTenantId(tenantId) await this.db .delete(wakeRegistrations) @@ -595,6 +768,27 @@ export class WakeRegistry { sourceUrl: string, tenantId?: string ): Promise { + if (this.registrationsCollection) { + const resolvedTenantId = this.resolveTenantId(tenantId) + const rows = await this.rowsByPredicate( + (row) => + row.tenantId === resolvedTenantId && + row.subscriberUrl === subscriberUrl && + row.sourceUrl === sourceUrl + ) + const tx = this.deleteRowsAction({ + rows, + persist: { + kind: `subscriberAndSource`, + tenantId: resolvedTenantId, + subscriberUrl, + sourceUrl, + }, + }) + await tx.isPersisted.promise + return + } + const resolvedTenantId = this.resolveTenantId(tenantId) await this.db .delete(wakeRegistrations) @@ -683,7 +877,9 @@ export class WakeRegistry { } } - private clearRegistrationState(reg: CachedWakeRegistration): void { + private clearRegistrationState( + reg: CachedWakeRegistration | WakeRegistrationCollectionRow + ): void { const timerKey = this.registrationKey(reg) this.clearDebounceState(timerKey) this.clearTimeoutState(timerKey) @@ -920,7 +1116,7 @@ export class WakeRegistry { if (regs.length === 0) return [] const results: Array = [] - const oneShotIds: Array = [] + const oneShotRows: Array = [] for (const reg of regs) { const match = this.matchCondition(reg, event) @@ -986,18 +1182,21 @@ export class WakeRegistry { } if (reg.oneShot) { - oneShotIds.push(reg.id) + oneShotRows.push(reg) } } - for (let j = oneShotIds.length - 1; j >= 0; j--) { - const id = oneShotIds[j]! - const removed = regs.find((reg) => reg.id === id) - if (removed) { - this.clearRegistrationState(this.collectionRowAsCached(removed)) - this.timeoutDelivered.delete(removed.id) - this.requireCollection().delete(removed.id) - } + if (oneShotRows.length > 0) { + const tx = this.deleteRowsAction({ + rows: oneShotRows, + persist: { kind: `oneShot` }, + }) + void tx.isPersisted.promise.catch((error) => { + console.warn( + `[wake-registry] failed to persist one-shot cleanup:`, + error + ) + }) } return results diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 8ccb85fa52..f9020fc544 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -458,6 +458,71 @@ describe(`Wake Registry`, () => { }) }) + it(`unregisterBySource removes all matching local collection rows`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/a`, + sourceUrl: `/source/1`, + condition: { on: `change` }, + oneShot: false, + }) + await registry.register({ + subscriberUrl: `/parent/b`, + sourceUrl: `/source/1`, + condition: { on: `change` }, + oneShot: false, + }) + await registry.register({ + subscriberUrl: `/parent/c`, + sourceUrl: `/source/2`, + condition: { on: `change` }, + oneShot: false, + }) + + await registry.unregisterBySource(`/source/1`) + + expect( + await registry.evaluate(`/source/1`, { + type: `texts`, + key: `t1`, + value: {}, + headers: { operation: `insert` }, + }) + ).toHaveLength(0) + expect( + await registry.evaluate(`/source/2`, { + type: `texts`, + key: `t2`, + value: {}, + headers: { operation: `insert` }, + }) + ).toHaveLength(1) + }) + + it(`oneShot match removes row before a second immediate evaluation`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: true, + }) + + const event = { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + } + + expect(await registry.evaluate(`/child/c1`, event)).toHaveLength(1) + expect(await registry.evaluate(`/child/c1`, event)).toHaveLength(0) + }) + it(`cleanup on subscriber deletion removes all registrations`, async () => { const registry = new WakeRegistry(createMockDb()) await registry.register({ From 499e9b0e3fdfdacbc8672722f339fb942a9e8e81 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 10:50:56 -0600 Subject: [PATCH 056/105] Drive wake timeouts from collection effects --- packages/agents-server/src/wake-registry.ts | 146 ++++++++++++++---- .../agents-server/test/wake-registry.test.ts | 48 ++++++ 2 files changed, 163 insertions(+), 31 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index c50a6c57a5..09ffa678ac 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -6,6 +6,7 @@ import { import { and as dbAnd, createCollection, + createEffect, createOptimisticAction, eq as dbEq, localOnlyCollectionOptions, @@ -160,6 +161,7 @@ export class WakeRegistry { > | null = null private mode: WakeRegistryMode = `unstarted` private nextLocalId = 1 + private registrationsEffect: { dispose(): Promise } | null = null private registrationCache = new Map>() private debounceTimers = new Map() private debounceBuffers = new Map< @@ -203,6 +205,29 @@ export class WakeRegistry { }) ) await this.requireCollection().preload() + this.startRegistrationEffect() + } + + private startRegistrationEffect(): void { + if (this.registrationsEffect) return + const collection = this.requireCollection() + this.registrationsEffect = createEffect< + WakeRegistrationCollectionRow, + number + >({ + query: (q) => q.from({ reg: collection }), + skipInitial: false, + onEnter: ({ value }) => { + this.syncTimeoutTimer(value) + }, + onUpdate: ({ value }) => { + this.syncTimeoutTimer(value) + }, + onExit: ({ value }) => { + this.clearRegistrationState(value) + this.timeoutDelivered.delete(value.id) + }, + }) } private allocateLocalId(): number { @@ -249,6 +274,25 @@ export class WakeRegistry { } } + private cachedRegistrationAsCollectionRow( + reg: CachedWakeRegistration + ): WakeRegistrationCollectionRow { + return { + id: reg.dbId, + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + sourceUrl: reg.sourceUrl, + condition: reg.condition, + debounceMs: reg.debounceMs ?? 0, + timeoutMs: reg.timeoutMs ?? 0, + oneShot: reg.oneShot, + timeoutConsumed: reg.timeoutConsumed ?? false, + includeResponse: reg.includeResponse !== false, + manifestKey: reg.manifestKey ?? null, + createdAt: reg.createdAt ?? new Date(), + } + } + private normalizeQueriedRows( rows: Array< WakeRegistrationCollectionRow | { reg: WakeRegistrationCollectionRow } @@ -296,7 +340,6 @@ export class WakeRegistry { createOptimisticAction({ onMutate: (row) => { this.requireCollection().insert(row) - this.syncTimeoutTimer(this.collectionRowAsCached(row)) }, mutationFn: async (_row, { transaction }) => { if (this.mode === `local-test`) { @@ -352,6 +395,7 @@ export class WakeRegistry { const resolvedTenantId = this.resolveTenantId(tenantId) this.timeoutCallbacks.set(resolvedTenantId, cb) this.syncTenantTimeoutTimers(resolvedTenantId) + void this.syncTenantCollectionTimeoutTimers(resolvedTenantId) } setDebounceCallback(cb: WakeDebounceCallback, tenantId?: string): void { @@ -494,11 +538,31 @@ export class WakeRegistry { } async stopSync(): Promise { + await this.registrationsEffect?.dispose() + this.registrationsEffect = null + this.registrationsCollection = null + this.mode = `unstarted` this.syncUnsubscribe?.() this.syncUnsubscribe = null this.syncAbortController?.abort() this.syncAbortController = null this.syncReadyPromise = null + this.resetRuntimeState() + } + + private resetRuntimeState(): void { + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer) + } + this.debounceTimers.clear() + this.debounceBuffers.clear() + this.debounceRunStatus.clear() + + for (const timer of this.timeoutTimers.values()) { + clearTimeout(timer) + } + this.timeoutTimers.clear() + this.timeoutDelivered.clear() } private async recoverSync( @@ -586,9 +650,9 @@ export class WakeRegistry { }) } - private startTimeoutTimer(reg: CachedWakeRegistration, dbId: number): void { - if (reg.timeoutMs == null || reg.timeoutMs <= 0) return - this.startTimeoutTimerWithDuration(reg, dbId, reg.timeoutMs) + private startTimeoutTimer(reg: WakeRegistrationCollectionRow): void { + if (reg.timeoutMs <= 0) return + this.startTimeoutTimerWithDuration(reg, reg.timeoutMs) } private async markTimeoutConsumed( @@ -604,12 +668,13 @@ export class WakeRegistry { ) .findOne() ) - const normalized = (( - row as - | { reg?: WakeRegistrationCollectionRow } - | WakeRegistrationCollectionRow - | undefined - )?.reg ?? row) as WakeRegistrationCollectionRow | undefined + const queried = row as + | { reg?: WakeRegistrationCollectionRow } + | WakeRegistrationCollectionRow + | undefined + const normalized = ( + queried && `reg` in queried ? queried.reg : queried + ) as WakeRegistrationCollectionRow | undefined if (!normalized) return const tx = this.markTimeoutConsumedAction({ row: normalized }) await tx.isPersisted.promise @@ -847,14 +912,13 @@ export class WakeRegistry { } private startTimeoutTimerWithDuration( - reg: CachedWakeRegistration, - dbId: number, + reg: WakeRegistrationCollectionRow, durationMs: number ): void { const timerKey = this.registrationKey(reg) const timer = setTimeout(() => { this.timeoutTimers.delete(timerKey) - this.deliverTimeoutForRegistration(reg, dbId) + this.deliverTimeoutForRegistration(reg) }, durationMs) this.timeoutTimers.set(timerKey, timer) } @@ -966,10 +1030,16 @@ export class WakeRegistry { } } - private syncTimeoutTimer(reg: CachedWakeRegistration): void { + private syncTimeoutTimer( + registration: CachedWakeRegistration | WakeRegistrationCollectionRow + ): void { + const reg = + `dbId` in registration + ? this.cachedRegistrationAsCollectionRow(registration) + : registration const timerKey = this.registrationKey(reg) - if (reg.timeoutConsumed || reg.timeoutMs == null || reg.timeoutMs <= 0) { + if (reg.timeoutConsumed || reg.timeoutMs <= 0) { this.clearTimeoutState(timerKey) return } @@ -978,31 +1048,30 @@ export class WakeRegistry { return } - if (!reg.createdAt) { - this.startTimeoutTimer(reg, reg.dbId) - return - } - const remaining = reg.createdAt.getTime() + reg.timeoutMs - Date.now() if (remaining > 0) { - this.startTimeoutTimerWithDuration(reg, reg.dbId, remaining) + this.startTimeoutTimerWithDuration(reg, remaining) return } - if (this.timeoutDelivered.has(reg.dbId)) { + if (this.timeoutDelivered.has(reg.id)) { return } - this.deliverTimeoutForRegistration(reg, reg.dbId) + this.deliverTimeoutForRegistration(reg) } private deliverTimeoutForRegistration( - reg: CachedWakeRegistration, - dbId: number + reg: WakeRegistrationCollectionRow ): void { - if (this.deliverTimeout(this.timeoutWakeResult(reg, dbId))) { - this.timeoutDelivered.add(dbId) - void this.markTimeoutConsumed(dbId, reg.tenantId) + if (this.deliverTimeout(this.timeoutWakeResult(reg))) { + this.timeoutDelivered.add(reg.id) + void this.markTimeoutConsumed(reg.id, reg.tenantId).catch((error) => { + serverLog.warn( + `[wake-registry] failed to mark timeout consumed for registration ${reg.id} (${reg.tenantId}, ${reg.sourceUrl} -> ${reg.subscriberUrl}):`, + error + ) + }) } } @@ -1016,14 +1085,29 @@ export class WakeRegistry { } } + private async syncTenantCollectionTimeoutTimers( + tenantId: string + ): Promise { + if (!this.registrationsCollection) return + const rows = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => dbEq(reg.tenantId, tenantId)) + ) + for (const queriedRow of rows) { + const row = ((queriedRow as { reg?: WakeRegistrationCollectionRow }) + .reg ?? queriedRow) as WakeRegistrationCollectionRow + this.syncTimeoutTimer(row) + } + } + private timeoutWakeResult( - reg: CachedWakeRegistration, - dbId: number + reg: WakeRegistrationCollectionRow ): WakeEvalResult { return { tenantId: reg.tenantId, subscriberUrl: reg.subscriberUrl, - registrationDbId: dbId, + registrationDbId: reg.id, sourceEventKey: `timeout`, wakeMessage: { source: reg.sourceUrl, diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index f9020fc544..544a4f4f9c 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -85,6 +85,54 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`update:run-1`) }) + it(`timeout effect delivers timeout wake once and marks row consumed`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + const delivered: Array = [] + registry.setTimeoutCallback((result) => delivered.push(result)) + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + timeoutMs: 25, + }) + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(delivered).toHaveLength(1) + expect(delivered[0]!.wakeMessage.timeout).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(delivered).toHaveLength(1) + }) + + it(`local timeout effect does not consume timeout before callback is registered`, async () => { + const registry = new WakeRegistry(createMockDb()) + await registry.startLocalForTests() + const delivered: Array = [] + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + timeoutMs: 25, + }) + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(delivered).toHaveLength(0) + + registry.setTimeoutCallback((result) => delivered.push(result)) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(delivered).toHaveLength(1) + expect(delivered[0]!.sourceEventKey).toBe(`timeout`) + }) + it(`keeps shared registrations scoped by tenant`, async () => { const registry = new WakeRegistry(createMockDb(), null) await registry.register({ From 0199d27dd2ad0130f69550f4845120c0737a1260 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 11:16:58 -0600 Subject: [PATCH 057/105] Use Electric collection for wake registry sync --- packages/agents-server/src/entity-manager.ts | 27 +- packages/agents-server/src/host.ts | 7 +- packages/agents-server/src/wake-registry.ts | 786 +++++------------- .../test/wake-registry-sync.test.ts | 183 +--- 4 files changed, 239 insertions(+), 764 deletions(-) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index a77627c0c4..61145285b6 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -82,17 +82,6 @@ type SpawnPersistResult = [ ] type SpawnPersistJob = () => Promise -function isRunFinishedWakeEvent(event: Record): boolean { - if (event.type !== `run`) return false - const value = event.value as Record | undefined - const headers = event.headers as Record | undefined - const status = value?.status as string | undefined - const operation = headers?.operation as string | undefined - return ( - operation === `update` && (status === `completed` || status === `failed`) - ) -} - type WriteTokenValidator = ( entity: ElectricAgentsEntity, token: string @@ -441,12 +430,10 @@ export class EntityManager { electricUrl?: string, electricSecret?: string ): Promise { - if (electricUrl) { - await this.wakeRegistry.startSync(electricUrl, electricSecret) - return + if (!electricUrl) { + throw new Error(`Electric URL is required to start WakeRegistry`) } - - await this.wakeRegistry.loadRegistrations() + await this.wakeRegistry.startSync(electricUrl, electricSecret) } setWriteTokenValidator(validator: WriteTokenValidator): void { @@ -3309,14 +3296,6 @@ export class EntityManager { event, this.tenantId ) - if (results.length === 0 && isRunFinishedWakeEvent(event)) { - await this.wakeRegistry.loadRegistrations() - results = await this.wakeRegistry.evaluate( - sourceUrl, - event, - this.tenantId - ) - } span.setAttribute(`electric_agents.wake.subscriber_count`, results.length) const settled = await Promise.allSettled( results.map((result) => this.deliverWakeResult(result)) diff --git a/packages/agents-server/src/host.ts b/packages/agents-server/src/host.ts index de556f8bdd..ac5888f2b8 100644 --- a/packages/agents-server/src/host.ts +++ b/packages/agents-server/src/host.ts @@ -113,11 +113,10 @@ export class AgentsHost { this.running = true try { - if (this.electricUrl) { - await this.wakeRegistry.startSync(this.electricUrl, this.electricSecret) - } else { - await this.wakeRegistry.loadRegistrations() + if (!this.electricUrl) { + throw new Error(`Electric URL is required to start WakeRegistry`) } + await this.wakeRegistry.startSync(this.electricUrl, this.electricSecret) if (this.startEntityBridgeManager) { await this.entityProjector.start() } diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 09ffa678ac..50a293562b 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -1,8 +1,4 @@ -import { - ShapeStream, - isChangeMessage, - isControlMessage, -} from '@electric-sql/client' +import { snakeCamelMapper } from '@electric-sql/client' import { and as dbAnd, createCollection, @@ -12,13 +8,13 @@ import { localOnlyCollectionOptions, queryOnce, } from '@tanstack/db' -import { and, eq } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' import { wakeRegistrations } from './db/schema.js' import { serverLog } from './utils/log.js' import { electricUrlWithPath } from './utils/electric-url.js' import { DEFAULT_TENANT_ID } from './tenant.js' import type { DrizzleDB } from './db/index.js' -import type { Message, Row, Value } from '@electric-sql/client' import type { Collection } from '@tanstack/db' export interface WakeRegistration { @@ -105,28 +101,6 @@ type DeleteRowsInput = { | { kind: `oneShot` } } -interface CachedWakeRegistration extends WakeRegistration { - tenantId: string - dbId: number - createdAt?: Date - timeoutConsumed?: boolean -} - -interface WakeRegistrationShapeRow extends Row { - id: number - tenant_id: string - subscriber_url: string - source_url: string - condition: WakeRegistration[`condition`] & Value - debounce_ms: number - timeout_ms: number - one_shot: boolean - timeout_consumed: boolean - include_response: boolean - manifest_key: string | null - created_at: Date -} - function wakeSourceEventId(event: Record): string { const headers = typeof event.headers === `object` && event.headers !== null @@ -162,7 +136,6 @@ export class WakeRegistry { private mode: WakeRegistryMode = `unstarted` private nextLocalId = 1 private registrationsEffect: { dispose(): Promise } | null = null - private registrationCache = new Map>() private debounceTimers = new Map() private debounceBuffers = new Map< string, @@ -173,12 +146,6 @@ export class WakeRegistry { private timeoutDelivered = new Set() private timeoutCallbacks = new Map() private debounceCallbacks = new Map() - private syncElectricUrl: string | null = null - private syncElectricSecret: string | undefined - private syncAbortController: AbortController | null = null - private syncUnsubscribe: (() => void) | null = null - private syncReadyPromise: Promise | null = null - private syncRecoveryPromise: Promise | null = null constructor( db: DrizzleDB, @@ -255,44 +222,6 @@ export class WakeRegistry { } } - private collectionRowAsCached( - row: WakeRegistrationCollectionRow - ): CachedWakeRegistration { - return { - tenantId: row.tenantId, - subscriberUrl: row.subscriberUrl, - sourceUrl: row.sourceUrl, - condition: row.condition, - debounceMs: row.debounceMs || undefined, - timeoutMs: row.timeoutMs || undefined, - oneShot: row.oneShot, - includeResponse: row.includeResponse === false ? false : undefined, - manifestKey: row.manifestKey ?? undefined, - dbId: row.id, - createdAt: row.createdAt, - timeoutConsumed: row.timeoutConsumed, - } - } - - private cachedRegistrationAsCollectionRow( - reg: CachedWakeRegistration - ): WakeRegistrationCollectionRow { - return { - id: reg.dbId, - tenantId: reg.tenantId, - subscriberUrl: reg.subscriberUrl, - sourceUrl: reg.sourceUrl, - condition: reg.condition, - debounceMs: reg.debounceMs ?? 0, - timeoutMs: reg.timeoutMs ?? 0, - oneShot: reg.oneShot, - timeoutConsumed: reg.timeoutConsumed ?? false, - includeResponse: reg.includeResponse !== false, - manifestKey: reg.manifestKey ?? null, - createdAt: reg.createdAt ?? new Date(), - } - } - private normalizeQueriedRows( rows: Array< WakeRegistrationCollectionRow | { reg: WakeRegistrationCollectionRow } @@ -341,14 +270,21 @@ export class WakeRegistry { onMutate: (row) => { this.requireCollection().insert(row) }, - mutationFn: async (_row, { transaction }) => { + mutationFn: async (row, { transaction }) => { if (this.mode === `local-test`) { this.requireCollection().utils.acceptMutations(transaction) return } - throw new Error( - `WakeRegistry registerAction runtime persistence is not initialized` - ) + if (this.mode === `electric`) { + const txid = await this.persistInsert(row) + if (txid === undefined) { + this.requireCollection().utils.acceptMutations(transaction) + return + } + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } + throw new Error(`WakeRegistry registerAction called before startup`) }, }) @@ -361,14 +297,21 @@ export class WakeRegistry { collection.delete(row.id) } }, - mutationFn: async ({ persist }, { transaction }) => { - if (this.mode === `local-test` || persist.kind === `oneShot`) { + mutationFn: async ({ rows }, { transaction }) => { + if (this.mode === `local-test`) { this.requireCollection().utils.acceptMutations(transaction) return } - throw new Error( - `WakeRegistry deleteRowsAction runtime persistence is not initialized` - ) + if (this.mode === `electric`) { + const txid = await this.persistDeleteRows(rows) + if (txid === undefined) { + this.requireCollection().utils.acceptMutations(transaction) + return + } + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } + throw new Error(`WakeRegistry deleteRowsAction called before startup`) }, }) @@ -380,13 +323,21 @@ export class WakeRegistry { draft.timeoutConsumed = true }) }, - mutationFn: async (_input, { transaction }) => { + mutationFn: async ({ row }, { transaction }) => { if (this.mode === `local-test`) { this.requireCollection().utils.acceptMutations(transaction) return } + if (this.mode === `electric`) { + const txid = await this.persistTimeoutConsumed({ + ...row, + timeoutConsumed: true, + }) + await this.requireCollection().utils.awaitTxId(txid, 10_000) + return { txid } + } throw new Error( - `WakeRegistry markTimeoutConsumedAction runtime persistence is not initialized` + `WakeRegistry markTimeoutConsumedAction called before startup` ) }, }) @@ -408,13 +359,7 @@ export class WakeRegistry { throw new Error(`WakeRegistry tenantId is required in shared mode`) } - private cacheKey(tenantId: string, sourceUrl: string): string { - return `${tenantId}:${sourceUrl}` - } - - private registrationKey( - reg: CachedWakeRegistration | WakeRegistrationCollectionRow - ): string { + private registrationKey(reg: WakeRegistrationCollectionRow): string { return [ reg.tenantId, reg.subscriberUrl, @@ -440,101 +385,50 @@ export class WakeRegistry { } async startSync(electricUrl: string, electricSecret?: string): Promise { - if (this.syncReadyPromise) { - await this.syncReadyPromise + if (this.registrationsCollection) { + await this.registrationsCollection.preload() return } - this.syncElectricUrl = electricUrl - this.syncElectricSecret = electricSecret - - const abortController = new AbortController() - const stream = new ShapeStream({ - url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(), - params: { - table: `wake_registrations`, - ...(this.tenantId - ? { where: `tenant_id = ${sqlStringLiteral(this.tenantId)}` } - : {}), - ...(electricSecret ? { secret: electricSecret } : {}), - columns: [ - `id`, - `tenant_id`, - `subscriber_url`, - `source_url`, - `condition`, - `debounce_ms`, - `timeout_ms`, - `one_shot`, - `timeout_consumed`, - `include_response`, - `manifest_key`, - `created_at`, - ], - replica: `full`, - }, - parser: { - timestamptz: (value: string) => new Date(value), - }, - signal: abortController.signal, - onError: (error) => { - if (abortController.signal.aborted) { - return {} - } - if (this.syncReadyPromise) { - void this.recoverSync(error, `shape stream error`) - } - return {} - }, - }) - - this.syncAbortController = abortController - this.syncReadyPromise = new Promise((resolve, reject) => { - let settled = false - - this.syncUnsubscribe = stream.subscribe( - async (messages) => { - try { - for (const message of messages) { - await this.applyShapeMessage(message) - if ( - !settled && - isControlMessage(message) && - message.headers.control === `up-to-date` - ) { - settled = true - resolve() - } - } - } catch (error) { - if (!settled) { - settled = true - reject(error) - return - } - serverLog.error( - `[wake-registry] failed to apply shape change:`, - error - ) - } + this.mode = `electric` + this.registrationsCollection = createCollection( + electricCollectionOptions({ + id: `wake-registrations:${this.tenantId ?? `all`}`, + getKey: (row: any) => row.id as number, + shapeOptions: { + url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(), + params: { + table: `wake_registrations`, + ...(this.tenantId + ? { where: `tenant_id = ${sqlStringLiteral(this.tenantId)}` } + : {}), + ...(electricSecret ? { secret: electricSecret } : {}), + columns: [ + `id`, + `tenant_id`, + `subscriber_url`, + `source_url`, + `condition`, + `debounce_ms`, + `timeout_ms`, + `one_shot`, + `timeout_consumed`, + `include_response`, + `manifest_key`, + `created_at`, + ], + replica: `full`, + }, + parser: { + timestamptz: (value: string) => new Date(value), + }, + columnMapper: snakeCamelMapper(), }, - (error) => { - if (!settled) { - settled = true - reject(error) - return - } - void this.recoverSync(error, `subscription error`) - } - ) - }) + } as any) + ) as any - try { - await this.syncReadyPromise - } catch (error) { - await this.stopSync() - throw error - } + await this.requireCollection().preload() + this.startRegistrationEffect() } async stopSync(): Promise { @@ -542,11 +436,6 @@ export class WakeRegistry { this.registrationsEffect = null this.registrationsCollection = null this.mode = `unstarted` - this.syncUnsubscribe?.() - this.syncUnsubscribe = null - this.syncAbortController?.abort() - this.syncAbortController = null - this.syncReadyPromise = null this.resetRuntimeState() } @@ -565,89 +454,130 @@ export class WakeRegistry { this.timeoutDelivered.clear() } - private async recoverSync( - error: unknown, - source: `shape stream error` | `subscription error` - ): Promise { - if (this.syncRecoveryPromise) { - return this.syncRecoveryPromise - } - - const electricUrl = this.syncElectricUrl - if (!electricUrl) { - serverLog.error( - `[wake-registry] Electric sync failed (${source}):`, - error - ) - return + private async allocateRuntimeId(): Promise { + const rows = await this.db.execute( + sql<{ + id: string + }>`select nextval('wake_registrations_id_seq')::text as id` + ) + const value = Array.isArray(rows) + ? rows[0]?.id + : ((rows as any).rows?.[0]?.id ?? (rows as any)[0]?.id) + const id = Number(value) + if (!Number.isInteger(id)) { + throw new Error(`Failed to allocate wake registration id`) } + return id + } - this.syncRecoveryPromise = (async () => { - serverLog.error( - `[wake-registry] Electric sync failed (${source}):`, - error - ) + private async persistInsert( + row: WakeRegistrationCollectionRow + ): Promise { + return await this.db.transaction(async (tx) => { + const rows = await tx + .insert(wakeRegistrations) + .values({ + id: row.id, + tenantId: row.tenantId, + subscriberUrl: row.subscriberUrl, + sourceUrl: row.sourceUrl, + condition: row.condition, + debounceMs: row.debounceMs, + timeoutMs: row.timeoutMs, + oneShot: row.oneShot, + timeoutConsumed: row.timeoutConsumed, + includeResponse: row.includeResponse, + manifestKey: row.manifestKey, + createdAt: row.createdAt, + }) + .onConflictDoNothing() + .returning({ txid: sql`pg_current_xact_id()::xid::text` }) + return rows[0]?.txid === undefined ? undefined : Number(rows[0].txid) + }) + } - await this.stopSync() - await this.loadRegistrations() + private async currentTxidInTransaction(tx: any): Promise { + const rows = await tx.execute( + sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid` + ) + return Array.isArray(rows) + ? rows[0]!.txid + : ((rows as any).rows?.[0]?.txid ?? (rows as any)[0]!.txid) + } - try { - await this.startSync(electricUrl, this.syncElectricSecret) - serverLog.info(`[wake-registry] Electric sync recovered`) - } catch (recoveryError) { - serverLog.error( - `[wake-registry] Electric sync recovery failed:`, - recoveryError + private async persistTimeoutConsumed( + row: WakeRegistrationCollectionRow + ): Promise { + return await this.db.transaction(async (tx) => { + const rows = await tx + .update(wakeRegistrations) + .set({ timeoutConsumed: row.timeoutConsumed }) + .where( + and( + eq(wakeRegistrations.tenantId, row.tenantId), + eq(wakeRegistrations.id, row.id) + ) ) - } finally { - this.syncRecoveryPromise = null + .returning({ txid: sql`pg_current_xact_id()::xid::text` }) + return Number(rows[0]?.txid ?? (await this.currentTxidInTransaction(tx))) + }) + } + + private async persistDeleteRows( + rows: Array + ): Promise { + if (rows.length === 0) return undefined + return await this.db.transaction(async (tx) => { + let txid: string | undefined + for (const row of rows) { + const deleted = await tx + .delete(wakeRegistrations) + .where( + and( + eq(wakeRegistrations.tenantId, row.tenantId), + eq(wakeRegistrations.id, row.id) + ) + ) + .returning({ txid: sql`pg_current_xact_id()::xid::text` }) + txid = deleted[0]?.txid ?? txid } - })() + return txid === undefined ? undefined : Number(txid) + }) + } - return this.syncRecoveryPromise + private registrationMatches( + row: WakeRegistrationCollectionRow, + reg: WakeRegistration, + tenantId: string + ): boolean { + return ( + row.tenantId === tenantId && + row.subscriberUrl === reg.subscriberUrl && + row.sourceUrl === reg.sourceUrl && + JSON.stringify(row.condition) === JSON.stringify(reg.condition) && + row.debounceMs === (reg.debounceMs ?? 0) && + row.timeoutMs === (reg.timeoutMs ?? 0) && + row.oneShot === reg.oneShot && + row.manifestKey === (reg.manifestKey ?? null) + ) } async register(reg: WakeRegistration): Promise { const tenantId = this.resolveTenantId(reg.tenantId) if (this.registrationsCollection) { - const id = this.allocateLocalId() - const tx = this.registerAction( - this.normalizeRegistration(reg, tenantId, id) + const existing = await this.rowsByPredicate((row) => + this.registrationMatches(row, reg, tenantId) ) - await tx.isPersisted.promise - return + if (existing.length > 0) return } - const result = await this.db - .insert(wakeRegistrations) - .values({ - tenantId, - subscriberUrl: reg.subscriberUrl, - sourceUrl: reg.sourceUrl, - condition: reg.condition, - debounceMs: reg.debounceMs ?? 0, - timeoutMs: reg.timeoutMs ?? 0, - oneShot: reg.oneShot, - includeResponse: reg.includeResponse !== false, - manifestKey: reg.manifestKey ?? null, - }) - .onConflictDoNothing() - .returning({ id: wakeRegistrations.id }) - - if (result.length === 0) { - // Another path (e.g. manifest-sync) may have created the row first. - // Refresh the cache so this process still sees the effective registration. - await this.loadRegistrations() - return - } - - const dbId = result[0]!.id - this.upsertCachedRegistration({ - ...reg, - tenantId, - dbId, - createdAt: new Date(), - timeoutConsumed: false, - }) + const id = + this.mode === `electric` + ? await this.allocateRuntimeId() + : this.allocateLocalId() + const tx = this.registerAction( + this.normalizeRegistration(reg, tenantId, id) + ) + await tx.isPersisted.promise } private startTimeoutTimer(reg: WakeRegistrationCollectionRow): void { @@ -727,22 +657,6 @@ export class WakeRegistry { eq(wakeRegistrations.manifestKey, manifestKey) ) ) - - const toRemove = Array.from(this.registrationCache.values()).flatMap( - (regs) => - regs - .filter( - (r) => - r.tenantId === resolvedTenantId && - r.subscriberUrl === subscriberUrl && - r.manifestKey === manifestKey - ) - .map((r) => r.dbId) - ) - - for (const dbId of toRemove) { - this.removeCachedRegistrationByDbId(dbId) - } } async unregisterBySubscriber( @@ -777,20 +691,6 @@ export class WakeRegistry { eq(wakeRegistrations.subscriberUrl, subscriberUrl) ) ) - - const toRemove = Array.from(this.registrationCache.values()).flatMap( - (regs) => - regs - .filter( - (r) => - r.tenantId === resolvedTenantId && - r.subscriberUrl === subscriberUrl - ) - .map((r) => r.dbId) - ) - for (const dbId of toRemove) { - this.removeCachedRegistrationByDbId(dbId) - } } async unregisterBySource( @@ -817,15 +717,6 @@ export class WakeRegistry { eq(wakeRegistrations.sourceUrl, sourceUrl) ) ) - - const key = this.cacheKey(resolvedTenantId, sourceUrl) - const regs = this.registrationCache.get(key) - if (regs) { - for (const reg of [...regs]) { - this.removeCachedRegistrationByDbId(reg.dbId) - } - this.registrationCache.delete(key) - } } async unregisterBySubscriberAndSource( @@ -864,51 +755,6 @@ export class WakeRegistry { eq(wakeRegistrations.sourceUrl, sourceUrl) ) ) - - const regs = this.registrationCache.get( - this.cacheKey(resolvedTenantId, sourceUrl) - ) - if (regs) { - const toRemove = regs - .filter( - (r) => - r.tenantId === resolvedTenantId && r.subscriberUrl === subscriberUrl - ) - .map((r) => r.dbId) - for (const dbId of toRemove) { - this.removeCachedRegistrationByDbId(dbId) - } - } - } - - async loadRegistrations(): Promise { - const rows = - this.tenantId === null - ? await this.db.select().from(wakeRegistrations) - : await this.db - .select() - .from(wakeRegistrations) - .where(eq(wakeRegistrations.tenantId, this.tenantId)) - - this.resetCachedRegistrations() - - for (const row of rows) { - const reg: CachedWakeRegistration = { - tenantId: row.tenantId, - subscriberUrl: row.subscriberUrl, - sourceUrl: row.sourceUrl, - condition: row.condition as WakeRegistration[`condition`], - debounceMs: row.debounceMs || undefined, - timeoutMs: row.timeoutMs || undefined, - oneShot: row.oneShot, - includeResponse: row.includeResponse === false ? false : undefined, - manifestKey: row.manifestKey ?? undefined, - dbId: row.id, - createdAt: row.createdAt, - timeoutConsumed: row.timeoutConsumed, - } - this.upsertCachedRegistration(reg) - } } private startTimeoutTimerWithDuration( @@ -941,102 +787,14 @@ export class WakeRegistry { } } - private clearRegistrationState( - reg: CachedWakeRegistration | WakeRegistrationCollectionRow - ): void { + private clearRegistrationState(reg: WakeRegistrationCollectionRow): void { const timerKey = this.registrationKey(reg) this.clearDebounceState(timerKey) this.clearTimeoutState(timerKey) } - private resetCachedRegistrations(): void { - for (const timer of this.debounceTimers.values()) { - clearTimeout(timer) - } - this.debounceTimers.clear() - this.debounceBuffers.clear() - this.debounceRunStatus.clear() - - for (const timer of this.timeoutTimers.values()) { - clearTimeout(timer) - } - this.timeoutTimers.clear() - this.registrationCache.clear() - } - - private replaceCachedRegistrations( - registrations: Array - ): void { - this.resetCachedRegistrations() - for (const registration of registrations) { - this.upsertCachedRegistration(registration) - } - } - - private findCachedRegistration( - dbId: number - ): { cacheKey: string; index: number; reg: CachedWakeRegistration } | null { - for (const [cacheKey, regs] of this.registrationCache) { - const index = regs.findIndex((reg) => reg.dbId === dbId) - if (index >= 0) { - return { - cacheKey, - index, - reg: regs[index]!, - } - } - } - - return null - } - - private upsertCachedRegistration(reg: CachedWakeRegistration): void { - const existing = this.findCachedRegistration(reg.dbId) - const nextKey = this.registrationKey(reg) - - if (existing) { - const previousKey = this.registrationKey(existing.reg) - const regs = this.registrationCache.get(existing.cacheKey) - if (regs) { - regs.splice(existing.index, 1) - if (regs.length === 0) { - this.registrationCache.delete(existing.cacheKey) - } - } - if (previousKey !== nextKey) { - this.clearRegistrationState(existing.reg) - } - } - - const cacheKey = this.cacheKey(reg.tenantId, reg.sourceUrl) - const cached = this.registrationCache.get(cacheKey) ?? [] - cached.push(reg) - this.registrationCache.set(cacheKey, cached) - this.syncTimeoutTimer(reg) - } - - private removeCachedRegistrationByDbId(dbId: number): void { - const existing = this.findCachedRegistration(dbId) - if (!existing) return - - this.clearRegistrationState(existing.reg) - this.timeoutDelivered.delete(dbId) - - const regs = this.registrationCache.get(existing.cacheKey) - if (!regs) return - regs.splice(existing.index, 1) - if (regs.length === 0) { - this.registrationCache.delete(existing.cacheKey) - } - } - - private syncTimeoutTimer( - registration: CachedWakeRegistration | WakeRegistrationCollectionRow - ): void { - const reg = - `dbId` in registration - ? this.cachedRegistrationAsCollectionRow(registration) - : registration + private syncTimeoutTimer(registration: WakeRegistrationCollectionRow): void { + const reg = registration const timerKey = this.registrationKey(reg) if (reg.timeoutConsumed || reg.timeoutMs <= 0) { @@ -1076,13 +834,7 @@ export class WakeRegistry { } private syncTenantTimeoutTimers(tenantId: string): void { - for (const regs of this.registrationCache.values()) { - for (const reg of regs) { - if (reg.tenantId === tenantId) { - this.syncTimeoutTimer(reg) - } - } - } + void this.syncTenantCollectionTimeoutTimers(tenantId) } private async syncTenantCollectionTimeoutTimers( @@ -1117,71 +869,13 @@ export class WakeRegistry { } } - private normalizeShapeRow( - row: WakeRegistrationShapeRow - ): CachedWakeRegistration { - return { - tenantId: - (row as { tenant_id?: string }).tenant_id ?? this.resolveTenantId(), - subscriberUrl: row.subscriber_url, - sourceUrl: row.source_url, - condition: row.condition, - debounceMs: row.debounce_ms || undefined, - timeoutMs: row.timeout_ms || undefined, - oneShot: row.one_shot, - includeResponse: row.include_response === false ? false : undefined, - manifestKey: row.manifest_key ?? undefined, - dbId: row.id, - createdAt: row.created_at, - timeoutConsumed: row.timeout_consumed, - } - } - - private async applyShapeMessage( - message: Message - ): Promise { - if (isControlMessage(message)) { - if (message.headers.control === `must-refetch`) { - this.resetCachedRegistrations() - } - return - } - - if (!isChangeMessage(message)) { - return - } - - if (message.headers.operation === `delete`) { - // Shape keys are protocol-level identifiers and are not guaranteed to be - // the table primary key. The wake_registrations shape uses - // `replica: full`, so deletes should carry the deleted row in old_value; - // use that row id to remove the matching in-memory registration. If the - // id is unavailable, reset the cache so we fail closed rather than - // keeping a stale wake registration alive. - const oldValue = ( - message as unknown as { - old_value?: { id?: unknown } - } - ).old_value - const oldId = Number(oldValue?.id) - if (Number.isFinite(oldId)) { - this.removeCachedRegistrationByDbId(oldId) - } else { - this.resetCachedRegistrations() - } - return - } - - this.upsertCachedRegistration(this.normalizeShapeRow(message.value)) - } - async evaluate( sourceUrl: string, event: Record, tenantId?: string ): Promise> { const resolvedTenantId = this.resolveTenantId(tenantId) - if (this.registrationsCollection) { + { const queriedRegs = await queryOnce((q) => q .from({ reg: this.requireCollection() }) @@ -1285,103 +979,7 @@ export class WakeRegistry { return results } - const cacheKey = this.cacheKey(resolvedTenantId, sourceUrl) - const regs = this.registrationCache.get(cacheKey) - if (!regs || regs.length === 0) return [] - - const results: Array = [] - const toRemove: Array = [] - - for (let i = 0; i < regs.length; i++) { - const reg = regs[i]! - const match = this.matchCondition(reg, event) - if (!match) continue - - const timerKey = this.registrationKey(reg) - const timeoutTimer = this.timeoutTimers.get(timerKey) - if (timeoutTimer) { - clearTimeout(timeoutTimer) - this.timeoutTimers.delete(timerKey) - void this.markTimeoutConsumed(reg.dbId, reg.tenantId) - } - - if (reg.debounceMs != null && reg.debounceMs > 0) { - const buffer = this.debounceBuffers.get(timerKey) ?? [] - buffer.push(match.change) - this.debounceBuffers.set(timerKey, buffer) - - // Preserve the latest runFinished status for debounced delivery - if (match.runFinishedStatus) { - this.debounceRunStatus.set(timerKey, match.runFinishedStatus) - } - - const existing = this.debounceTimers.get(timerKey) - if (existing) clearTimeout(existing) - - const timer = setTimeout(() => { - this.debounceTimers.delete(timerKey) - const flushed = this.debounceBuffers.get(timerKey) - if (flushed && flushed.length > 0) { - this.debounceBuffers.delete(timerKey) - const runStatus = this.debounceRunStatus.get(timerKey) - this.debounceRunStatus.delete(timerKey) - this.deliverDebounce({ - tenantId: reg.tenantId, - subscriberUrl: reg.subscriberUrl, - registrationDbId: reg.dbId, - sourceEventKey: flushed[flushed.length - 1]!.key, - wakeMessage: { - source: sourceUrl, - timeout: false, - changes: flushed, - }, - runFinishedStatus: runStatus, - includeResponse: reg.includeResponse, - }) - } - }, reg.debounceMs) - this.debounceTimers.set(timerKey, timer) - } else { - results.push({ - tenantId: reg.tenantId, - subscriberUrl: reg.subscriberUrl, - registrationDbId: reg.dbId, - sourceEventKey: wakeSourceEventId(event), - wakeMessage: { - source: sourceUrl, - timeout: false, - changes: [match.change], - }, - runFinishedStatus: match.runFinishedStatus, - includeResponse: reg.includeResponse, - }) - } - - if (reg.oneShot) { - toRemove.push(i) - } - } - - for (let j = toRemove.length - 1; j >= 0; j--) { - const removed = regs.splice(toRemove[j]!, 1) - if (removed[0]) { - this.clearRegistrationState(removed[0]) - this.timeoutDelivered.delete(removed[0].dbId) - void this.db - .delete(wakeRegistrations) - .where( - and( - eq(wakeRegistrations.tenantId, removed[0].tenantId), - eq(wakeRegistrations.id, removed[0].dbId) - ) - ) - } - } - if (regs.length === 0) { - this.registrationCache.delete(cacheKey) - } - - return results + return [] } /** Flush any pending debounce buffers for a subscriber and return them. */ diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index d2998ac25c..a92ea6480d 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -1,155 +1,54 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { createDb } from '../src/db' +import { wakeRegistrations } from '../src/db/schema' import { WakeRegistry } from '../src/wake-registry' - -const { shapeStreamState } = vi.hoisted(() => ({ - shapeStreamState: { - latest: null as null | { - emit: (messages: Array>) => Promise - signal?: AbortSignal - }, - }, -})) - -vi.mock(`@electric-sql/client`, () => ({ - isControlMessage: (message: { headers?: Record }) => - typeof message.headers?.control === `string`, - isChangeMessage: (message: { headers?: Record }) => - typeof message.headers?.operation === `string`, - ShapeStream: class MockShapeStream { - private onMessages: - | ((messages: Array>) => Promise | void) - | null = null - - constructor(options: { signal?: AbortSignal }) { - shapeStreamState.latest = { - signal: options.signal, - emit: async (messages) => { - await this.onMessages?.(messages) - }, - } - } - - subscribe( - callback: (messages: Array>) => Promise, - _onError?: (error: Error) => void - ): () => void { - this.onMessages = callback - return () => { - this.onMessages = null - } - } - }, -})) - -function createMockDb(): any { - return { - insert: () => ({ - values: () => ({ - onConflictDoNothing: () => ({ - returning: () => Promise.resolve([{ id: 1 }]), - }), - }), - }), - delete: () => ({ - where: () => Promise.resolve(), - }), - update: () => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - }), - select: () => ({ - from: () => Promise.resolve([]), - }), - } -} - -describe(`WakeRegistry Electric sync`, () => { - it(`ignores malformed shape messages without headers while waiting for up-to-date`, async () => { - const registry = new WakeRegistry(createMockDb()) - - const startPromise = registry.startSync(`http://electric.test`) - - await expect( - shapeStreamState.latest!.emit([ - { - key: `ignored-malformed-message`, - }, - { - headers: { - control: `up-to-date`, - }, - }, - ]) - ).resolves.toBeUndefined() - - await expect(startPromise).resolves.toBeUndefined() - - await registry.stopSync() +import { + TEST_ELECTRIC_URL, + TEST_POSTGRES_URL, + resetElectricAgentsTestBackend, +} from './test-backend' + +const connection = createDb(TEST_POSTGRES_URL) +const db = connection.db + +describe(`WakeRegistry Electric collection sync`, () => { + beforeAll(async () => { + await resetElectricAgentsTestBackend() }) - it(`hydrates and updates the cache from shape changes`, async () => { - const registry = new WakeRegistry(createMockDb()) + afterAll(async () => { + await connection.client.end() + }) - const startPromise = registry.startSync(`http://electric.test`) + it(`syncs a registered wake through Postgres and Electric`, async () => { + const registry = new WakeRegistry(db as any) + await registry.startSync(TEST_ELECTRIC_URL) - await shapeStreamState.latest!.emit([ - { - key: `1`, - value: { - id: 1, - subscriber_url: `/parent/p1`, - source_url: `/child/c1`, - condition: `runFinished`, - debounce_ms: 0, - timeout_ms: 0, - one_shot: false, - timeout_consumed: false, - include_response: true, - manifest_key: null, - created_at: new Date(), - }, - headers: { - operation: `insert`, - }, - }, - { - headers: { - control: `up-to-date`, - }, - }, - ]) + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/c1`, + condition: `runFinished`, + oneShot: false, + }) - await startPromise + const rows = await db + .select() + .from(wakeRegistrations) + .where(eq(wakeRegistrations.sourceUrl, `/child/c1`)) - expect( - await registry.evaluate(`/child/c1`, { - type: `run`, - key: `run-1`, - value: { status: `completed` }, - headers: { operation: `update` }, - }) - ).toHaveLength(1) + expect(rows).toHaveLength(1) - await shapeStreamState.latest!.emit([ - { - key: `1`, - headers: { - operation: `delete`, - }, - }, - ]) + const results = await registry.evaluate(`/child/c1`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) - expect( - await registry.evaluate(`/child/c1`, { - type: `run`, - key: `run-2`, - value: { status: `completed` }, - headers: { operation: `update` }, - }) - ).toHaveLength(0) + expect(results).toHaveLength(1) + expect(results[0]!.registrationDbId).toBe(rows[0]!.id) await registry.stopSync() - expect(shapeStreamState.latest!.signal?.aborted).toBe(true) }) }) From c74e29e7dd2e9617662c06aef35ea7db7dcf9f3e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 11:32:44 -0600 Subject: [PATCH 058/105] Require Electric URL for wake registry startup --- packages/agents-server/src/entity-manager.ts | 2 +- packages/agents-server/src/host.ts | 2 +- packages/agents-server/test/host.test.ts | 5 ++ .../test/pg-sync-wake-delivery.test.ts | 3 + .../agents-server/test/server-start.test.ts | 21 ++++- .../agents-server/test/wake-registry.test.ts | 82 +++++++++---------- 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index 61145285b6..e7d67ad029 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -431,7 +431,7 @@ export class EntityManager { electricSecret?: string ): Promise { if (!electricUrl) { - throw new Error(`Electric URL is required to start WakeRegistry`) + throw new Error(`WakeRegistry runtime requires an Electric URL`) } await this.wakeRegistry.startSync(electricUrl, electricSecret) } diff --git a/packages/agents-server/src/host.ts b/packages/agents-server/src/host.ts index ac5888f2b8..21d281b614 100644 --- a/packages/agents-server/src/host.ts +++ b/packages/agents-server/src/host.ts @@ -114,7 +114,7 @@ export class AgentsHost { try { if (!this.electricUrl) { - throw new Error(`Electric URL is required to start WakeRegistry`) + throw new Error(`WakeRegistry runtime requires an Electric URL`) } await this.wakeRegistry.startSync(this.electricUrl, this.electricSecret) if (this.startEntityBridgeManager) { diff --git a/packages/agents-server/test/host.test.ts b/packages/agents-server/test/host.test.ts index 3ac40d548a..903b62a90f 100644 --- a/packages/agents-server/test/host.test.ts +++ b/packages/agents-server/test/host.test.ts @@ -86,8 +86,11 @@ describe(`AgentsHost`, () => { const host = new AgentsHost({ db: createMockDb(), pgClient: vi.fn() as any, + electricUrl: `http://electric.test`, }) + vi.spyOn(host.wakeRegistry, `startSync`).mockResolvedValue(undefined) + const runtime = await host.registerTenant({ serviceId: `svc-before-start`, durableStreamsUrl: `https://streams.test/v1/streams/svc-before-start`, @@ -98,6 +101,8 @@ describe(`AgentsHost`, () => { const loadTenantBridges = vi .spyOn(host.entityProjector, `loadTenantBridges`) .mockResolvedValue(undefined) + vi.spyOn(host.entityProjector, `start`).mockResolvedValue(undefined) + vi.spyOn(host.entityProjector, `stop`).mockResolvedValue(undefined) vi.spyOn(host.scheduler, `start`).mockResolvedValue(undefined) vi.spyOn(host.scheduler, `stop`).mockResolvedValue(undefined) vi.spyOn(host.tagStreamOutboxDrainer, `start`).mockImplementation(() => {}) diff --git a/packages/agents-server/test/pg-sync-wake-delivery.test.ts b/packages/agents-server/test/pg-sync-wake-delivery.test.ts index 11085294b7..7a0559abd1 100644 --- a/packages/agents-server/test/pg-sync-wake-delivery.test.ts +++ b/packages/agents-server/test/pg-sync-wake-delivery.test.ts @@ -31,6 +31,7 @@ function event(operation: `insert` | `update` | `delete`, key = operation) { describe(`pgSync wake delivery matching`, () => { it(`insert wakes insert subscriber and delete does not wake insert-only subscriber`, async () => { const registry = new WakeRegistry(createDb() as any, `default`) + await registry.startLocalForTests() await registry.register({ subscriberUrl: `/horton/a`, sourceUrl: `/_electric/pg-sync/test`, @@ -59,6 +60,7 @@ describe(`pgSync wake delivery matching`, () => { it(`splits two subscribers on the same source by operation`, async () => { const registry = new WakeRegistry(createDb() as any, `default`) + await registry.startLocalForTests() await registry.register({ subscriberUrl: `/horton/a`, sourceUrl: `/_electric/pg-sync/test`, @@ -94,6 +96,7 @@ describe(`pgSync wake delivery matching`, () => { it(`filters pgSync events by collection`, async () => { const registry = new WakeRegistry(createDb() as any, `default`) + await registry.startLocalForTests() await registry.register({ subscriberUrl: `/horton/a`, sourceUrl: `/_electric/pg-sync/test`, diff --git a/packages/agents-server/test/server-start.test.ts b/packages/agents-server/test/server-start.test.ts index 20532f2819..067969bb55 100644 --- a/packages/agents-server/test/server-start.test.ts +++ b/packages/agents-server/test/server-start.test.ts @@ -147,9 +147,6 @@ vi.mock(`../src/wake-registry`, () => ({ stopSync(): Promise { return Promise.resolve() } - loadRegistrations(): Promise { - return Promise.resolve() - } }, })) @@ -279,6 +276,19 @@ describe(`ElectricAgentsServer.start`, () => { } }) + it(`fails host startup without Electric URL for wake registry sync`, async () => { + server = new ElectricAgentsServer({ + durableStreamsUrl: `http://durable.test`, + port: 0, + postgresUrl: TEST_POSTGRES_URL, + electricUrl: undefined, + }) + + await expect(server.start()).rejects.toThrow( + `WakeRegistry runtime requires an Electric URL` + ) + }) + it(`rejects startup and cleans up when scheduler startup fails`, async () => { schedulerStartMock.mockRejectedValueOnce(new Error(`scheduler exploded`)) @@ -286,6 +296,7 @@ describe(`ElectricAgentsServer.start`, () => { durableStreamsUrl: `http://durable.test`, port: 0, postgresUrl: TEST_POSTGRES_URL, + electricUrl: `http://electric.test`, }) await expect(server.start()).rejects.toThrow(`scheduler exploded`) @@ -313,6 +324,7 @@ describe(`ElectricAgentsServer.start`, () => { durableStreamsUrl: `http://durable.test`, port: 0, postgresUrl: `postgres://electric_agents:electric_agents@localhost:5432/electric_agents`, + electricUrl: `http://electric.test`, }) await expect(server.start()).resolves.toMatch(/^http:\/\//) @@ -354,6 +366,7 @@ describe(`ElectricAgentsServer.start`, () => { durableStreamsUrl: `http://durable.test`, port: 0, postgresUrl: `postgres://electric_agents:electric_agents@localhost:5432/electric_agents`, + electricUrl: `http://electric.test`, }) await expect(server.start()).resolves.toMatch(/^http:\/\//) @@ -376,6 +389,7 @@ describe(`ElectricAgentsServer.start`, () => { mockStreamFn: streamFn as any, port: 0, postgresUrl: TEST_POSTGRES_URL, + electricUrl: `http://electric.test`, }) await expect(server.start()).resolves.toMatch(/^http:\/\//) @@ -399,6 +413,7 @@ describe(`ElectricAgentsServer.start`, () => { mockStreamFn: streamFn as any, port: 0, postgresUrl: TEST_POSTGRES_URL, + electricUrl: `http://electric.test`, }) await expect(server.start()).resolves.toMatch(/^http:\/\//) diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 544a4f4f9c..3d5e787ed7 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -29,6 +29,14 @@ import { import type { Server } from 'node:http' import type { WakeEvalResult } from '../src/wake-registry' +async function createLocalRegistry( + tenantId?: string | null +): Promise { + const registry = new WakeRegistry(createMockDb(), tenantId) + await registry.startLocalForTests() + return registry +} + let nextDbId = 1 function createMockDb(): any { return { @@ -62,8 +70,7 @@ function createMockDb(): any { describe(`Wake Registry`, () => { it(`evaluates registrations from local TanStack DB collection`, async () => { - const registry = new WakeRegistry(createMockDb()) - await registry.startLocalForTests() + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, @@ -86,8 +93,7 @@ describe(`Wake Registry`, () => { }) it(`timeout effect delivers timeout wake once and marks row consumed`, async () => { - const registry = new WakeRegistry(createMockDb()) - await registry.startLocalForTests() + const registry = await createLocalRegistry() const delivered: Array = [] registry.setTimeoutCallback((result) => delivered.push(result)) @@ -109,8 +115,7 @@ describe(`Wake Registry`, () => { }) it(`local timeout effect does not consume timeout before callback is registered`, async () => { - const registry = new WakeRegistry(createMockDb()) - await registry.startLocalForTests() + const registry = await createLocalRegistry() const delivered: Array = [] await registry.register({ @@ -134,7 +139,7 @@ describe(`Wake Registry`, () => { }) it(`keeps shared registrations scoped by tenant`, async () => { - const registry = new WakeRegistry(createMockDb(), null) + const registry = await createLocalRegistry(null) await registry.register({ tenantId: `tenant-a`, subscriberUrl: `/tenant-a/parent`, @@ -168,7 +173,7 @@ describe(`Wake Registry`, () => { }) it(`dispatches shared timeout callbacks by tenant`, async () => { - const registry = new WakeRegistry(createMockDb(), null) + const registry = await createLocalRegistry(null) const tenantA: Array = [] const tenantB: Array = [] registry.setTimeoutCallback((result) => tenantA.push(result), `tenant-a`) @@ -200,7 +205,7 @@ describe(`Wake Registry`, () => { }) it(`does not consume shared timeout before tenant callback is registered`, async () => { - const registry = new WakeRegistry(createMockDb(), null) + const registry = await createLocalRegistry(null) const delivered: Array = [] await registry.register({ @@ -227,7 +232,7 @@ describe(`Wake Registry`, () => { }) it(`evaluates runFinished condition on completed run event`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -253,7 +258,7 @@ describe(`Wake Registry`, () => { }) it(`returns registrationDbId and sourceEventKey for immediate matches`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/_cron/abc`, @@ -274,7 +279,7 @@ describe(`Wake Registry`, () => { }) it(`removes cached registrations from shape delete old_value ids`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/_cron/abc`, @@ -306,7 +311,7 @@ describe(`Wake Registry`, () => { }) it(`keeps distinct registrations distinct for the same source event`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/_cron/abc`, @@ -335,7 +340,7 @@ describe(`Wake Registry`, () => { }) it(`runFinished remains active for repeated child completions`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -361,7 +366,7 @@ describe(`Wake Registry`, () => { }) it(`evaluates collection change condition`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/source/s1`, @@ -382,7 +387,7 @@ describe(`Wake Registry`, () => { }) it(`includes inbox message details in wake changes`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/agent/self`, sourceUrl: `/agent/self`, @@ -419,7 +424,7 @@ describe(`Wake Registry`, () => { }) it(`ignores events for non-matching collections`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/source/s1`, @@ -438,7 +443,7 @@ describe(`Wake Registry`, () => { }) it(`filters collection wakes by operation kind when ops is provided`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/source/s1`, @@ -473,7 +478,7 @@ describe(`Wake Registry`, () => { }) it(`maps pg-sync old_value from the event value into change.oldValue`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/watcher/w1`, sourceUrl: `/_electric/pg-sync/default/pg-source-1`, @@ -507,8 +512,7 @@ describe(`Wake Registry`, () => { }) it(`unregisterBySource removes all matching local collection rows`, async () => { - const registry = new WakeRegistry(createMockDb()) - await registry.startLocalForTests() + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/a`, @@ -550,8 +554,7 @@ describe(`Wake Registry`, () => { }) it(`oneShot match removes row before a second immediate evaluation`, async () => { - const registry = new WakeRegistry(createMockDb()) - await registry.startLocalForTests() + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, @@ -572,7 +575,7 @@ describe(`Wake Registry`, () => { }) it(`cleanup on subscriber deletion removes all registrations`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -606,7 +609,7 @@ describe(`Wake Registry`, () => { }) it(`surgical unregister removes only the targeted subscriber+source pair`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -663,7 +666,7 @@ describe(`Wake Registry`, () => { }) it(`rebuilds registry from register calls`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -697,7 +700,7 @@ describe(`Wake Registry`, () => { }) it(`evaluates shared-state wakes against the shared-state stream path`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/_electric/shared-state/board-1`, @@ -717,7 +720,7 @@ describe(`Wake Registry`, () => { }) it(`delivers timeout wake when timeoutMs expires`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() const delivered: Array = [] registry.setTimeoutCallback((result) => { delivered.push(result) @@ -742,7 +745,7 @@ describe(`Wake Registry`, () => { }) it(`debounce coalesces rapid events into single wake`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() const debounced: Array = [] registry.setDebounceCallback((result) => { debounced.push(result) @@ -776,7 +779,7 @@ describe(`Wake Registry`, () => { }) it(`keeps debounced registrations distinct for different conditions on the same source`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() const debounced: Array = [] registry.setDebounceCallback((result) => { debounced.push(result) @@ -831,7 +834,7 @@ describe(`Wake Registry`, () => { ).toBe(2) }) - it(`reloads wake registrations and retries runFinished evaluation on cache miss`, async () => { + it(`awaits wake registration evaluation results`, async () => { const wakeResult = { tenantId: `default`, subscriberUrl: `/parent/p1`, @@ -845,11 +848,7 @@ describe(`Wake Registry`, () => { runFinishedStatus: `completed`, } const wakeRegistry = { - evaluate: vi - .fn() - .mockReturnValueOnce([]) - .mockReturnValueOnce([wakeResult]), - loadRegistrations: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue([wakeResult]), } const deliverWakeResult = vi.fn().mockResolvedValue(undefined) const manager = { @@ -865,8 +864,7 @@ describe(`Wake Registry`, () => { headers: { operation: `update` }, }) - expect(wakeRegistry.loadRegistrations).toHaveBeenCalledTimes(1) - expect(wakeRegistry.evaluate).toHaveBeenCalledTimes(2) + expect(wakeRegistry.evaluate).toHaveBeenCalledTimes(1) expect(deliverWakeResult).toHaveBeenCalledWith(wakeResult) }) @@ -912,7 +910,7 @@ describe(`Wake Registry`, () => { }) it(`passes includeResponse through evaluate result`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -933,7 +931,7 @@ describe(`Wake Registry`, () => { }) it(`includeResponse defaults to undefined when not set`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, sourceUrl: `/child/c1`, @@ -953,7 +951,7 @@ describe(`Wake Registry`, () => { }) it(`debounced runFinished preserves runFinishedStatus and includeResponse`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() const delivered: Array = [] registry.setDebounceCallback((result) => { delivered.push(result) @@ -988,7 +986,7 @@ describe(`Wake Registry`, () => { }) it(`debounced multi-run: status matches changes[0] run key`, async () => { - const registry = new WakeRegistry(createMockDb()) + const registry = await createLocalRegistry() const delivered: Array = [] registry.setDebounceCallback((result) => { delivered.push(result) From 7a5037aca1d3a9ddf26dec3c77697c2151438d45 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 11:43:47 -0600 Subject: [PATCH 059/105] Verify TanStack DB wake registry behavior --- .changeset/fix-deferred-pull-wakes.md | 4 +- .../test/wake-registry-sync.test.ts | 57 ++++++++++--------- .../agents-server/test/wake-registry.test.ts | 50 ++++------------ 3 files changed, 45 insertions(+), 66 deletions(-) diff --git a/.changeset/fix-deferred-pull-wakes.md b/.changeset/fix-deferred-pull-wakes.md index 0d583fd2d9..12a601701f 100644 --- a/.changeset/fix-deferred-pull-wakes.md +++ b/.changeset/fix-deferred-pull-wakes.md @@ -3,4 +3,6 @@ '@electric-ax/agents-server': patch --- -Fix child wake delivery so multiple deferred wakes for the same entity stream are preserved and runFinished wake evaluation recovers from a stale server-side registration cache. +Fix child wake delivery so multiple deferred wakes for the same entity stream are preserved. + +Refactor the server wake registry to use TanStack DB collections and optimistic actions over `wake_registrations`, removing the manual ShapeStream-backed registration cache and stale-cache reload fallback. diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index a92ea6480d..19288978c9 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { eq } from 'drizzle-orm' import { createDb } from '../src/db' @@ -21,34 +22,38 @@ describe(`WakeRegistry Electric collection sync`, () => { await connection.client.end() }) - it(`syncs a registered wake through Postgres and Electric`, async () => { - const registry = new WakeRegistry(db as any) - await registry.startSync(TEST_ELECTRIC_URL) - - await registry.register({ - subscriberUrl: `/parent/p1`, - sourceUrl: `/child/c1`, - condition: `runFinished`, - oneShot: false, - }) - + it(`syncs wake rows from Postgres through Electric`, async () => { + const suffix = randomUUID() + const subscriberUrl = `/parent/sync-${suffix}` + const sourceUrl = `/child/sync-${suffix}` const rows = await db - .select() - .from(wakeRegistrations) - .where(eq(wakeRegistrations.sourceUrl, `/child/c1`)) + .insert(wakeRegistrations) + .values({ + subscriberUrl, + sourceUrl, + condition: `runFinished`, + oneShot: false, + }) + .returning() - expect(rows).toHaveLength(1) - - const results = await registry.evaluate(`/child/c1`, { - type: `run`, - key: `run-1`, - value: { status: `completed` }, - headers: { operation: `update` }, - }) - - expect(results).toHaveLength(1) - expect(results[0]!.registrationDbId).toBe(rows[0]!.id) + const registry = new WakeRegistry(db as any) + await registry.startSync(TEST_ELECTRIC_URL) - await registry.stopSync() + try { + const results = await registry.evaluate(sourceUrl, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(1) + expect(results[0]!.registrationDbId).toBe(rows[0]!.id) + } finally { + await registry.stopSync() + await db + .delete(wakeRegistrations) + .where(eq(wakeRegistrations.sourceUrl, sourceUrl)) + } }) }) diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 3d5e787ed7..11b8087e5d 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -225,6 +225,7 @@ describe(`Wake Registry`, () => { (result) => delivered.push(result), `tenant-late` ) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(delivered).toHaveLength(1) expect(delivered[0]!.tenantId).toBe(`tenant-late`) @@ -278,38 +279,6 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`insert:tick-7`) }) - it(`removes cached registrations from shape delete old_value ids`, async () => { - const registry = await createLocalRegistry() - await registry.register({ - subscriberUrl: `/watcher/w1`, - sourceUrl: `/_cron/abc`, - condition: { on: `change` }, - oneShot: false, - }) - - const before = await registry.evaluate(`/_cron/abc`, { - type: `cron_tick`, - key: `tick-7`, - value: {}, - headers: { operation: `insert` }, - }) - expect(before).toHaveLength(1) - - await (registry as any).applyShapeMessage({ - key: `shape-key-is-not-the-registration-id`, - old_value: { id: before[0]!.registrationDbId }, - headers: { operation: `delete` }, - }) - - const after = await registry.evaluate(`/_cron/abc`, { - type: `cron_tick`, - key: `tick-8`, - value: {}, - headers: { operation: `insert` }, - }) - expect(after).toHaveLength(0) - }) - it(`keeps distinct registrations distinct for the same source event`, async () => { const registry = await createLocalRegistry() await registry.register({ @@ -642,7 +611,7 @@ describe(`Wake Registry`, () => { expect(r2).toHaveLength(1) }) - it(`register() rejects when DB insert fails so callers can catch`, async () => { + it(`local register does not require a backing DB insert`, async () => { const failingDb = { ...createMockDb(), insert: () => ({ @@ -654,6 +623,7 @@ describe(`Wake Registry`, () => { }), } const registry = new WakeRegistry(failingDb) + await registry.startLocalForTests() await expect( registry.register({ @@ -662,7 +632,7 @@ describe(`Wake Registry`, () => { condition: `runFinished`, oneShot: false, }) - ).rejects.toThrow(`connection refused`) + ).resolves.toBeUndefined() }) it(`rebuilds registry from register calls`, async () => { @@ -930,7 +900,7 @@ describe(`Wake Registry`, () => { expect(results[0]!.includeResponse).toBe(false) }) - it(`includeResponse defaults to undefined when not set`, async () => { + it(`includeResponse defaults to true when not set`, async () => { const registry = await createLocalRegistry() await registry.register({ subscriberUrl: `/parent/p1`, @@ -947,7 +917,7 @@ describe(`Wake Registry`, () => { }) expect(results).toHaveLength(1) - expect(results[0]!.includeResponse).toBeUndefined() + expect(results[0]!.includeResponse).toBe(true) }) it(`debounced runFinished preserves runFinishedStatus and includeResponse`, async () => { @@ -1208,9 +1178,11 @@ describe(`Wake Registry Integration`, () => { const wakeEvents = await waitForWakeEvents(parent.streams.main, 3) expect(wakeEvents).toHaveLength(3) - expect(new Set(wakeEvents.map((event) => event.value?.source))).toEqual( - new Set(children.map((child) => child.url)) - ) + expect( + new Set( + wakeEvents.map((event) => (event.value as { source?: string }).source) + ) + ).toEqual(new Set(children.map((child) => child.url))) }, 15_000) it(`spawn with wake registers condition and delivers wake on child run completion`, async () => { From 72b306ab409b7da101d8a2e570f125baa63c4521 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 12:21:04 -0600 Subject: [PATCH 060/105] Fix wake registry conflict cleanup --- packages/agents-server/src/wake-registry.ts | 233 ++++++++++-------- .../agents-server/test/wake-registry.test.ts | 41 +++ 2 files changed, 167 insertions(+), 107 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 50a293562b..c83dcce94c 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -17,6 +17,20 @@ import { DEFAULT_TENANT_ID } from './tenant.js' import type { DrizzleDB } from './db/index.js' import type { Collection } from '@tanstack/db' +class WakeRegistrationConflictError extends Error { + constructor() { + super(`Wake registration insert conflicted with an existing row`) + this.name = `WakeRegistrationConflictError` + } +} + +class WakeRegistrationStaleError extends Error { + constructor() { + super(`Wake registration row was no longer present`) + this.name = `WakeRegistrationStaleError` + } +} + export interface WakeRegistration { tenantId?: string subscriberUrl: string @@ -278,8 +292,7 @@ export class WakeRegistry { if (this.mode === `electric`) { const txid = await this.persistInsert(row) if (txid === undefined) { - this.requireCollection().utils.acceptMutations(transaction) - return + throw new WakeRegistrationConflictError() } await this.requireCollection().utils.awaitTxId(txid, 10_000) return { txid } @@ -333,6 +346,9 @@ export class WakeRegistry { ...row, timeoutConsumed: true, }) + if (txid === undefined) { + throw new WakeRegistrationStaleError() + } await this.requireCollection().utils.awaitTxId(txid, 10_000) return { txid } } @@ -496,18 +512,9 @@ export class WakeRegistry { }) } - private async currentTxidInTransaction(tx: any): Promise { - const rows = await tx.execute( - sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid` - ) - return Array.isArray(rows) - ? rows[0]!.txid - : ((rows as any).rows?.[0]?.txid ?? (rows as any)[0]!.txid) - } - private async persistTimeoutConsumed( row: WakeRegistrationCollectionRow - ): Promise { + ): Promise { return await this.db.transaction(async (tx) => { const rows = await tx .update(wakeRegistrations) @@ -519,7 +526,7 @@ export class WakeRegistry { ) ) .returning({ txid: sql`pg_current_xact_id()::xid::text` }) - return Number(rows[0]?.txid ?? (await this.currentTxidInTransaction(tx))) + return rows[0]?.txid === undefined ? undefined : Number(rows[0].txid) }) } @@ -577,7 +584,12 @@ export class WakeRegistry { const tx = this.registerAction( this.normalizeRegistration(reg, tenantId, id) ) - await tx.isPersisted.promise + try { + await tx.isPersisted.promise + } catch (error) { + if (error instanceof WakeRegistrationConflictError) return + throw error + } } private startTimeoutTimer(reg: WakeRegistrationCollectionRow): void { @@ -607,7 +619,12 @@ export class WakeRegistry { ) as WakeRegistrationCollectionRow | undefined if (!normalized) return const tx = this.markTimeoutConsumedAction({ row: normalized }) - await tx.isPersisted.promise + try { + await tx.isPersisted.promise + } catch (error) { + if (error instanceof WakeRegistrationStaleError) return + throw error + } return } await this.db @@ -875,111 +892,113 @@ export class WakeRegistry { tenantId?: string ): Promise> { const resolvedTenantId = this.resolveTenantId(tenantId) - { - const queriedRegs = await queryOnce((q) => - q - .from({ reg: this.requireCollection() }) - .where(({ reg }) => - dbAnd( - dbEq(reg.tenantId, resolvedTenantId), - dbEq(reg.sourceUrl, sourceUrl) - ) + const queriedRegs = await queryOnce((q) => + q + .from({ reg: this.requireCollection() }) + .where(({ reg }) => + dbAnd( + dbEq(reg.tenantId, resolvedTenantId), + dbEq(reg.sourceUrl, sourceUrl) ) - ) - const regs = queriedRegs.map( - (queriedReg) => - ((queriedReg as { reg?: WakeRegistrationCollectionRow }).reg ?? - queriedReg) as WakeRegistrationCollectionRow - ) - if (regs.length === 0) return [] - - const results: Array = [] - const oneShotRows: Array = [] + ) + ) + const regs = queriedRegs.map( + (queriedReg) => + ((queriedReg as { reg?: WakeRegistrationCollectionRow }).reg ?? + queriedReg) as WakeRegistrationCollectionRow + ) + if (regs.length === 0) return [] - for (const reg of regs) { - const match = this.matchCondition(reg, event) - if (!match) continue + const results: Array = [] + const oneShotRows: Array = [] - const timerKey = this.registrationKey(reg) - const timeoutTimer = this.timeoutTimers.get(timerKey) - if (timeoutTimer) { - clearTimeout(timeoutTimer) - this.timeoutTimers.delete(timerKey) - void this.markTimeoutConsumed(reg.id, reg.tenantId) - } + for (const reg of regs) { + const match = this.matchCondition(reg, event) + if (!match) continue - if (reg.debounceMs > 0) { - const buffer = this.debounceBuffers.get(timerKey) ?? [] - buffer.push(match.change) - this.debounceBuffers.set(timerKey, buffer) + const timerKey = this.registrationKey(reg) + const timeoutTimer = this.timeoutTimers.get(timerKey) + if (timeoutTimer) { + clearTimeout(timeoutTimer) + this.timeoutTimers.delete(timerKey) + void this.markTimeoutConsumed(reg.id, reg.tenantId).catch((error) => { + console.warn( + `[wake-registry] failed to persist timeout consumption:`, + error + ) + }) + } - if (match.runFinishedStatus) { - this.debounceRunStatus.set(timerKey, match.runFinishedStatus) - } + if (reg.debounceMs > 0) { + const buffer = this.debounceBuffers.get(timerKey) ?? [] + buffer.push(match.change) + this.debounceBuffers.set(timerKey, buffer) - const existing = this.debounceTimers.get(timerKey) - if (existing) clearTimeout(existing) - - const timer = setTimeout(() => { - this.debounceTimers.delete(timerKey) - const flushed = this.debounceBuffers.get(timerKey) - if (flushed && flushed.length > 0) { - this.debounceBuffers.delete(timerKey) - const runStatus = this.debounceRunStatus.get(timerKey) - this.debounceRunStatus.delete(timerKey) - this.deliverDebounce({ - tenantId: reg.tenantId, - subscriberUrl: reg.subscriberUrl, - registrationDbId: reg.id, - sourceEventKey: flushed[flushed.length - 1]!.key, - wakeMessage: { - source: sourceUrl, - timeout: false, - changes: flushed, - }, - runFinishedStatus: runStatus, - includeResponse: reg.includeResponse, - }) - } - }, reg.debounceMs) - this.debounceTimers.set(timerKey, timer) - } else { - results.push({ - tenantId: reg.tenantId, - subscriberUrl: reg.subscriberUrl, - registrationDbId: reg.id, - sourceEventKey: wakeSourceEventId(event), - wakeMessage: { - source: sourceUrl, - timeout: false, - changes: [match.change], - }, - runFinishedStatus: match.runFinishedStatus, - includeResponse: reg.includeResponse, - }) + if (match.runFinishedStatus) { + this.debounceRunStatus.set(timerKey, match.runFinishedStatus) } - if (reg.oneShot) { - oneShotRows.push(reg) - } + const existing = this.debounceTimers.get(timerKey) + if (existing) clearTimeout(existing) + + const timer = setTimeout(() => { + this.debounceTimers.delete(timerKey) + const flushed = this.debounceBuffers.get(timerKey) + if (flushed && flushed.length > 0) { + this.debounceBuffers.delete(timerKey) + const runStatus = this.debounceRunStatus.get(timerKey) + this.debounceRunStatus.delete(timerKey) + this.deliverDebounce({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: flushed[flushed.length - 1]!.key, + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: flushed, + }, + runFinishedStatus: runStatus, + includeResponse: reg.includeResponse, + }) + } + }, reg.debounceMs) + this.debounceTimers.set(timerKey, timer) + } else { + results.push({ + tenantId: reg.tenantId, + subscriberUrl: reg.subscriberUrl, + registrationDbId: reg.id, + sourceEventKey: wakeSourceEventId(event), + wakeMessage: { + source: sourceUrl, + timeout: false, + changes: [match.change], + }, + runFinishedStatus: match.runFinishedStatus, + includeResponse: reg.includeResponse, + }) } - if (oneShotRows.length > 0) { - const tx = this.deleteRowsAction({ - rows: oneShotRows, - persist: { kind: `oneShot` }, - }) - void tx.isPersisted.promise.catch((error) => { - console.warn( - `[wake-registry] failed to persist one-shot cleanup:`, - error - ) - }) + if (reg.oneShot) { + oneShotRows.push(reg) } + } - return results + if (oneShotRows.length > 0) { + const tx = this.deleteRowsAction({ + rows: oneShotRows, + persist: { kind: `oneShot` }, + }) + void tx.isPersisted.promise.catch((error) => { + console.warn( + `[wake-registry] failed to persist one-shot cleanup:`, + error + ) + }) } - return [] + + return results } /** Flush any pending debounce buffers for a subscriber and return them. */ diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 11b8087e5d..c03f8e655d 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -92,6 +92,47 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`update:run-1`) }) + it(`drops optimistic register row when runtime insert conflicts invisibly`, async () => { + const registry = await createLocalRegistry() + ;(registry as any).mode = `electric` + ;(registry as any).allocateRuntimeId = async () => 42 + ;(registry as any).persistInsert = async () => undefined + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/conflict`, + condition: `runFinished`, + oneShot: false, + }) + + const results = await registry.evaluate(`/child/conflict`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(0) + }) + + it(`does not await an invisible txid when runtime timeout update matches no rows`, async () => { + const registry = await createLocalRegistry() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/stale-timeout`, + condition: `runFinished`, + oneShot: false, + timeoutMs: 1_000, + }) + ;(registry as any).mode = `electric` + ;(registry as any).persistTimeoutConsumed = async () => undefined + + await expect( + (registry as any).markTimeoutConsumed(1, `default`) + ).resolves.toBeUndefined() + }) + it(`timeout effect delivers timeout wake once and marks row consumed`, async () => { const registry = await createLocalRegistry() const delivered: Array = [] From 96c74aa895544a209691dec0a4df9a430e6e0fbb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 13:57:57 -0600 Subject: [PATCH 061/105] Stabilize wake registry verification --- packages/agents-server/src/wake-registry.ts | 2 +- .../test/horton-pull-wake-e2e.test.ts | 4 +- .../test/wake-registry-sync.test.ts | 41 ++++++++++++------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index c83dcce94c..5edb9379b1 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -409,7 +409,7 @@ export class WakeRegistry { this.mode = `electric` this.registrationsCollection = createCollection( electricCollectionOptions({ - id: `wake-registrations:${this.tenantId ?? `all`}`, + id: `wake-registrations:${this.tenantId ?? `all`}:${electricUrlWithPath(electricUrl, `/v1/shape`).toString()}`, getKey: (row: any) => row.id as number, shapeOptions: { url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(), diff --git a/packages/agents-server/test/horton-pull-wake-e2e.test.ts b/packages/agents-server/test/horton-pull-wake-e2e.test.ts index 17bfd5ea79..66640a02ae 100644 --- a/packages/agents-server/test/horton-pull-wake-e2e.test.ts +++ b/packages/agents-server/test/horton-pull-wake-e2e.test.ts @@ -10,6 +10,7 @@ import { waitFor, } from './test-utils' import { + TEST_ELECTRIC_URL, TEST_POSTGRES_URL, resetElectricAgentsTestBackend, } from './test-backend' @@ -266,7 +267,7 @@ describe(`pull-wake Horton e2e with mocked LLM`, () => { durableStreamsUrl: durableStreamTestServerUrl(dsServer.url), port: 0, postgresUrl: TEST_POSTGRES_URL, - electricUrl: undefined, + electricUrl: TEST_ELECTRIC_URL, authenticateRequest: (req) => req.headers.get(`authorization`) === authHeaders.authorization ? testPrincipal @@ -276,6 +277,7 @@ describe(`pull-wake Horton e2e with mocked LLM`, () => { streamBaseUrl = electricAgentsServer.streamClient.baseUrl builtinAgentsServer = new BuiltinAgentsServer({ agentServerUrl: baseUrl, + durableStreamsFetchCache: false, mockStreamFn, pullWake: { runnerId, diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index 19288978c9..b10cbae40f 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -4,22 +4,28 @@ import { eq } from 'drizzle-orm' import { createDb } from '../src/db' import { wakeRegistrations } from '../src/db/schema' import { WakeRegistry } from '../src/wake-registry' -import { - TEST_ELECTRIC_URL, - TEST_POSTGRES_URL, - resetElectricAgentsTestBackend, -} from './test-backend' +import { configureElectricAgentsTestBackendEnv } from './test-backend-env' +import type { createDb as createDbType } from '../src/db' -const connection = createDb(TEST_POSTGRES_URL) -const db = connection.db +configureElectricAgentsTestBackendEnv(`agent-server-sync`, 10) + +type DbConnection = ReturnType + +let connection: DbConnection +let db: DbConnection[`db`] +let testElectricUrl: string describe(`WakeRegistry Electric collection sync`, () => { beforeAll(async () => { - await resetElectricAgentsTestBackend() - }) + const backend = await import(`./test-backend`) + await backend.resetElectricAgentsTestBackend() + testElectricUrl = backend.TEST_ELECTRIC_URL + connection = createDb(backend.TEST_POSTGRES_URL) + db = connection.db + }, 60_000) afterAll(async () => { - await connection.client.end() + await connection?.client.end() }) it(`syncs wake rows from Postgres through Electric`, async () => { @@ -37,15 +43,22 @@ describe(`WakeRegistry Electric collection sync`, () => { .returning() const registry = new WakeRegistry(db as any) - await registry.startSync(TEST_ELECTRIC_URL) + await registry.startSync(testElectricUrl) try { - const results = await registry.evaluate(sourceUrl, { + let results: Awaited> = [] + const event = { type: `run`, key: `run-1`, value: { status: `completed` }, headers: { operation: `update` }, - }) + } + const deadline = Date.now() + 5_000 + do { + results = await registry.evaluate(sourceUrl, event) + if (results.length > 0) break + await new Promise((resolve) => setTimeout(resolve, 50)) + } while (Date.now() < deadline) expect(results).toHaveLength(1) expect(results[0]!.registrationDbId).toBe(rows[0]!.id) @@ -55,5 +68,5 @@ describe(`WakeRegistry Electric collection sync`, () => { .delete(wakeRegistrations) .where(eq(wakeRegistrations.sourceUrl, sourceUrl)) } - }) + }, 15_000) }) From 1d1a85f61832bcd763f6b2576d97062bbb1ccd6d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 14:39:27 -0600 Subject: [PATCH 062/105] Remove skill content max truncation --- packages/agents-runtime/src/skills/extract-meta.ts | 5 ----- packages/agents-runtime/src/skills/preamble.ts | 6 ------ packages/agents-runtime/src/skills/tools.ts | 14 +------------- packages/agents-runtime/src/skills/types.ts | 1 - .../test/skills/context-loader.test.ts | 4 ---- .../agents-runtime/test/skills/preamble.test.ts | 4 ---- packages/agents-runtime/test/skills/tools.test.ts | 14 ++++++-------- 7 files changed, 7 insertions(+), 41 deletions(-) diff --git a/packages/agents-runtime/src/skills/extract-meta.ts b/packages/agents-runtime/src/skills/extract-meta.ts index 05a69fac98..f5044f1c71 100644 --- a/packages/agents-runtime/src/skills/extract-meta.ts +++ b/packages/agents-runtime/src/skills/extract-meta.ts @@ -7,11 +7,8 @@ interface ExtractedMeta { arguments?: Array argumentHint?: string userInvocable?: boolean - max: number } -const DEFAULT_MAX = 10_000 - export async function extractSkillMeta( name: string, content: string @@ -26,7 +23,6 @@ export async function extractSkillMeta( ...(preamble.arguments && { arguments: preamble.arguments }), ...(preamble.argumentHint && { argumentHint: preamble.argumentHint }), ...(preamble.userInvocable && { userInvocable: true }), - max: preamble.max ?? DEFAULT_MAX, } } @@ -35,7 +31,6 @@ export async function extractSkillMeta( whenToUse: preamble.whenToUse ?? `User asks about ${humanize(name).toLowerCase()}`, keywords: preamble.keywords ?? [name], - max: preamble.max ?? DEFAULT_MAX, } } diff --git a/packages/agents-runtime/src/skills/preamble.ts b/packages/agents-runtime/src/skills/preamble.ts index 34c6335227..f6c2d84485 100644 --- a/packages/agents-runtime/src/skills/preamble.ts +++ b/packages/agents-runtime/src/skills/preamble.ts @@ -5,7 +5,6 @@ export interface PreambleFields { arguments?: Array argumentHint?: string userInvocable?: boolean - max?: number } export function parsePreamble(content: string): PreambleFields { @@ -82,11 +81,6 @@ export function parsePreamble(content: string): PreambleFields { case `user-invocable`: result.userInvocable = rawValue === `true` break - case `max`: { - const num = parseInt(rawValue, 10) - if (!Number.isNaN(num) && num > 0) result.max = num - break - } } } diff --git a/packages/agents-runtime/src/skills/tools.ts b/packages/agents-runtime/src/skills/tools.ts index fb512ea1a8..0c05d220f6 100644 --- a/packages/agents-runtime/src/skills/tools.ts +++ b/packages/agents-runtime/src/skills/tools.ts @@ -12,7 +12,6 @@ function skillContextId(name: string): string { export interface LoadSkillResult { loaded: boolean alreadyLoaded?: boolean - truncated?: boolean chars?: number message: string contextSource?: string @@ -50,12 +49,6 @@ export async function loadSkillIntoContext( } } - let truncated = false - if (content.length > meta.max) { - truncated = true - content = content.slice(0, meta.max) - } - if (args) { content = substituteArgs(content, args, meta.arguments) } @@ -67,9 +60,6 @@ export async function loadSkillIntoContext( }) const skillDir = path.join(path.dirname(meta.source), name) - const truncNote = truncated - ? `\n\nWARNING: Content was truncated from ${meta.charCount.toLocaleString()} to ${meta.max.toLocaleString()} chars. Inform the user.` - : `` const allRefFiles = listRefFiles(skillDir) const mdFiles = allRefFiles.filter((f) => f.endsWith(`.md`)) @@ -96,11 +86,10 @@ export async function loadSkillIntoContext( const dirNote = hasRefDir ? `\nSkill directory: ${skillDir}` : `` const refSection = refContents.length > 0 ? `\n\n${refContents.join(`\n\n`)}` : `` - const contextSource = `SKILL ACTIVATED: "${name}". The instructions below override your default behavior. Follow them exactly. Do not read any files to find this content — it is all here.\n${dirNote}${truncNote}\n\n${content}${refSection}` + const contextSource = `SKILL ACTIVATED: "${name}". The instructions below override your default behavior. Follow them exactly. Do not read any files to find this content — it is all here.\n${dirNote}\n\n${content}${refSection}` return { loaded: true, - truncated, chars: content.length, message: contextSource, contextSource, @@ -136,7 +125,6 @@ export function createSkillTools( ...(result.alreadyLoaded ? { alreadyLoaded: result.alreadyLoaded } : {}), - ...(result.truncated ? { truncated: result.truncated } : {}), ...(result.chars !== undefined ? { chars: result.chars } : {}), }, } diff --git a/packages/agents-runtime/src/skills/types.ts b/packages/agents-runtime/src/skills/types.ts index dcbc4ec1e1..0b94d7af39 100644 --- a/packages/agents-runtime/src/skills/types.ts +++ b/packages/agents-runtime/src/skills/types.ts @@ -6,7 +6,6 @@ export interface SkillMeta { arguments?: Array argumentHint?: string userInvocable?: boolean - max: number charCount: number contentHash: string source: string diff --git a/packages/agents-runtime/test/skills/context-loader.test.ts b/packages/agents-runtime/test/skills/context-loader.test.ts index e7e3e0b5a1..8deded9a00 100644 --- a/packages/agents-runtime/test/skills/context-loader.test.ts +++ b/packages/agents-runtime/test/skills/context-loader.test.ts @@ -17,7 +17,6 @@ function createRegistry(): SkillsRegistry { whenToUse: `User wants a tutorial`, keywords: [`quickstart`], userInvocable: true, - max: 1000, charCount: 100, contentHash: `hash-1`, source: `/skills/quickstart.md`, @@ -33,7 +32,6 @@ function createRegistry(): SkillsRegistry { arguments: [`project_name`, `Bad-Arg`], argumentHint: `[project-name]`, userInvocable: true, - max: 1000, charCount: 100, contentHash: `hash-2`, source: `/skills/init.md`, @@ -47,7 +45,6 @@ function createRegistry(): SkillsRegistry { whenToUse: `Never directly`, keywords: [], userInvocable: false, - max: 1000, charCount: 100, contentHash: `hash-3`, source: `/skills/internal.md`, @@ -61,7 +58,6 @@ function createRegistry(): SkillsRegistry { whenToUse: `Never directly`, keywords: [], userInvocable: true, - max: 1000, charCount: 100, contentHash: `hash-4`, source: `/skills/bad.md`, diff --git a/packages/agents-runtime/test/skills/preamble.test.ts b/packages/agents-runtime/test/skills/preamble.test.ts index 79cf4a2cb4..b6c6f7311f 100644 --- a/packages/agents-runtime/test/skills/preamble.test.ts +++ b/packages/agents-runtime/test/skills/preamble.test.ts @@ -7,7 +7,6 @@ describe(`parsePreamble`, () => { description: Interactive tutorial guide whenToUse: User asks about tutorials or getting started keywords: [tutorial, multi-agent, spawn] -max: 15000 --- # Tutorial content here` @@ -17,7 +16,6 @@ max: 15000 description: `Interactive tutorial guide`, whenToUse: `User asks about tutorials or getting started`, keywords: [`tutorial`, `multi-agent`, `spawn`], - max: 15000, }) }) @@ -69,7 +67,6 @@ keywords: - getting started - learn - multi-agent -max: 10000 --- # Content here` @@ -81,7 +78,6 @@ max: 10000 `learn`, `multi-agent`, ]) - expect(result.max).toBe(10000) }) it(`strips surrounding quotes from description and whenToUse`, () => { diff --git a/packages/agents-runtime/test/skills/tools.test.ts b/packages/agents-runtime/test/skills/tools.test.ts index 0bb3574543..ffe831a045 100644 --- a/packages/agents-runtime/test/skills/tools.test.ts +++ b/packages/agents-runtime/test/skills/tools.test.ts @@ -39,7 +39,6 @@ const QUICKSTART_META: SkillMeta = { description: `A quickstart`, whenToUse: `When learning`, keywords: [`quickstart`], - max: 10_000, charCount: 500, contentHash: `abc123`, source: `/skills/quickstart.md`, @@ -112,9 +111,9 @@ describe(`skill tools`, () => { }) }) - it(`use_skill truncates and warns when content exceeds max`, async () => { + it(`use_skill loads full content for large skills`, async () => { const bigContent = `x`.repeat(15_000) - const meta = { ...QUICKSTART_META, max: 10_000, charCount: 15_000 } + const meta = { ...QUICKSTART_META, charCount: 15_000 } const registry = createMockRegistry({ quickstart: { meta, content: bigContent }, }) @@ -125,13 +124,12 @@ describe(`skill tools`, () => { const result = await useTool.execute(`tc1`, { name: `quickstart` }) const insertedContent = ctx.insertContext.mock.calls[0]![1].content - // Content is truncated to max (10,000) — no wrapper prefix in insertContext - expect(insertedContent).toContain(`x`.repeat(100)) - expect(insertedContent.length).toBe(10_000) - // Tool result contains truncation warning + expect(insertedContent).toBe(bigContent) + expect(result.details).toMatchObject({ loaded: true, chars: 15_000 }) + expect(result.details).not.toHaveProperty(`truncated`) expect(result.content[0]).toMatchObject({ type: `text`, - text: expect.stringContaining(`truncated`), + text: expect.not.stringContaining(`truncated`), }) }) From 6ce6c446ba21fc6849c8e872c834eed618cf5406 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 14:40:18 -0600 Subject: [PATCH 063/105] Add changeset for skill content truncation --- .changeset/remove-skill-content-max.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/remove-skill-content-max.md diff --git a/.changeset/remove-skill-content-max.md b/.changeset/remove-skill-content-max.md new file mode 100644 index 0000000000..807994964e --- /dev/null +++ b/.changeset/remove-skill-content-max.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Remove the skill content `max` cap so `use_skill` loads complete skill instructions instead of truncating them. From e73142d02441fc0da983e3b3e1ef2faa54ed6807 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 14:45:02 -0600 Subject: [PATCH 064/105] Avoid resetting backend in wake sync test --- .../test/wake-registry-sync.test.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index b10cbae40f..60a0b4ae9c 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -4,23 +4,21 @@ import { eq } from 'drizzle-orm' import { createDb } from '../src/db' import { wakeRegistrations } from '../src/db/schema' import { WakeRegistry } from '../src/wake-registry' -import { configureElectricAgentsTestBackendEnv } from './test-backend-env' -import type { createDb as createDbType } from '../src/db' +import { + TEST_ELECTRIC_URL, + TEST_POSTGRES_URL, + ensureElectricAgentsTestBackend, +} from './test-backend' -configureElectricAgentsTestBackendEnv(`agent-server-sync`, 10) - -type DbConnection = ReturnType +type DbConnection = ReturnType let connection: DbConnection let db: DbConnection[`db`] -let testElectricUrl: string describe(`WakeRegistry Electric collection sync`, () => { beforeAll(async () => { - const backend = await import(`./test-backend`) - await backend.resetElectricAgentsTestBackend() - testElectricUrl = backend.TEST_ELECTRIC_URL - connection = createDb(backend.TEST_POSTGRES_URL) + await ensureElectricAgentsTestBackend() + connection = createDb(TEST_POSTGRES_URL) db = connection.db }, 60_000) @@ -43,7 +41,7 @@ describe(`WakeRegistry Electric collection sync`, () => { .returning() const registry = new WakeRegistry(db as any) - await registry.startSync(testElectricUrl) + await registry.startSync(TEST_ELECTRIC_URL) try { let results: Awaited> = [] From 48109a9c62894943108c67bbf550ab6c094fb2b5 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 15:15:33 -0600 Subject: [PATCH 065/105] Stabilize wake registration visibility --- packages/agents-server/src/wake-registry.ts | 56 ++++++++++++++----- .../test/scheduler-integration.test.ts | 19 +++++-- .../agents-server/test/wake-registry.test.ts | 1 + 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 5edb9379b1..74b4a2179c 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -18,7 +18,7 @@ import type { DrizzleDB } from './db/index.js' import type { Collection } from '@tanstack/db' class WakeRegistrationConflictError extends Error { - constructor() { + constructor(readonly row: WakeRegistrationCollectionRow) { super(`Wake registration insert conflicted with an existing row`) this.name = `WakeRegistrationConflictError` } @@ -140,6 +140,8 @@ function sqlStringLiteral(value: string): string { return `'${value.replace(/'/g, `''`)}'` } +let nextWakeRegistryCollectionInstance = 1 + export class WakeRegistry { private db: DrizzleDB private registrationsCollection: Collection< @@ -160,6 +162,7 @@ export class WakeRegistry { private timeoutDelivered = new Set() private timeoutCallbacks = new Map() private debounceCallbacks = new Map() + private readonly collectionInstance = nextWakeRegistryCollectionInstance++ constructor( db: DrizzleDB, @@ -292,7 +295,7 @@ export class WakeRegistry { if (this.mode === `electric`) { const txid = await this.persistInsert(row) if (txid === undefined) { - throw new WakeRegistrationConflictError() + throw new WakeRegistrationConflictError(row) } await this.requireCollection().utils.awaitTxId(txid, 10_000) return { txid } @@ -409,7 +412,7 @@ export class WakeRegistry { this.mode = `electric` this.registrationsCollection = createCollection( electricCollectionOptions({ - id: `wake-registrations:${this.tenantId ?? `all`}:${electricUrlWithPath(electricUrl, `/v1/shape`).toString()}`, + id: `wake-registrations:${this.tenantId ?? `all`}:${electricUrlWithPath(electricUrl, `/v1/shape`).toString()}:${this.collectionInstance}`, getKey: (row: any) => row.id as number, shapeOptions: { url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(), @@ -552,23 +555,47 @@ export class WakeRegistry { }) } + private registrationRowsMatch( + row: WakeRegistrationCollectionRow, + other: WakeRegistrationCollectionRow + ): boolean { + return ( + row.tenantId === other.tenantId && + row.subscriberUrl === other.subscriberUrl && + row.sourceUrl === other.sourceUrl && + JSON.stringify(row.condition) === JSON.stringify(other.condition) && + row.debounceMs === other.debounceMs && + row.timeoutMs === other.timeoutMs && + row.oneShot === other.oneShot && + row.manifestKey === other.manifestKey + ) + } + private registrationMatches( row: WakeRegistrationCollectionRow, reg: WakeRegistration, tenantId: string ): boolean { - return ( - row.tenantId === tenantId && - row.subscriberUrl === reg.subscriberUrl && - row.sourceUrl === reg.sourceUrl && - JSON.stringify(row.condition) === JSON.stringify(reg.condition) && - row.debounceMs === (reg.debounceMs ?? 0) && - row.timeoutMs === (reg.timeoutMs ?? 0) && - row.oneShot === reg.oneShot && - row.manifestKey === (reg.manifestKey ?? null) + return this.registrationRowsMatch( + row, + this.normalizeRegistration(reg, tenantId, row.id) ) } + private async waitForRegistrationVisible( + row: WakeRegistrationCollectionRow, + timeoutMs = 10_000 + ): Promise { + const deadline = Date.now() + timeoutMs + do { + const rows = await this.rowsByPredicate((candidate) => + this.registrationRowsMatch(candidate, row) + ) + if (rows.length > 0) return + await new Promise((resolve) => setTimeout(resolve, 25)) + } while (Date.now() < deadline) + } + async register(reg: WakeRegistration): Promise { const tenantId = this.resolveTenantId(reg.tenantId) if (this.registrationsCollection) { @@ -587,7 +614,10 @@ export class WakeRegistry { try { await tx.isPersisted.promise } catch (error) { - if (error instanceof WakeRegistrationConflictError) return + if (error instanceof WakeRegistrationConflictError) { + await this.waitForRegistrationVisible(error.row) + return + } throw error } } diff --git a/packages/agents-server/test/scheduler-integration.test.ts b/packages/agents-server/test/scheduler-integration.test.ts index af5ba79fbb..aedfca5e0f 100644 --- a/packages/agents-server/test/scheduler-integration.test.ts +++ b/packages/agents-server/test/scheduler-integration.test.ts @@ -7,11 +7,13 @@ import { readStreamEvents, waitFor, } from './test-utils' -import { - TEST_ELECTRIC_URL, - TEST_POSTGRES_URL, - resetElectricAgentsTestBackend, -} from './test-backend' +import { configureElectricAgentsTestBackendEnv } from './test-backend-env' + +configureElectricAgentsTestBackendEnv(`agent-server-scheduler`, 20) + +let TEST_ELECTRIC_URL = `` +let TEST_POSTGRES_URL = `` +let resetElectricAgentsTestBackend: () => Promise describe(`Scheduler Integration`, () => { let dsServer: DurableStreamTestServer @@ -85,6 +87,11 @@ describe(`Scheduler Integration`, () => { } beforeAll(async () => { + const backend = await import(`./test-backend`) + TEST_ELECTRIC_URL = backend.TEST_ELECTRIC_URL + TEST_POSTGRES_URL = backend.TEST_POSTGRES_URL + resetElectricAgentsTestBackend = backend.resetElectricAgentsTestBackend + dsServer = new DurableStreamTestServer({ port: 0, longPollTimeout: 500, @@ -289,7 +296,7 @@ describe(`Scheduler Integration`, () => { }), } ) - expect(scheduleRes.status).toBe(200) + expect(scheduleRes.status, await scheduleRes.clone().text()).toBe(200) await waitFor( async () => { diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index c03f8e655d..4434b75222 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -97,6 +97,7 @@ describe(`Wake Registry`, () => { ;(registry as any).mode = `electric` ;(registry as any).allocateRuntimeId = async () => 42 ;(registry as any).persistInsert = async () => undefined + ;(registry as any).waitForRegistrationVisible = async () => {} await registry.register({ subscriberUrl: `/parent/p1`, From 89292f7d4188edc4bea5da51bc8ba11207d2fc5a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 14:35:37 -0600 Subject: [PATCH 066/105] Fail started runs on handler errors --- packages/agents-runtime/src/process-wake.ts | 45 +++++++++++- .../agents-runtime/test/process-wake.test.ts | 70 ++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 8ed0484dad..abbec6acbd 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -179,12 +179,14 @@ function withRegisteredManifestEntry( } } -async function latestNewRunKey( +async function latestStartedNewRunKey( db: EntityStreamDBWithActions, existingRunKeys: ReadonlySet ): Promise { const runs = await queryOnce((q) => q.from({ runs: db.collections.runs })) - return runs.filter((run) => !existingRunKeys.has(run.key)).at(-1)?.key + return runs + .filter((run) => !existingRunKeys.has(run.key) && run.status === `started`) + .at(-1)?.key } async function resolveHeadersProvider( @@ -551,7 +553,29 @@ export async function processWake( failBackgroundWake(error, `WRITE_FAILED`) }, }) + const producedRunStatuses = new Map< + string, + `started` | `completed` | `failed` + >() + const producedRunOrder: Array = [] const writeEvent = (event: ChangeEvent): void => { + if ( + event.type === `run` && + event.value && + typeof event.value === `object` + ) { + const status = (event.value as { status?: unknown }).status + if ( + status === `started` || + status === `completed` || + status === `failed` + ) { + if (!producedRunStatuses.has(event.key)) { + producedRunOrder.push(event.key) + } + producedRunStatuses.set(event.key, status) + } + } producer.append(JSON.stringify(event)) } @@ -2285,7 +2309,22 @@ export async function processWake( ? setupErr.code : `HANDLER_FAILED` log.error(`handler failed for ${entityUrl}:`, errMsg) - const failedRunKey = await latestNewRunKey(db, existingRunKeys) + const failedRunKey = + (await latestStartedNewRunKey(db, existingRunKeys)) ?? + [...producedRunOrder] + .reverse() + .find((key) => producedRunStatuses.get(key) === `started`) + if (failedRunKey) { + writeEvent( + entityStateSchema.runs.update({ + key: failedRunKey, + value: { + status: `failed`, + finish_reason: `error`, + } as never, + }) as ChangeEvent + ) + } writeEvent( entityStateSchema.errors.insert({ key: `error-${epoch}-${crypto.randomUUID()}`, diff --git a/packages/agents-runtime/test/process-wake.test.ts b/packages/agents-runtime/test/process-wake.test.ts index 49c3a5460f..2b29ef346b 100644 --- a/packages/agents-runtime/test/process-wake.test.ts +++ b/packages/agents-runtime/test/process-wake.test.ts @@ -208,7 +208,9 @@ vi.mock(`../src/entity-stream-db`, () => ({ ? errors : event.type === `signal` ? signals - : undefined + : event.type === `run` + ? runs + : undefined if (!collection) { return } @@ -505,6 +507,72 @@ describe(`processWake`, () => { fetchMock.mockRestore() }) + it(`marks a newly-started run failed when the handler throws before ending it`, async () => { + defineEntity(`test-agent`, { + handler: (ctx) => { + ctx.recordRun() + throw new Error(`boom after run start`) + }, + }) + + await expect(processWake(makeNotification(), BASE_CONFIG)).rejects.toThrow( + `boom after run start` + ) + + const events = mockProducerAppend.mock.calls.map(([body]) => + JSON.parse(String(body)) + ) as Array + const startedRun = events.find( + (event) => + event.type === `run` && + event.headers.operation === `insert` && + (event.value as { status?: string }).status === `started` + ) + + expect(startedRun).toBeDefined() + expect(events).toContainEqual( + expect.objectContaining({ + type: `run`, + key: startedRun!.key, + headers: expect.objectContaining({ operation: `update` }), + value: expect.objectContaining({ + status: `failed`, + finish_reason: `error`, + }), + }) + ) + }) + + it(`does not mark a completed run failed when later handler cleanup throws`, async () => { + defineEntity(`test-agent`, { + handler: (ctx) => { + ctx.db.collections.runs.insert({ + key: `externally-visible-run`, + status: `completed`, + finish_reason: `stop`, + }) + throw new Error(`boom after completed run`) + }, + }) + + await expect(processWake(makeNotification(), BASE_CONFIG)).rejects.toThrow( + `boom after completed run` + ) + + const events = mockProducerAppend.mock.calls.map(([body]) => + JSON.parse(String(body)) + ) as Array + + expect(events).not.toContainEqual( + expect.objectContaining({ + type: `run`, + key: `externally-visible-run`, + headers: expect.objectContaining({ operation: `update` }), + value: expect.objectContaining({ status: `failed` }), + }) + ) + }) + it(`returns null without acking for unknown entity types`, async () => { // No entity type registered — runtime should silently bail const result = await processWake(makeNotification(), BASE_CONFIG) From e6b9c582f9d7802ece6debd08b468c31a7e8444a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 14:36:29 -0600 Subject: [PATCH 067/105] Add changeset for started run failures --- .changeset/fail-started-agent-runs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fail-started-agent-runs.md diff --git a/.changeset/fail-started-agent-runs.md b/.changeset/fail-started-agent-runs.md new file mode 100644 index 0000000000..d0a86f8ff7 --- /dev/null +++ b/.changeset/fail-started-agent-runs.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Mark newly-started agent runs as failed when a wake handler errors before ending them, preventing chat UIs from showing "Thinking" indefinitely after runtime failures. From e70a1e1f829baedd441b963cefd1e13fd1014ef3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 15:39:21 -0600 Subject: [PATCH 068/105] Tolerate wake registration tx visibility lag --- packages/agents-server/src/wake-registry.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 74b4a2179c..739ae85304 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -297,7 +297,17 @@ export class WakeRegistry { if (txid === undefined) { throw new WakeRegistrationConflictError(row) } - await this.requireCollection().utils.awaitTxId(txid, 10_000) + try { + await this.requireCollection().utils.awaitTxId(txid, 10_000) + } catch (error) { + if ( + error instanceof Error && + error.name === `TimeoutWaitingForTxIdError` + ) { + return { txid } + } + throw error + } return { txid } } throw new Error(`WakeRegistry registerAction called before startup`) From 9bb4d388c540f07b96c0238734222fa8188749aa Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 15:45:44 -0600 Subject: [PATCH 069/105] Handle concurrent cron stream creation --- packages/agents-server/src/entity-manager.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index e7d67ad029..26d9c972f1 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -4078,9 +4078,19 @@ export class EntityManager { // Ensure the backing stream exists const exists = await this.streamClient.exists(streamPath) if (!exists) { - await this.streamClient.create(streamPath, { - contentType: `application/json`, - }) + try { + await this.streamClient.create(streamPath, { + contentType: `application/json`, + }) + } catch (err) { + const status = + err instanceof Error && `status` in err + ? (err as { status?: unknown }).status + : undefined + if (status !== 409 && !/\b409\b/.test(String(err))) { + throw err + } + } } const fireAt = getNextCronFireAt(spec.expression, spec.timezone) From 613758eb3251720d661e782e5a7a57b910c2fe64 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 15:57:15 -0600 Subject: [PATCH 070/105] Isolate scheduler integration backend --- packages/agents-server/src/wake-registry.ts | 19 ++++-- .../test/scheduler-integration.test.ts | 8 ++- .../agents-server/test/test-backend-env.ts | 33 ++++++---- packages/agents-server/test/test-backend.ts | 60 ++++++++++++------- 4 files changed, 82 insertions(+), 38 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 739ae85304..d7f01c0374 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -483,6 +483,20 @@ export class WakeRegistry { this.timeoutDelivered.clear() } + private async allocateRegistrationId(): Promise { + if (this.mode !== `electric`) { + return this.allocateLocalId() + } + + for (let attempt = 0; attempt < 100; attempt++) { + const id = await this.allocateRuntimeId() + const existing = await this.rowsByPredicate((row) => row.id === id) + if (existing.length === 0) return id + } + + throw new Error(`Failed to allocate unused wake registration id`) + } + private async allocateRuntimeId(): Promise { const rows = await this.db.execute( sql<{ @@ -614,10 +628,7 @@ export class WakeRegistry { ) if (existing.length > 0) return } - const id = - this.mode === `electric` - ? await this.allocateRuntimeId() - : this.allocateLocalId() + const id = await this.allocateRegistrationId() const tx = this.registerAction( this.normalizeRegistration(reg, tenantId, id) ) diff --git a/packages/agents-server/test/scheduler-integration.test.ts b/packages/agents-server/test/scheduler-integration.test.ts index aedfca5e0f..e07c5b7a74 100644 --- a/packages/agents-server/test/scheduler-integration.test.ts +++ b/packages/agents-server/test/scheduler-integration.test.ts @@ -9,7 +9,9 @@ import { } from './test-utils' import { configureElectricAgentsTestBackendEnv } from './test-backend-env' -configureElectricAgentsTestBackendEnv(`agent-server-scheduler`, 20) +configureElectricAgentsTestBackendEnv(`agent-server-scheduler`, 20, { + force: true, +}) let TEST_ELECTRIC_URL = `` let TEST_POSTGRES_URL = `` @@ -88,8 +90,8 @@ describe(`Scheduler Integration`, () => { beforeAll(async () => { const backend = await import(`./test-backend`) - TEST_ELECTRIC_URL = backend.TEST_ELECTRIC_URL - TEST_POSTGRES_URL = backend.TEST_POSTGRES_URL + TEST_ELECTRIC_URL = process.env.ELECTRIC_URL ?? backend.TEST_ELECTRIC_URL + TEST_POSTGRES_URL = process.env.DATABASE_URL ?? backend.TEST_POSTGRES_URL resetElectricAgentsTestBackend = backend.resetElectricAgentsTestBackend dsServer = new DurableStreamTestServer({ diff --git a/packages/agents-server/test/test-backend-env.ts b/packages/agents-server/test/test-backend-env.ts index 95fa459646..60a5157b28 100644 --- a/packages/agents-server/test/test-backend-env.ts +++ b/packages/agents-server/test/test-backend-env.ts @@ -5,19 +5,32 @@ import { export function configureElectricAgentsTestBackendEnv( namespace: string, - portOffset: number + portOffset: number, + opts: { force?: boolean } = {} ): void { const { postgresPort, electricPort } = getElectricAgentsDevPorts() const scopedPostgresPort = postgresPort + portOffset const scopedElectricPort = electricPort + portOffset - process.env.ELECTRIC_AGENTS_COMPOSE_PROJECT ??= `${getElectricAgentsComposeProject()}-${namespace}` - process.env.PG_HOST_PORT ??= String(scopedPostgresPort) - process.env.ELECTRIC_HOST_PORT ??= String(scopedElectricPort) - process.env.JAEGER_UI_PORT ??= `0` - process.env.JAEGER_OTLP_HTTP_PORT ??= `0` - process.env.JAEGER_OTLP_GRPC_PORT ??= `0` - process.env.DATABASE_URL ??= `postgres://electric_agents:electric_agents@localhost:${scopedPostgresPort}/electric_agents` - process.env.ELECTRIC_URL ??= `http://localhost:${scopedElectricPort}` - process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED ??= `1` + const setEnv = (key: string, value: string): void => { + if (opts.force || process.env[key] === undefined) { + process.env[key] = value + } + } + + setEnv( + `ELECTRIC_AGENTS_COMPOSE_PROJECT`, + `${getElectricAgentsComposeProject()}-${namespace}` + ) + setEnv(`PG_HOST_PORT`, String(scopedPostgresPort)) + setEnv(`ELECTRIC_HOST_PORT`, String(scopedElectricPort)) + setEnv(`JAEGER_UI_PORT`, `0`) + setEnv(`JAEGER_OTLP_HTTP_PORT`, `0`) + setEnv(`JAEGER_OTLP_GRPC_PORT`, `0`) + setEnv( + `DATABASE_URL`, + `postgres://electric_agents:electric_agents@localhost:${scopedPostgresPort}/electric_agents` + ) + setEnv(`ELECTRIC_URL`, `http://localhost:${scopedElectricPort}`) + setEnv(`ELECTRIC_AGENTS_TEST_BACKEND_MANAGED`, `1`) } diff --git a/packages/agents-server/test/test-backend.ts b/packages/agents-server/test/test-backend.ts index 49f767fa63..b97a2f03b6 100644 --- a/packages/agents-server/test/test-backend.ts +++ b/packages/agents-server/test/test-backend.ts @@ -19,17 +19,30 @@ export const TEST_POSTGRES_URL = export const TEST_ELECTRIC_URL = process.env.ELECTRIC_URL ?? `http://localhost:3060` -const hasExplicitBackendConfig = - process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED !== `1` && - (process.env.DATABASE_URL !== undefined || - process.env.ELECTRIC_URL !== undefined || - process.env.CLICKHOUSE_URL !== undefined) +function currentPostgresUrl(): string { + return ( + process.env.DATABASE_URL ?? + `postgres://electric_agents:electric_agents@localhost:5432/electric_agents` + ) +} + +function hasExplicitBackendConfig(): boolean { + return ( + process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED !== `1` && + (process.env.DATABASE_URL !== undefined || + process.env.ELECTRIC_URL !== undefined || + process.env.CLICKHOUSE_URL !== undefined) + ) +} -let ensureBackendPromise: Promise | null = null -const resetLockDir = path.join( - os.tmpdir(), - `${getElectricAgentsComposeProject()}-agent-server-test-backend.lock` -) +const ensureBackendPromises = new Map>() + +function currentResetLockDir(): string { + return path.join( + os.tmpdir(), + `${getElectricAgentsComposeProject()}-agent-server-test-backend.lock` + ) +} async function startElectricAgentsTestBackend(): Promise { await execFileAsync( @@ -164,7 +177,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { if (process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED === `1`) { await stopElectricAgentsTestBackendAndRemoveVolumes() - ensureBackendPromise = null + ensureBackendPromises.delete(postgresUrl) await ensureElectricAgentsTestBackend() await runMigrations(postgresUrl) if (await matchesCurrentSchema()) { @@ -178,14 +191,16 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { ) } export async function ensureElectricAgentsTestBackend(): Promise { - if (ensureBackendPromise) { - await ensureBackendPromise + const postgresUrl = currentPostgresUrl() + const existing = ensureBackendPromises.get(postgresUrl) + if (existing) { + await existing return } - ensureBackendPromise = (async () => { + const ensureBackendPromise = (async () => { try { - const pg = postgres(TEST_POSTGRES_URL, { + const pg = postgres(postgresUrl, { max: 1, onnotice: () => {}, connect_timeout: 2, @@ -197,29 +212,31 @@ export async function ensureElectricAgentsTestBackend(): Promise { await pg.end({ timeout: 2 }) } } catch (error) { - if (hasExplicitBackendConfig) { + if (hasExplicitBackendConfig()) { const details = error instanceof Error ? error.message : String(error) throw new Error( - `Explicit Electric Agents test backend is unreachable at ${TEST_POSTGRES_URL}: ${details}` + `Explicit Electric Agents test backend is unreachable at ${postgresUrl}: ${details}` ) } await startElectricAgentsTestBackend() } })() + ensureBackendPromises.set(postgresUrl, ensureBackendPromise) try { await ensureBackendPromise } catch (error) { - ensureBackendPromise = null + ensureBackendPromises.delete(postgresUrl) throw error } } export async function resetElectricAgentsTestBackend(): Promise { await ensureElectricAgentsTestBackend() + const postgresUrl = currentPostgresUrl() await withResetLock(async () => { - const pg = postgres(TEST_POSTGRES_URL, { + const pg = postgres(postgresUrl, { max: 1, onnotice: () => {}, }) @@ -234,12 +251,13 @@ export async function resetElectricAgentsTestBackend(): Promise { await pg.end() } - await runMigrations(TEST_POSTGRES_URL) - await ensureExpectedSchema(TEST_POSTGRES_URL) + await runMigrations(postgresUrl) + await ensureExpectedSchema(postgresUrl) }) } async function withResetLock(fn: () => Promise): Promise { + const resetLockDir = currentResetLockDir() for (;;) { try { await mkdir(resetLockDir) From 6c4dfa6fb8a61b4975364b260226890586020425 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 16:03:46 -0600 Subject: [PATCH 071/105] Isolate scheduler integration test backend --- .../test/scheduler-integration.test.ts | 102 +++++++++++++++--- .../agents-server/test/test-backend-env.ts | 33 ++---- packages/agents-server/test/test-backend.ts | 60 ++++------- 3 files changed, 117 insertions(+), 78 deletions(-) diff --git a/packages/agents-server/test/scheduler-integration.test.ts b/packages/agents-server/test/scheduler-integration.test.ts index e07c5b7a74..f1f4be9e38 100644 --- a/packages/agents-server/test/scheduler-integration.test.ts +++ b/packages/agents-server/test/scheduler-integration.test.ts @@ -1,21 +1,92 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { getCronStreamPath } from '@electric-ax/agents-runtime' import { DurableStreamTestServer } from '@durable-streams/server' +import { runMigrations } from '../src/db' import { ElectricAgentsServer } from '../src/server' import { durableStreamTestServerUrl, readStreamEvents, waitFor, } from './test-utils' -import { configureElectricAgentsTestBackendEnv } from './test-backend-env' - -configureElectricAgentsTestBackendEnv(`agent-server-scheduler`, 20, { - force: true, -}) - -let TEST_ELECTRIC_URL = `` -let TEST_POSTGRES_URL = `` -let resetElectricAgentsTestBackend: () => Promise +import { + ELECTRIC_AGENTS_COMPOSE_FILE, + getElectricAgentsComposeProject, + getElectricAgentsDevPorts, +} from './electric-agents-compose-utils' + +const execFileAsync = promisify(execFile) +const { postgresPort, electricPort } = getElectricAgentsDevPorts() +const TEST_POSTGRES_PORT = postgresPort + 20 +const TEST_ELECTRIC_PORT = electricPort + 20 +const TEST_COMPOSE_PROJECT = `${getElectricAgentsComposeProject()}-agent-server-scheduler` +const TEST_POSTGRES_URL = `postgres://electric_agents:electric_agents@localhost:${TEST_POSTGRES_PORT}/electric_agents` +const TEST_ELECTRIC_URL = `http://localhost:${TEST_ELECTRIC_PORT}` +const TEST_BACKEND_ENV = { + ...process.env, + ELECTRIC_AGENTS_COMPOSE_PROJECT: TEST_COMPOSE_PROJECT, + PG_HOST_PORT: String(TEST_POSTGRES_PORT), + ELECTRIC_HOST_PORT: String(TEST_ELECTRIC_PORT), + JAEGER_UI_PORT: `0`, + JAEGER_OTLP_HTTP_PORT: `0`, + JAEGER_OTLP_GRPC_PORT: `0`, + DATABASE_URL: TEST_POSTGRES_URL, + ELECTRIC_URL: TEST_ELECTRIC_URL, + ELECTRIC_AGENTS_TEST_BACKEND_MANAGED: `1`, +} + +async function startTestBackend(): Promise { + await execFileAsync( + `docker`, + [ + `compose`, + `-p`, + TEST_COMPOSE_PROJECT, + `-f`, + ELECTRIC_AGENTS_COMPOSE_FILE, + `up`, + `-d`, + `--wait`, + ], + { env: TEST_BACKEND_ENV } + ) +} + +async function stopTestBackend(): Promise { + await execFileAsync( + `docker`, + [ + `compose`, + `-p`, + TEST_COMPOSE_PROJECT, + `-f`, + ELECTRIC_AGENTS_COMPOSE_FILE, + `down`, + `-v`, + ], + { env: TEST_BACKEND_ENV } + ) +} + +async function resetTestBackend(): Promise { + await startTestBackend() + const pg = postgres(TEST_POSTGRES_URL, { + max: 1, + onnotice: () => {}, + }) + try { + await pg.unsafe(` + DROP SCHEMA IF EXISTS drizzle CASCADE; + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public AUTHORIZATION CURRENT_USER; + `) + } finally { + await pg.end() + } + await runMigrations(TEST_POSTGRES_URL) +} describe(`Scheduler Integration`, () => { let dsServer: DurableStreamTestServer @@ -89,22 +160,21 @@ describe(`Scheduler Integration`, () => { } beforeAll(async () => { - const backend = await import(`./test-backend`) - TEST_ELECTRIC_URL = process.env.ELECTRIC_URL ?? backend.TEST_ELECTRIC_URL - TEST_POSTGRES_URL = process.env.DATABASE_URL ?? backend.TEST_POSTGRES_URL - resetElectricAgentsTestBackend = backend.resetElectricAgentsTestBackend - dsServer = new DurableStreamTestServer({ port: 0, longPollTimeout: 500, webhooks: true, }) - await Promise.all([resetElectricAgentsTestBackend(), dsServer.start()]) + await Promise.all([resetTestBackend(), dsServer.start()]) await startElectricAgentsServer() }, 120_000) afterAll(async () => { - await Promise.allSettled([stopElectricAgentsServer(), dsServer.stop()]) + await Promise.allSettled([ + stopElectricAgentsServer(), + dsServer.stop(), + stopTestBackend(), + ]) }, 120_000) it(`delayed_send survives server restart and lands exactly once`, async () => { diff --git a/packages/agents-server/test/test-backend-env.ts b/packages/agents-server/test/test-backend-env.ts index 60a5157b28..95fa459646 100644 --- a/packages/agents-server/test/test-backend-env.ts +++ b/packages/agents-server/test/test-backend-env.ts @@ -5,32 +5,19 @@ import { export function configureElectricAgentsTestBackendEnv( namespace: string, - portOffset: number, - opts: { force?: boolean } = {} + portOffset: number ): void { const { postgresPort, electricPort } = getElectricAgentsDevPorts() const scopedPostgresPort = postgresPort + portOffset const scopedElectricPort = electricPort + portOffset - const setEnv = (key: string, value: string): void => { - if (opts.force || process.env[key] === undefined) { - process.env[key] = value - } - } - - setEnv( - `ELECTRIC_AGENTS_COMPOSE_PROJECT`, - `${getElectricAgentsComposeProject()}-${namespace}` - ) - setEnv(`PG_HOST_PORT`, String(scopedPostgresPort)) - setEnv(`ELECTRIC_HOST_PORT`, String(scopedElectricPort)) - setEnv(`JAEGER_UI_PORT`, `0`) - setEnv(`JAEGER_OTLP_HTTP_PORT`, `0`) - setEnv(`JAEGER_OTLP_GRPC_PORT`, `0`) - setEnv( - `DATABASE_URL`, - `postgres://electric_agents:electric_agents@localhost:${scopedPostgresPort}/electric_agents` - ) - setEnv(`ELECTRIC_URL`, `http://localhost:${scopedElectricPort}`) - setEnv(`ELECTRIC_AGENTS_TEST_BACKEND_MANAGED`, `1`) + process.env.ELECTRIC_AGENTS_COMPOSE_PROJECT ??= `${getElectricAgentsComposeProject()}-${namespace}` + process.env.PG_HOST_PORT ??= String(scopedPostgresPort) + process.env.ELECTRIC_HOST_PORT ??= String(scopedElectricPort) + process.env.JAEGER_UI_PORT ??= `0` + process.env.JAEGER_OTLP_HTTP_PORT ??= `0` + process.env.JAEGER_OTLP_GRPC_PORT ??= `0` + process.env.DATABASE_URL ??= `postgres://electric_agents:electric_agents@localhost:${scopedPostgresPort}/electric_agents` + process.env.ELECTRIC_URL ??= `http://localhost:${scopedElectricPort}` + process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED ??= `1` } diff --git a/packages/agents-server/test/test-backend.ts b/packages/agents-server/test/test-backend.ts index b97a2f03b6..49f767fa63 100644 --- a/packages/agents-server/test/test-backend.ts +++ b/packages/agents-server/test/test-backend.ts @@ -19,30 +19,17 @@ export const TEST_POSTGRES_URL = export const TEST_ELECTRIC_URL = process.env.ELECTRIC_URL ?? `http://localhost:3060` -function currentPostgresUrl(): string { - return ( - process.env.DATABASE_URL ?? - `postgres://electric_agents:electric_agents@localhost:5432/electric_agents` - ) -} - -function hasExplicitBackendConfig(): boolean { - return ( - process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED !== `1` && - (process.env.DATABASE_URL !== undefined || - process.env.ELECTRIC_URL !== undefined || - process.env.CLICKHOUSE_URL !== undefined) - ) -} +const hasExplicitBackendConfig = + process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED !== `1` && + (process.env.DATABASE_URL !== undefined || + process.env.ELECTRIC_URL !== undefined || + process.env.CLICKHOUSE_URL !== undefined) -const ensureBackendPromises = new Map>() - -function currentResetLockDir(): string { - return path.join( - os.tmpdir(), - `${getElectricAgentsComposeProject()}-agent-server-test-backend.lock` - ) -} +let ensureBackendPromise: Promise | null = null +const resetLockDir = path.join( + os.tmpdir(), + `${getElectricAgentsComposeProject()}-agent-server-test-backend.lock` +) async function startElectricAgentsTestBackend(): Promise { await execFileAsync( @@ -177,7 +164,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { if (process.env.ELECTRIC_AGENTS_TEST_BACKEND_MANAGED === `1`) { await stopElectricAgentsTestBackendAndRemoveVolumes() - ensureBackendPromises.delete(postgresUrl) + ensureBackendPromise = null await ensureElectricAgentsTestBackend() await runMigrations(postgresUrl) if (await matchesCurrentSchema()) { @@ -191,16 +178,14 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { ) } export async function ensureElectricAgentsTestBackend(): Promise { - const postgresUrl = currentPostgresUrl() - const existing = ensureBackendPromises.get(postgresUrl) - if (existing) { - await existing + if (ensureBackendPromise) { + await ensureBackendPromise return } - const ensureBackendPromise = (async () => { + ensureBackendPromise = (async () => { try { - const pg = postgres(postgresUrl, { + const pg = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {}, connect_timeout: 2, @@ -212,31 +197,29 @@ export async function ensureElectricAgentsTestBackend(): Promise { await pg.end({ timeout: 2 }) } } catch (error) { - if (hasExplicitBackendConfig()) { + if (hasExplicitBackendConfig) { const details = error instanceof Error ? error.message : String(error) throw new Error( - `Explicit Electric Agents test backend is unreachable at ${postgresUrl}: ${details}` + `Explicit Electric Agents test backend is unreachable at ${TEST_POSTGRES_URL}: ${details}` ) } await startElectricAgentsTestBackend() } })() - ensureBackendPromises.set(postgresUrl, ensureBackendPromise) try { await ensureBackendPromise } catch (error) { - ensureBackendPromises.delete(postgresUrl) + ensureBackendPromise = null throw error } } export async function resetElectricAgentsTestBackend(): Promise { await ensureElectricAgentsTestBackend() - const postgresUrl = currentPostgresUrl() await withResetLock(async () => { - const pg = postgres(postgresUrl, { + const pg = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {}, }) @@ -251,13 +234,12 @@ export async function resetElectricAgentsTestBackend(): Promise { await pg.end() } - await runMigrations(postgresUrl) - await ensureExpectedSchema(postgresUrl) + await runMigrations(TEST_POSTGRES_URL) + await ensureExpectedSchema(TEST_POSTGRES_URL) }) } async function withResetLock(fn: () => Promise): Promise { - const resetLockDir = currentResetLockDir() for (;;) { try { await mkdir(resetLockDir) From 6125195a61507761aae15d6b7cee6eecfafe5436 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 16:10:23 -0600 Subject: [PATCH 072/105] Stabilize wake registry sync test --- .../agents-server/test/wake-registry-sync.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index 60a0b4ae9c..974bdec99f 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -30,6 +30,9 @@ describe(`WakeRegistry Electric collection sync`, () => { const suffix = randomUUID() const subscriberUrl = `/parent/sync-${suffix}` const sourceUrl = `/child/sync-${suffix}` + const registry = new WakeRegistry(db as any) + await registry.startSync(TEST_ELECTRIC_URL) + const rows = await db .insert(wakeRegistrations) .values({ @@ -40,9 +43,6 @@ describe(`WakeRegistry Electric collection sync`, () => { }) .returning() - const registry = new WakeRegistry(db as any) - await registry.startSync(TEST_ELECTRIC_URL) - try { let results: Awaited> = [] const event = { @@ -51,11 +51,11 @@ describe(`WakeRegistry Electric collection sync`, () => { value: { status: `completed` }, headers: { operation: `update` }, } - const deadline = Date.now() + 5_000 + const deadline = Date.now() + 15_000 do { results = await registry.evaluate(sourceUrl, event) if (results.length > 0) break - await new Promise((resolve) => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 100)) } while (Date.now() < deadline) expect(results).toHaveLength(1) @@ -66,5 +66,5 @@ describe(`WakeRegistry Electric collection sync`, () => { .delete(wakeRegistrations) .where(eq(wakeRegistrations.sourceUrl, sourceUrl)) } - }, 15_000) + }, 25_000) }) From d1aafabcc0b8fb031332c54f2fe44f5dbbe41a1a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 16:19:52 -0600 Subject: [PATCH 073/105] Isolate wake registry sync test backend --- .../test/wake-registry-sync.test.ts | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/agents-server/test/wake-registry-sync.test.ts b/packages/agents-server/test/wake-registry-sync.test.ts index 974bdec99f..79dd27fa58 100644 --- a/packages/agents-server/test/wake-registry-sync.test.ts +++ b/packages/agents-server/test/wake-registry-sync.test.ts @@ -1,14 +1,85 @@ +import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { promisify } from 'node:util' +import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { eq } from 'drizzle-orm' -import { createDb } from '../src/db' +import { createDb, runMigrations } from '../src/db' import { wakeRegistrations } from '../src/db/schema' import { WakeRegistry } from '../src/wake-registry' import { - TEST_ELECTRIC_URL, - TEST_POSTGRES_URL, - ensureElectricAgentsTestBackend, -} from './test-backend' + ELECTRIC_AGENTS_COMPOSE_FILE, + getElectricAgentsComposeProject, + getElectricAgentsDevPorts, +} from './electric-agents-compose-utils' + +const execFileAsync = promisify(execFile) +const { postgresPort, electricPort } = getElectricAgentsDevPorts() +const TEST_POSTGRES_PORT = postgresPort + 40 +const TEST_ELECTRIC_PORT = electricPort + 40 +const TEST_COMPOSE_PROJECT = `${getElectricAgentsComposeProject()}-wake-registry-sync` +const TEST_POSTGRES_URL = `postgres://electric_agents:electric_agents@localhost:${TEST_POSTGRES_PORT}/electric_agents` +const TEST_ELECTRIC_URL = `http://localhost:${TEST_ELECTRIC_PORT}` +const TEST_BACKEND_ENV = { + ...process.env, + ELECTRIC_AGENTS_COMPOSE_PROJECT: TEST_COMPOSE_PROJECT, + PG_HOST_PORT: String(TEST_POSTGRES_PORT), + ELECTRIC_HOST_PORT: String(TEST_ELECTRIC_PORT), + JAEGER_UI_PORT: `0`, + JAEGER_OTLP_HTTP_PORT: `0`, + JAEGER_OTLP_GRPC_PORT: `0`, + DATABASE_URL: TEST_POSTGRES_URL, + ELECTRIC_URL: TEST_ELECTRIC_URL, + ELECTRIC_AGENTS_TEST_BACKEND_MANAGED: `1`, +} + +async function startTestBackend(): Promise { + await execFileAsync( + `docker`, + [ + `compose`, + `-p`, + TEST_COMPOSE_PROJECT, + `-f`, + ELECTRIC_AGENTS_COMPOSE_FILE, + `up`, + `-d`, + `--wait`, + ], + { env: TEST_BACKEND_ENV } + ) +} + +async function stopTestBackend(): Promise { + await execFileAsync( + `docker`, + [ + `compose`, + `-p`, + TEST_COMPOSE_PROJECT, + `-f`, + ELECTRIC_AGENTS_COMPOSE_FILE, + `down`, + `-v`, + ], + { env: TEST_BACKEND_ENV } + ) +} + +async function resetTestBackend(): Promise { + await startTestBackend() + const pg = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {} }) + try { + await pg.unsafe(` + DROP SCHEMA IF EXISTS drizzle CASCADE; + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public AUTHORIZATION CURRENT_USER; + `) + } finally { + await pg.end() + } + await runMigrations(TEST_POSTGRES_URL) +} type DbConnection = ReturnType @@ -17,14 +88,14 @@ let db: DbConnection[`db`] describe(`WakeRegistry Electric collection sync`, () => { beforeAll(async () => { - await ensureElectricAgentsTestBackend() + await resetTestBackend() connection = createDb(TEST_POSTGRES_URL) db = connection.db - }, 60_000) + }, 120_000) afterAll(async () => { - await connection?.client.end() - }) + await Promise.allSettled([connection?.client.end(), stopTestBackend()]) + }, 120_000) it(`syncs wake rows from Postgres through Electric`, async () => { const suffix = randomUUID() From e11b2f60ac24e458f292cbc8a377ae9e26cb47f8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 16:36:46 -0600 Subject: [PATCH 074/105] Clarify queued wake changeset --- .changeset/fix-deferred-pull-wakes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-deferred-pull-wakes.md b/.changeset/fix-deferred-pull-wakes.md index 12a601701f..961199ed32 100644 --- a/.changeset/fix-deferred-pull-wakes.md +++ b/.changeset/fix-deferred-pull-wakes.md @@ -3,6 +3,6 @@ '@electric-ax/agents-server': patch --- -Fix child wake delivery so multiple deferred wakes for the same entity stream are preserved. +Fix child wake delivery so same-stream wake trigger notifications are queued while an active claim is already running, ensuring the runner checks for all pending wake rows again after the active work drains. Refactor the server wake registry to use TanStack DB collections and optimistic actions over `wake_registrations`, removing the manual ShapeStream-backed registration cache and stale-cache reload fallback. From d9da41abe5678d440e99977e8a81b793bbbe6ff1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 19 Jun 2026 16:40:01 -0600 Subject: [PATCH 075/105] Coalesce queued wake triggers --- packages/agents-runtime/src/pull-wake-runner.ts | 9 ++------- packages/agents-runtime/test/pull-wake-runner.test.ts | 11 ++++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 6a0a323eb9..4359d6ce95 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -382,14 +382,9 @@ export function createPullWakeRunner( return } - const deferredEvent = deferredEvents.shift()! - if (deferredEvents.length === 0) { - deferredWakeEventsByStreamPath.delete(streamPath) - } + const deferredEvent = deferredEvents[0]! + deferredWakeEventsByStreamPath.delete(streamPath) spawnClaimActor(deferredEvent, signal) - if (deferredEvents.length > 0) { - scheduleDeferredWakeRetry(streamPath, signal) - } } const timer = setTimeout(retry, DEFERRED_WAKE_RETRY_MS) diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 0d03b5f7fc..8ce5f1f59b 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -278,13 +278,13 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) - it(`retries every wake skipped while the same stream is already being claimed`, async () => { + it(`coalesces wake notifications skipped while the same stream is already being claimed`, async () => { const events = [ { ...wakeEvent(`parent`), generation: 1 }, { ...wakeEvent(`parent`), generation: 2 }, { ...wakeEvent(`parent`), generation: 3 }, ] - const claims = [`one`, `two`, `three`].map(notification) + const claims = [`one`, `coalesced`].map(notification) const firstClaimResponse = deferred() const fetchMock = vi .fn() @@ -294,9 +294,6 @@ describe(`createPullWakeRunner`, () => { .mockImplementationOnce(async (_input: RequestInfo | URL) => Response.json(claims[1]) ) - .mockImplementationOnce(async (_input: RequestInfo | URL) => - Response.json(claims[2]) - ) vi.stubGlobal(`fetch`, fetchMock) const testRuntime = runtime() const streamFactory = vi.fn(async () => ({ @@ -324,10 +321,10 @@ describe(`createPullWakeRunner`, () => { firstClaimResponse.resolve(Response.json(claims[0])) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenCalledTimes(2) }) await waitFor(() => { - expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(3) + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(2) }) await runner.stop() From 86df9033fd7a9644f6766ceaaa3d10b797c76c48 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 20 Jun 2026 11:51:37 -0600 Subject: [PATCH 076/105] Fix wake queue and registry visibility gaps --- .../agents-runtime/src/pull-wake-runner.ts | 52 ++++++--- .../test/pull-wake-runner.test.ts | 50 +++++++++ packages/agents-server/src/wake-registry.ts | 106 +++++++++++++----- .../agents-server/test/wake-registry.test.ts | 84 ++++++++++++++ 4 files changed, 245 insertions(+), 47 deletions(-) diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 4359d6ce95..01aeec1a9b 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -364,30 +364,41 @@ export function createPullWakeRunner( deferredWakeEventsByStreamPath.clear() } - const scheduleDeferredWakeRetry = ( + const drainQueuedWakeClaims = ( streamPath: string, signal: AbortSignal ): void => { - if (deferredWakeTimersByStreamPath.has(streamPath)) return - - const retry = (): void => { + const timer = deferredWakeTimersByStreamPath.get(streamPath) + if (timer) { + clearTimeout(timer) deferredWakeTimersByStreamPath.delete(streamPath) - const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) - if (!deferredEvents?.length || signal.aborted || !isRunningState()) { - deferredWakeEventsByStreamPath.delete(streamPath) - return - } - if (hasActiveStreamClaim(streamPath)) { - scheduleDeferredWakeRetry(streamPath, signal) - return - } + } - const deferredEvent = deferredEvents[0]! + const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) + if (!deferredEvents?.length || signal.aborted || !isRunningState()) { deferredWakeEventsByStreamPath.delete(streamPath) - spawnClaimActor(deferredEvent, signal) + return + } + if (hasActiveStreamClaim(streamPath)) { + scheduleDeferredWakeRetry(streamPath, signal) + return } - const timer = setTimeout(retry, DEFERRED_WAKE_RETRY_MS) + const deferredEvent = deferredEvents[0]! + deferredWakeEventsByStreamPath.delete(streamPath) + spawnClaimActor(deferredEvent, signal) + } + + const scheduleDeferredWakeRetry = ( + streamPath: string, + signal: AbortSignal + ): void => { + if (deferredWakeTimersByStreamPath.has(streamPath)) return + + const timer = setTimeout( + () => drainQueuedWakeClaims(streamPath, signal), + DEFERRED_WAKE_RETRY_MS + ) timer.unref?.() deferredWakeTimersByStreamPath.set(streamPath, timer) } @@ -400,7 +411,12 @@ export function createPullWakeRunner( const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) ?? [] deferredEvents.push(event) deferredWakeEventsByStreamPath.set(streamPath, deferredEvents) - scheduleDeferredWakeRetry(streamPath, signal) + if (hasActiveStreamClaim(streamPath)) { + recordClaimSkipped() + scheduleDeferredWakeRetry(streamPath, signal) + return + } + drainQueuedWakeClaims(streamPath, signal) } const claimWake = async ( @@ -539,7 +555,7 @@ export function createPullWakeRunner( eventsReceived++ notifyHeartbeatChange() const signal = controller?.signal - if (signal && !signal.aborted) spawnClaimActor(event, signal) + if (signal && !signal.aborted) scheduleDeferredWakeClaim(event, signal) }, onOffset: (offset) => { if (offset !== currentOffset) { diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 8ce5f1f59b..24cf53358b 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -330,6 +330,56 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) + it(`does not let a new same-stream notification jump ahead of a queued trigger`, async () => { + const continueStream = deferred() + const firstClaimResponse = deferred() + const requestGenerations: Array = [] + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + requestGenerations.push(JSON.parse(String(init?.body)).generation) + if (requestGenerations.length === 1) return firstClaimResponse.promise + return Response.json(notification(`coalesced`)) + } + ) + vi.stubGlobal(`fetch`, fetchMock) + const testRuntime = runtime() + const streamFactory = vi.fn(async () => ({ + offset: `42`, + async *jsonStream() { + yield { ...wakeEvent(`parent`), generation: 1 } + yield { ...wakeEvent(`parent`), generation: 2 } + await continueStream.promise + yield { ...wakeEvent(`parent`), generation: 3 } + }, + closed: Promise.resolve(), + })) + + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: testRuntime, + heartbeatIntervalMs: 0, + eventHeartbeatThrottleMs: 0, + streamFactory, + }) + + runner.start() + await waitFor(() => { + expect(requestGenerations).toEqual([1]) + }) + + firstClaimResponse.resolve(Response.json(notification(`one`))) + continueStream.resolve() + + await waitFor(() => { + expect(requestGenerations).toEqual([1, 2]) + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(requestGenerations).toEqual([1, 2]) + + await runner.stop() + }) + it(`skips stale wake events when claim returns no pending work`, async () => { const event = wakeEvent(`one`) const fetchMock = vi.fn(async (_input: RequestInfo | URL) => diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index d7f01c0374..6de8b1d829 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -8,7 +8,7 @@ import { localOnlyCollectionOptions, queryOnce, } from '@tanstack/db' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { electricCollectionOptions } from '@tanstack/electric-db-collection' import { wakeRegistrations } from './db/schema.js' import { serverLog } from './utils/log.js' @@ -295,7 +295,8 @@ export class WakeRegistry { if (this.mode === `electric`) { const txid = await this.persistInsert(row) if (txid === undefined) { - throw new WakeRegistrationConflictError(row) + this.requireCollection().utils.acceptMutations(transaction) + return } try { await this.requireCollection().utils.awaitTxId(txid, 10_000) @@ -304,6 +305,7 @@ export class WakeRegistry { error instanceof Error && error.name === `TimeoutWaitingForTxIdError` ) { + this.requireCollection().utils.acceptMutations(transaction) return { txid } } throw error @@ -323,18 +325,29 @@ export class WakeRegistry { collection.delete(row.id) } }, - mutationFn: async ({ rows }, { transaction }) => { + mutationFn: async (input, { transaction }) => { if (this.mode === `local-test`) { this.requireCollection().utils.acceptMutations(transaction) return } if (this.mode === `electric`) { - const txid = await this.persistDeleteRows(rows) + const txid = await this.persistDeleteRows(input) if (txid === undefined) { this.requireCollection().utils.acceptMutations(transaction) return } - await this.requireCollection().utils.awaitTxId(txid, 10_000) + try { + await this.requireCollection().utils.awaitTxId(txid, 10_000) + } catch (error) { + if ( + error instanceof Error && + error.name === `TimeoutWaitingForTxIdError` + ) { + this.requireCollection().utils.acceptMutations(transaction) + return { txid } + } + throw error + } return { txid } } throw new Error(`WakeRegistry deleteRowsAction called before startup`) @@ -557,25 +570,53 @@ export class WakeRegistry { }) } + private deletePredicate(input: DeleteRowsInput) { + switch (input.persist.kind) { + case `manifestKey`: + return and( + eq(wakeRegistrations.tenantId, input.persist.tenantId), + eq(wakeRegistrations.subscriberUrl, input.persist.subscriberUrl), + eq(wakeRegistrations.manifestKey, input.persist.manifestKey) + ) + case `subscriber`: + return and( + eq(wakeRegistrations.tenantId, input.persist.tenantId), + eq(wakeRegistrations.subscriberUrl, input.persist.subscriberUrl) + ) + case `source`: + return and( + eq(wakeRegistrations.tenantId, input.persist.tenantId), + eq(wakeRegistrations.sourceUrl, input.persist.sourceUrl) + ) + case `subscriberAndSource`: + return and( + eq(wakeRegistrations.tenantId, input.persist.tenantId), + eq(wakeRegistrations.subscriberUrl, input.persist.subscriberUrl), + eq(wakeRegistrations.sourceUrl, input.persist.sourceUrl) + ) + case `oneShot`: + return input.rows.length === 0 + ? undefined + : inArray( + wakeRegistrations.id, + input.rows.map((row) => row.id) + ) + } + } + private async persistDeleteRows( - rows: Array + input: DeleteRowsInput ): Promise { - if (rows.length === 0) return undefined + const predicate = this.deletePredicate(input) + if (!predicate) return undefined return await this.db.transaction(async (tx) => { - let txid: string | undefined - for (const row of rows) { - const deleted = await tx - .delete(wakeRegistrations) - .where( - and( - eq(wakeRegistrations.tenantId, row.tenantId), - eq(wakeRegistrations.id, row.id) - ) - ) - .returning({ txid: sql`pg_current_xact_id()::xid::text` }) - txid = deleted[0]?.txid ?? txid - } - return txid === undefined ? undefined : Number(txid) + const deleted = await tx + .delete(wakeRegistrations) + .where(predicate) + .returning({ txid: sql`pg_current_xact_id()::xid::text` }) + return deleted[0]?.txid === undefined + ? undefined + : Number(deleted[0].txid) }) } @@ -689,6 +730,17 @@ export class WakeRegistry { ) } + private async deleteRows(input: DeleteRowsInput): Promise { + if (this.registrationsCollection && input.rows.length > 0) { + const tx = this.deleteRowsAction(input) + await tx.isPersisted.promise + return + } + if (this.mode === `electric`) { + await this.persistDeleteRows(input) + } + } + async unregisterByManifestKey( subscriberUrl: string, manifestKey: string, @@ -702,7 +754,7 @@ export class WakeRegistry { row.subscriberUrl === subscriberUrl && row.manifestKey === manifestKey ) - const tx = this.deleteRowsAction({ + await this.deleteRows({ rows, persist: { kind: `manifestKey`, @@ -711,7 +763,6 @@ export class WakeRegistry { manifestKey, }, }) - await tx.isPersisted.promise return } @@ -738,7 +789,7 @@ export class WakeRegistry { row.tenantId === resolvedTenantId && row.subscriberUrl === subscriberUrl ) - const tx = this.deleteRowsAction({ + await this.deleteRows({ rows, persist: { kind: `subscriber`, @@ -746,7 +797,6 @@ export class WakeRegistry { subscriberUrl, }, }) - await tx.isPersisted.promise return } @@ -768,11 +818,10 @@ export class WakeRegistry { if (this.registrationsCollection) { const resolvedTenantId = this.resolveTenantId(tenantId) const rows = await this.rowsForSource(resolvedTenantId, sourceUrl) - const tx = this.deleteRowsAction({ + await this.deleteRows({ rows, persist: { kind: `source`, tenantId: resolvedTenantId, sourceUrl }, }) - await tx.isPersisted.promise return } @@ -800,7 +849,7 @@ export class WakeRegistry { row.subscriberUrl === subscriberUrl && row.sourceUrl === sourceUrl ) - const tx = this.deleteRowsAction({ + await this.deleteRows({ rows, persist: { kind: `subscriberAndSource`, @@ -809,7 +858,6 @@ export class WakeRegistry { sourceUrl, }, }) - await tx.isPersisted.promise return } diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 4434b75222..1082984592 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -116,6 +116,90 @@ describe(`Wake Registry`, () => { expect(results).toHaveLength(0) }) + it(`keeps registrations locally visible when Electric tx visibility times out`, async () => { + const registry = await createLocalRegistry() + ;(registry as any).mode = `electric` + ;(registry as any).allocateRuntimeId = async () => 42 + ;(registry as any).persistInsert = async () => 123 + const timeout = new Error(`timed out`) + timeout.name = `TimeoutWaitingForTxIdError` + ;(registry as any).requireCollection().utils.awaitTxId = async () => { + throw timeout + } + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/slow-visible`, + condition: `runFinished`, + oneShot: false, + }) + + const results = await registry.evaluate(`/child/slow-visible`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(results).toHaveLength(1) + }) + + it(`keeps committed one-shot deletes locally deleted when Electric tx visibility times out`, async () => { + const registry = await createLocalRegistry() + + await registry.register({ + subscriberUrl: `/parent/p1`, + sourceUrl: `/child/one-shot-timeout`, + condition: `runFinished`, + oneShot: true, + }) + ;(registry as any).mode = `electric` + ;(registry as any).persistDeleteRows = async () => 123 + const timeout = new Error(`timed out`) + timeout.name = `TimeoutWaitingForTxIdError` + ;(registry as any).requireCollection().utils.awaitTxId = async () => { + throw timeout + } + + const first = await registry.evaluate(`/child/one-shot-timeout`, { + type: `run`, + key: `run-1`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + const second = await registry.evaluate(`/child/one-shot-timeout`, { + type: `run`, + key: `run-2`, + value: { status: `completed` }, + headers: { operation: `update` }, + }) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) + + it(`persists unregister predicates even when matching rows are not locally visible`, async () => { + const registry = await createLocalRegistry() + ;(registry as any).mode = `electric` + let persisted: unknown + ;(registry as any).persistDeleteRows = async (input: unknown) => { + persisted = input + return undefined + } + + await registry.unregisterBySubscriber(`/parent/missing`) + + expect(persisted).toEqual({ + rows: [], + persist: { + kind: `subscriber`, + tenantId: `default`, + subscriberUrl: `/parent/missing`, + }, + }) + }) + it(`does not await an invisible txid when runtime timeout update matches no rows`, async () => { const registry = await createLocalRegistry() From c73fb91a2848da173abe97e4d310a3bf00e2623f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 20 Jun 2026 12:02:02 -0600 Subject: [PATCH 077/105] Update wake registry conflict visibility test --- packages/agents-server/test/wake-registry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 1082984592..6f9ceae37a 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -92,7 +92,7 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`update:run-1`) }) - it(`drops optimistic register row when runtime insert conflicts invisibly`, async () => { + it(`keeps optimistic register row when runtime insert conflicts invisibly`, async () => { const registry = await createLocalRegistry() ;(registry as any).mode = `electric` ;(registry as any).allocateRuntimeId = async () => 42 @@ -113,7 +113,7 @@ describe(`Wake Registry`, () => { headers: { operation: `update` }, }) - expect(results).toHaveLength(0) + expect(results).toHaveLength(1) }) it(`keeps registrations locally visible when Electric tx visibility times out`, async () => { From 718826b82061f1ae8c2cf0183b69a16768862121 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 20 Jun 2026 12:40:16 -0600 Subject: [PATCH 078/105] Reconcile wake registration conflicts --- packages/agents-server/src/wake-registry.ts | 85 ++++++++++++++++--- .../agents-server/test/wake-registry.test.ts | 38 ++++++++- 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 6de8b1d829..6c80a1bd8f 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -95,6 +95,11 @@ export interface WakeRegistrationCollectionRow { type WakeRegistryMode = `unstarted` | `local-test` | `electric` +type PersistInsertResult = { + txid: number + row: WakeRegistrationCollectionRow +} + type DeleteRowsInput = { rows: Array persist: @@ -293,24 +298,25 @@ export class WakeRegistry { return } if (this.mode === `electric`) { - const txid = await this.persistInsert(row) - if (txid === undefined) { - this.requireCollection().utils.acceptMutations(transaction) - return + const result = await this.persistInsert(row) + if (result.row.id !== row.id) { + const collection = this.requireCollection() + collection.delete(row.id) + collection.insert(result.row) } try { - await this.requireCollection().utils.awaitTxId(txid, 10_000) + await this.requireCollection().utils.awaitTxId(result.txid, 10_000) } catch (error) { if ( error instanceof Error && error.name === `TimeoutWaitingForTxIdError` ) { this.requireCollection().utils.acceptMutations(transaction) - return { txid } + return { txid: result.txid } } throw error } - return { txid } + return { txid: result.txid } } throw new Error(`WakeRegistry registerAction called before startup`) }, @@ -429,6 +435,7 @@ export class WakeRegistry { async startSync(electricUrl: string, electricSecret?: string): Promise { if (this.registrationsCollection) { await this.registrationsCollection.preload() + this.startRegistrationEffect() return } @@ -469,16 +476,26 @@ export class WakeRegistry { } as any) ) as any - await this.requireCollection().preload() + try { + await this.requireCollection().preload() + } catch (error) { + const collection = this.registrationsCollection + this.registrationsCollection = null + this.mode = `unstarted` + await collection?.cleanup?.() + throw error + } this.startRegistrationEffect() } async stopSync(): Promise { + const collection = this.registrationsCollection await this.registrationsEffect?.dispose() this.registrationsEffect = null this.registrationsCollection = null this.mode = `unstarted` this.resetRuntimeState() + await collection?.cleanup?.() } private resetRuntimeState(): void { @@ -528,7 +545,7 @@ export class WakeRegistry { private async persistInsert( row: WakeRegistrationCollectionRow - ): Promise { + ): Promise { return await this.db.transaction(async (tx) => { const rows = await tx .insert(wakeRegistrations) @@ -546,9 +563,53 @@ export class WakeRegistry { manifestKey: row.manifestKey, createdAt: row.createdAt, }) - .onConflictDoNothing() - .returning({ txid: sql`pg_current_xact_id()::xid::text` }) - return rows[0]?.txid === undefined ? undefined : Number(rows[0].txid) + .onConflictDoUpdate({ + target: [ + wakeRegistrations.tenantId, + wakeRegistrations.subscriberUrl, + wakeRegistrations.sourceUrl, + wakeRegistrations.oneShot, + wakeRegistrations.debounceMs, + wakeRegistrations.timeoutMs, + wakeRegistrations.condition, + wakeRegistrations.manifestKey, + ], + set: { createdAt: sql`${wakeRegistrations.createdAt}` }, + }) + .returning({ + id: wakeRegistrations.id, + tenantId: wakeRegistrations.tenantId, + subscriberUrl: wakeRegistrations.subscriberUrl, + sourceUrl: wakeRegistrations.sourceUrl, + condition: wakeRegistrations.condition, + debounceMs: wakeRegistrations.debounceMs, + timeoutMs: wakeRegistrations.timeoutMs, + oneShot: wakeRegistrations.oneShot, + timeoutConsumed: wakeRegistrations.timeoutConsumed, + includeResponse: wakeRegistrations.includeResponse, + manifestKey: wakeRegistrations.manifestKey, + createdAt: wakeRegistrations.createdAt, + txid: sql`pg_current_xact_id()::xid::text`, + }) + const inserted = rows[0] + if (!inserted) throw new Error(`Wake registration insert returned no row`) + return { + txid: Number(inserted.txid), + row: { + id: inserted.id, + tenantId: inserted.tenantId, + subscriberUrl: inserted.subscriberUrl, + sourceUrl: inserted.sourceUrl, + condition: inserted.condition as WakeRegistration[`condition`], + debounceMs: inserted.debounceMs, + timeoutMs: inserted.timeoutMs, + oneShot: inserted.oneShot, + timeoutConsumed: inserted.timeoutConsumed, + includeResponse: inserted.includeResponse, + manifestKey: inserted.manifestKey, + createdAt: inserted.createdAt, + }, + } }) } diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 6f9ceae37a..9d03eaa86b 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -92,12 +92,15 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`update:run-1`) }) - it(`keeps optimistic register row when runtime insert conflicts invisibly`, async () => { + it(`reconciles invisible insert conflicts to the canonical database row`, async () => { const registry = await createLocalRegistry() ;(registry as any).mode = `electric` ;(registry as any).allocateRuntimeId = async () => 42 - ;(registry as any).persistInsert = async () => undefined - ;(registry as any).waitForRegistrationVisible = async () => {} + ;(registry as any).persistInsert = async (row: any) => ({ + txid: 123, + row: { ...row, id: 7 }, + }) + ;(registry as any).requireCollection().utils.awaitTxId = async () => {} await registry.register({ subscriberUrl: `/parent/p1`, @@ -114,13 +117,14 @@ describe(`Wake Registry`, () => { }) expect(results).toHaveLength(1) + expect(results[0]!.registrationDbId).toBe(7) }) it(`keeps registrations locally visible when Electric tx visibility times out`, async () => { const registry = await createLocalRegistry() ;(registry as any).mode = `electric` ;(registry as any).allocateRuntimeId = async () => 42 - ;(registry as any).persistInsert = async () => 123 + ;(registry as any).persistInsert = async (row: any) => ({ txid: 123, row }) const timeout = new Error(`timed out`) timeout.name = `TimeoutWaitingForTxIdError` ;(registry as any).requireCollection().utils.awaitTxId = async () => { @@ -200,6 +204,32 @@ describe(`Wake Registry`, () => { }) }) + it(`cleans up the Electric collection on stopSync`, async () => { + const registry = await createLocalRegistry() + const cleanup = vi.fn(async () => undefined) + const dispose = vi.fn(async () => undefined) + ;(registry as any).registrationsCollection = { cleanup } + ;(registry as any).registrationsEffect = { dispose } + + await registry.stopSync() + + expect(dispose).toHaveBeenCalledTimes(1) + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it(`starts the registration effect after retrying an existing collection preload`, async () => { + const registry = await createLocalRegistry() + const preload = vi.fn(async () => undefined) + const startEffect = vi.fn() + ;(registry as any).registrationsCollection = { preload } + ;(registry as any).startRegistrationEffect = startEffect + + await registry.startSync(`http://electric.test`) + + expect(preload).toHaveBeenCalledTimes(1) + expect(startEffect).toHaveBeenCalledTimes(1) + }) + it(`does not await an invisible txid when runtime timeout update matches no rows`, async () => { const registry = await createLocalRegistry() From f26a5ec09dfb51cc9848f7a57a4fe394adc09ec2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 20 Jun 2026 20:29:54 -0600 Subject: [PATCH 079/105] Tighten wake registry conflict handling --- packages/agents-runtime/src/pull-wake-runner.ts | 13 ++++++------- packages/agents-server/src/wake-registry.ts | 16 ++++++++-------- .../agents-server/test/wake-registry.test.ts | 12 ++++++++---- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 01aeec1a9b..5ea066afaf 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -129,7 +129,7 @@ export function createPullWakeRunner( let stopPromise: Promise | null = null const claimActors = new Set>() const claimingStreamPaths = new Set() - const deferredWakeEventsByStreamPath = new Map>() + const deferredWakeEventsByStreamPath = new Map() const deferredWakeTimersByStreamPath = new Map< string, ReturnType @@ -374,8 +374,8 @@ export function createPullWakeRunner( deferredWakeTimersByStreamPath.delete(streamPath) } - const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) - if (!deferredEvents?.length || signal.aborted || !isRunningState()) { + const deferredEvent = deferredWakeEventsByStreamPath.get(streamPath) + if (!deferredEvent || signal.aborted || !isRunningState()) { deferredWakeEventsByStreamPath.delete(streamPath) return } @@ -384,7 +384,6 @@ export function createPullWakeRunner( return } - const deferredEvent = deferredEvents[0]! deferredWakeEventsByStreamPath.delete(streamPath) spawnClaimActor(deferredEvent, signal) } @@ -408,9 +407,9 @@ export function createPullWakeRunner( signal: AbortSignal ): void => { const streamPath = normalizeStreamPath(event.stream) - const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) ?? [] - deferredEvents.push(event) - deferredWakeEventsByStreamPath.set(streamPath, deferredEvents) + if (!deferredWakeEventsByStreamPath.has(streamPath)) { + deferredWakeEventsByStreamPath.set(streamPath, event) + } if (hasActiveStreamClaim(streamPath)) { recordClaimSkipped() scheduleDeferredWakeRetry(streamPath, signal) diff --git a/packages/agents-server/src/wake-registry.ts b/packages/agents-server/src/wake-registry.ts index 6c80a1bd8f..be2ff670d9 100644 --- a/packages/agents-server/src/wake-registry.ts +++ b/packages/agents-server/src/wake-registry.ts @@ -239,7 +239,7 @@ export class WakeRegistry { oneShot: reg.oneShot, timeoutConsumed: false, includeResponse: reg.includeResponse !== false, - manifestKey: reg.manifestKey ?? null, + manifestKey: reg.manifestKey ?? ``, createdAt: new Date(), } } @@ -299,11 +299,6 @@ export class WakeRegistry { } if (this.mode === `electric`) { const result = await this.persistInsert(row) - if (result.row.id !== row.id) { - const collection = this.requireCollection() - collection.delete(row.id) - collection.insert(result.row) - } try { await this.requireCollection().utils.awaitTxId(result.txid, 10_000) } catch (error) { @@ -560,7 +555,7 @@ export class WakeRegistry { oneShot: row.oneShot, timeoutConsumed: row.timeoutConsumed, includeResponse: row.includeResponse, - manifestKey: row.manifestKey, + manifestKey: row.manifestKey ?? ``, createdAt: row.createdAt, }) .onConflictDoUpdate({ @@ -574,7 +569,12 @@ export class WakeRegistry { wakeRegistrations.condition, wakeRegistrations.manifestKey, ], - set: { createdAt: sql`${wakeRegistrations.createdAt}` }, + set: { + id: row.id, + timeoutConsumed: row.timeoutConsumed, + includeResponse: row.includeResponse, + createdAt: sql`${wakeRegistrations.createdAt}`, + }, }) .returning({ id: wakeRegistrations.id, diff --git a/packages/agents-server/test/wake-registry.test.ts b/packages/agents-server/test/wake-registry.test.ts index 9d03eaa86b..28d4eae148 100644 --- a/packages/agents-server/test/wake-registry.test.ts +++ b/packages/agents-server/test/wake-registry.test.ts @@ -92,15 +92,19 @@ describe(`Wake Registry`, () => { expect(results[0]!.sourceEventKey).toBe(`update:run-1`) }) - it(`reconciles invisible insert conflicts to the canonical database row`, async () => { + it(`reconciles invisible insert conflicts to the locally allocated database row`, async () => { const registry = await createLocalRegistry() ;(registry as any).mode = `electric` ;(registry as any).allocateRuntimeId = async () => 42 ;(registry as any).persistInsert = async (row: any) => ({ txid: 123, - row: { ...row, id: 7 }, + row, }) - ;(registry as any).requireCollection().utils.awaitTxId = async () => {} + const timeout = new Error(`timed out`) + timeout.name = `TimeoutWaitingForTxIdError` + ;(registry as any).requireCollection().utils.awaitTxId = async () => { + throw timeout + } await registry.register({ subscriberUrl: `/parent/p1`, @@ -117,7 +121,7 @@ describe(`Wake Registry`, () => { }) expect(results).toHaveLength(1) - expect(results[0]!.registrationDbId).toBe(7) + expect(results[0]!.registrationDbId).toBe(42) }) it(`keeps registrations locally visible when Electric tx visibility times out`, async () => { From a4da19e658499c100ffd287dfbfb145496e32754 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 17:10:29 -0600 Subject: [PATCH 080/105] Reconnect pull-wake stream after sleep --- .../reconnect-wake-stream-after-sleep.md | 5 ++ .../agents-runtime/src/pull-wake-runner.ts | 25 ++++++++ .../test/pull-wake-runner.test.ts | 63 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .changeset/reconnect-wake-stream-after-sleep.md diff --git a/.changeset/reconnect-wake-stream-after-sleep.md b/.changeset/reconnect-wake-stream-after-sleep.md new file mode 100644 index 0000000000..cdf01622bb --- /dev/null +++ b/.changeset/reconnect-wake-stream-after-sleep.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Reconnect pull-wake streams after the runner resumes from sleep or a long event-loop stall so desktop agents keep receiving wake notifications. diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 5ea066afaf..fc578c74b8 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -29,6 +29,12 @@ export interface PullWakeRunnerConfig { wakeStreamPath?: string heartbeatIntervalMs?: number eventHeartbeatThrottleMs?: number + /** + * If the heartbeat timer fires after a wall-clock gap larger than this, + * assume the process resumed from sleep or a long event-loop stall and force + * the long-lived wake stream to reconnect. Defaults to the runner lease. + */ + resumeGapResetMs?: number leaseMs?: number heartbeatPath?: string claimPath?: string @@ -146,6 +152,8 @@ export function createPullWakeRunner( config.eventHeartbeatThrottleMs ?? DEFAULT_EVENT_HEARTBEAT_THROTTLE_MS ) const leaseMs = config.leaseMs ?? heartbeatIntervalMs * 3 + const resumeGapResetMs = config.resumeGapResetMs ?? leaseMs + let lastHeartbeatTickAt = Date.now() const heartbeatPath = config.heartbeatPath ?? `/_electric/runners/${encodeURIComponent(config.runnerId)}/heartbeat` @@ -296,8 +304,25 @@ export function createPullWakeRunner( const startHeartbeat = (signal: AbortSignal): void => { if (heartbeatIntervalMs <= 0) return + lastHeartbeatTickAt = Date.now() requestHeartbeat(signal) heartbeatTimer = setInterval(() => { + const now = Date.now() + const elapsedMs = now - lastHeartbeatTickAt + lastHeartbeatTickAt = now + if ( + resumeGapResetMs > 0 && + elapsedMs > resumeGapResetMs && + isRunningState() && + !signal.aborted + ) { + actor.send({ + type: `STREAM_RESET`, + error: new Error( + `Pull-wake runner heartbeat timer resumed after ${elapsedMs}ms gap` + ), + }) + } requestHeartbeat(signal) }, heartbeatIntervalMs) } diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 24cf53358b..c57e99ad89 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -899,6 +899,69 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) + it(`forces the stream to reconnect when heartbeat timer observes a resume gap`, async () => { + vi.useFakeTimers() + const firstStreamOpened = deferred() + const secondStreamOpened = deferred() + const firstStreamClosed = deferred() + const secondStreamClosed = deferred() + const firstCancel = vi.fn(() => firstStreamClosed.resolve()) + const streamFactory = vi.fn(async () => { + if (streamFactory.mock.calls.length === 1) { + firstStreamOpened.resolve() + return { + async *jsonStream() { + await firstStreamClosed.promise + }, + cancel: firstCancel, + closed: firstStreamClosed.promise, + } + } + secondStreamOpened.resolve() + return { + async *jsonStream() { + await secondStreamClosed.promise + }, + cancel: () => secondStreamClosed.resolve(), + closed: secondStreamClosed.promise, + } + }) + vi.stubGlobal( + `fetch`, + vi.fn(async () => Response.json({})) + ) + + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: runtime(), + heartbeatIntervalMs: 10, + resumeGapResetMs: 25, + eventHeartbeatThrottleMs: 0, + streamFactory, + }) + + runner.start() + await firstStreamOpened.promise + + // Simulate the computer being asleep: wall-clock time jumps forward before + // the next heartbeat timer gets CPU again, while networking is healthy. + vi.setSystemTime(Date.now() + 60) + await vi.advanceTimersByTimeAsync(10) + + await waitFor(() => { + expect(firstCancel).toHaveBeenCalledWith(expect.any(Error)) + }) + + await vi.advanceTimersByTimeAsync(1_000) + await secondStreamOpened.promise + + expect(streamFactory).toHaveBeenCalledTimes(2) + expect(runner.getHealth().reconnect_count).toBe(1) + + await runner.stop() + }) + it(`forces the stream to reconnect after repeated heartbeat failures`, async () => { vi.useFakeTimers() const firstStreamOpened = deferred() From 503ccbfecdacb01aa7ba6d7145cf976fc52f56c0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 23 Jun 2026 15:19:02 -0600 Subject: [PATCH 081/105] Reclaim pending wake after done callback --- packages/agents-runtime/src/create-handler.ts | 5 +- packages/agents-runtime/src/process-wake.ts | 21 ++-- .../agents-runtime/src/pull-wake-runner.ts | 6 ++ packages/agents-runtime/src/types.ts | 5 + .../agents-runtime/test/process-wake.test.ts | 102 ++++++++++++++++++ .../test/pull-wake-runner.test.ts | 49 +++++++++ .../test/pull-wake-subscription-stack.test.ts | 63 +++++++++++ 7 files changed, 242 insertions(+), 9 deletions(-) diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index 7bc8069ec5..7d1bc54571 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -145,7 +145,10 @@ export interface RuntimeRouter { */ dispatchWake: ( notification: WakeNotification, - options?: Pick + options?: Pick< + ProcessWakeConfig, + `claimHeaders` | `claimTokenHeader` | `onDoneNextWake` + > ) => void /** True when a wake for the stream path is already in flight. */ diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 8ed0484dad..ce40057e07 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -75,6 +75,7 @@ interface ClaimCallbackResponse { claimToken?: string token?: string writeToken?: string + next_wake?: boolean error?: { code?: string; message?: string } } @@ -2489,7 +2490,7 @@ export async function processWake( log.info(`shutdown requested, sending done callback at checkpoint`) } try { - await sendDone( + const doneResponse = await sendDone( callback, activeClaimToken, claimHeaderConfig, @@ -2497,6 +2498,9 @@ export async function processWake( streamPath, doneOffset === `-1` ? null : doneOffset ) + if (doneResponse.next_wake) { + config.onDoneNextWake?.(streamPath) + } } catch (err) { cleanupErrors.push(toError(err)) } @@ -2538,7 +2542,7 @@ async function sendDone( epoch: number, streamPath: string, offset: string | null -): Promise { +): Promise { const response = await fetch(callback, { method: `POST`, headers: await createClaimCallbackHeaders(claimHeaderConfig, token), @@ -2549,15 +2553,16 @@ async function sendDone( }), }) + const body = await response.text().catch(() => ``) if (!response.ok) { - let body = `` - try { - body = await response.text() - } catch { - body = `` - } throw new Error( `Done callback failed (${response.status}): ${body || response.statusText}` ) } + + const raw = parseClaimCallbackResponseBody(body) + return { + ...raw, + ok: raw.ok === undefined ? response.ok && raw.error === undefined : raw.ok, + } } diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 5ea066afaf..ab18a9ceeb 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -490,6 +490,12 @@ export function createPullWakeRunner( config.runtime.dispatchWake(notification, { claimHeaders: resolveClaimHeaders, claimTokenHeader: config.claimTokenHeader, + onDoneNextWake: (nextStreamPath) => { + scheduleDeferredWakeClaim( + { ...event, stream: nextStreamPath }, + signal + ) + }, }) } catch (err) { reportError(err) diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 7e4759ff8e..bf9c471cc7 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -788,6 +788,11 @@ export interface ProcessWakeConfig { }) => Array | Promise> /** Optional shutdown signal to end idle waits during host teardown. */ shutdownSignal?: AbortSignal + /** + * Called when the Durable Streams done callback reports that releasing this + * claim left pending work for the same subscription. + */ + onDoneNextWake?: (streamPath: string) => void /** Idle timeout in ms before closing the wake (default: 20_000) */ idleTimeout?: number /** Heartbeat interval in ms (default: 10_000) */ diff --git a/packages/agents-runtime/test/process-wake.test.ts b/packages/agents-runtime/test/process-wake.test.ts index 49c3a5460f..d6960d21c2 100644 --- a/packages/agents-runtime/test/process-wake.test.ts +++ b/packages/agents-runtime/test/process-wake.test.ts @@ -560,6 +560,34 @@ describe(`processWake`, () => { expect(body.epoch).toBe(1) }) + it(`notifies the runner when done leaves another wake pending`, async () => { + defineEntity(`test-agent`, { + handler: () => {}, + }) + const onDoneNextWake = vi.fn() + fetchMock.mockImplementation((url, opts) => { + const body = opts?.body ? JSON.parse(String(opts.body)) : {} + if (String(url).includes(`/_electric/wakes/wake-abc`) && body.done) { + return Promise.resolve( + new Response(JSON.stringify({ ok: true, next_wake: true }), { + status: 200, + headers: { 'content-type': `application/json` }, + }) + ) + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': `application/json` }, + }) + ) + }) + + await processWake(makeNotification(), { ...BASE_CONFIG, onDoneNextWake }) + + expect(onDoneNextWake).toHaveBeenCalledWith(`/streams/entity:agent-1`) + }) + it(`acks the local consumed offset on done even when the stream tail is ahead`, async () => { defineEntity(`test-agent`, { handler: () => {}, @@ -985,6 +1013,80 @@ describe(`processWake`, () => { ) }) + it(`does not ack queued inbox that arrived before SIGINT aborts an active run`, async () => { + const wakePayloads: Array = [] + let handlerStartedResolve: (() => void) | null = null + const handlerStarted = new Promise((resolve) => { + handlerStartedResolve = resolve + }) + let signalAbortedResolve: (() => void) | null = null + const signalAborted = new Promise((resolve) => { + signalAbortedResolve = resolve + }) + + defineEntity(`test-agent`, { + handler: async (ctx, wake) => { + wakePayloads.push(wake.payload) + handlerStartedResolve?.() + if (ctx.signal.aborted) { + signalAbortedResolve?.() + return + } + await new Promise((resolve) => { + ctx.signal.addEventListener( + `abort`, + () => { + signalAbortedResolve?.() + resolve() + }, + { once: true } + ) + }) + }, + }) + + const notification = makeNotification({ triggerEvent: `inbox` }) + notification.entity!.status = `running` + const wakePromise = processWake(notification, BASE_CONFIG) + await handlerStarted + + mockEntityOnBatch.current?.({ + items: [ + ev( + `inbox`, + `m-before-sigint`, + `insert`, + { payload: `should run in a later wake` }, + { offset: `10_500` } + ), + ev( + `signal`, + `sigint-handler`, + `insert`, + { signal: `SIGINT`, status: `unhandled` }, + { offset: `10_600` } + ), + ], + offset: `10_600`, + }) + + await signalAborted + await wakePromise + + expect(wakePayloads).toEqual([undefined]) + const doneCalls = fetchMock.mock.calls.filter( + ([url, opts]) => + String(url).includes(`/_electric/wakes/wake-abc`) && + (opts?.body as string | undefined)?.includes(`"done":true`) + ) + const body = JSON.parse(doneCalls.at(-1)![1]!.body as string) as { + acks: Array<{ path: string; offset: string }> + } + expect(body.acks).toEqual([ + { path: `/streams/entity:agent-1`, offset: `10_100` }, + ]) + }) + it(`does not continue fresh inbox work after SIGINT aborts an active run`, async () => { const wakePayloads: Array = [] let handlerStartedResolve: (() => void) | null = null diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 24cf53358b..a0a9bdf9dd 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -170,6 +170,7 @@ describe(`createPullWakeRunner`, () => { expect(testRuntime.dispatchWake).toHaveBeenCalledWith(claimed, { claimHeaders: expect.any(Function), claimTokenHeader: `electric-claim-token`, + onDoneNextWake: expect.any(Function), }) expect(testRuntime.drainWakes).not.toHaveBeenCalled() expect(runner.offset).toBe(`42`) @@ -380,6 +381,54 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) + it(`reclaims immediately when a dispatched wake reports pending work on done`, async () => { + const event = { ...wakeEvent(`one`), generation: 1 } + const claimed = notification(`one`) + const fetchMock = vi.fn(async (_input: RequestInfo | URL) => + Response.json(claimed) + ) + vi.stubGlobal(`fetch`, fetchMock) + let doneNextWake: ((streamPath: string) => void) | undefined + const testRuntime = { + ...runtime(), + dispatchWake: vi.fn((_notification, options) => { + doneNextWake = options?.onDoneNextWake + }), + } + const streamFactory = vi.fn(async () => ({ + offset: `42`, + async *jsonStream() { + yield event + }, + closed: Promise.resolve(), + })) + + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: testRuntime, + heartbeatIntervalMs: 0, + eventHeartbeatThrottleMs: 0, + streamFactory, + }) + + runner.start() + await waitFor(() => { + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(1) + }) + + doneNextWake?.(`/chat/one/main`) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + await waitFor(() => { + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(2) + }) + + await runner.stop() + }) + it(`skips stale wake events when claim returns no pending work`, async () => { const event = wakeEvent(`one`) const fetchMock = vi.fn(async (_input: RequestInfo | URL) => diff --git a/packages/agents-server/test/pull-wake-subscription-stack.test.ts b/packages/agents-server/test/pull-wake-subscription-stack.test.ts index 241cde77db..e43a6e182c 100644 --- a/packages/agents-server/test/pull-wake-subscription-stack.test.ts +++ b/packages/agents-server/test/pull-wake-subscription-stack.test.ts @@ -98,6 +98,69 @@ describe(`pull-wake subscription stack`, () => { }) }) + it(`emits another runner wake when ack leaves later work pending`, async () => { + dsServer = new DurableStreamTestServer({ + port: 0, + longPollTimeout: 100, + webhooks: true, + }) + await dsServer.start() + const streamBaseUrl = durableStreamTestServerUrl(dsServer.url) + const client = new StreamClient(streamBaseUrl) + + await client.ensure(`/runners/runner-1/wake`, { + contentType: `application/json`, + }) + await client.ensure(`/horton/one/main`, { + contentType: `application/json`, + }) + await client.putSubscription(`runner:runner-1:one`, { + type: `pull-wake`, + streams: [`/horton/one/main`], + wake_stream: `/runners/runner-1/wake`, + }) + + const first = await client.append( + `/horton/one/main`, + JSON.stringify({ type: `message`, value: `first` }) + ) + const claim = await client.claimSubscription( + `runner:runner-1:one`, + `worker-1` + ) + expect(claim).toMatchObject({ + generation: 1, + token: expect.any(String), + streams: [expect.objectContaining({ has_pending: true })], + }) + + await client.append( + `/horton/one/main`, + JSON.stringify({ type: `message`, value: `second` }) + ) + + const ack = await client.ackSubscription( + `runner:runner-1:one`, + claim!.token, + { + wake_id: claim!.wake_id, + generation: claim!.generation, + acks: [{ path: `horton/one/main`, offset: first.offset }], + done: true, + } + ) + expect(ack).toMatchObject({ ok: true, next_wake: true }) + + const wakes = await readJsonStream>( + streamBaseUrl, + `/runners/runner-1/wake` + ) + expect(wakes).toEqual([ + expect.objectContaining({ generation: 1 }), + expect.objectContaining({ generation: 2 }), + ]) + }) + it(`proxies pre-existing runner wake events to pull-wake runners`, async () => { dsServer = new DurableStreamTestServer({ port: 0, From c1f1aed4652463670618bf96ed65859607122342 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 23 Jun 2026 18:31:35 -0600 Subject: [PATCH 082/105] Do not ack pending inbox during SIGINT cleanup --- packages/agents-runtime/src/process-wake.ts | 40 +++++++++ .../agents-runtime/test/process-wake.test.ts | 87 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index ce40057e07..fe9e6c2662 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -634,7 +634,47 @@ export async function processWake( return left < right ? -1 : 1 } + const firstPendingRunnableFreshOffset = (): string | null => { + for (const batch of pendingLiveBatches) { + const events = toChangeEvents(batch) + const cancelledInboxKeys = new Set( + events.filter(isInboxCancellationEvent).map(inboxEventKey) + ) + for (const event of events) { + if (event.type === `wake`) { + return event.headers.offset ?? batch.offset + } + if (isInboxEvent(event)) { + if (cancelledInboxKeys.has(inboxEventKey(event))) continue + const value = event.value as + | { + payload?: unknown + mode?: `immediate` | `queued` | `paused` | `steer` + status?: `pending` | `processed` | `cancelled` + } + | undefined + if ( + value?.status !== `cancelled` && + (value?.payload !== undefined || + value?.mode === `queued` || + value?.mode === `steer`) + ) { + return event.headers.offset ?? batch.offset + } + } + } + } + return null + } + const setSafeAckOffset = (offset: string): void => { + const pendingFreshOffset = firstPendingRunnableFreshOffset() + if ( + pendingFreshOffset !== null && + compareOffsets(offset, pendingFreshOffset) >= 0 + ) { + return + } if (compareOffsets(offset, safeAckOffset) > 0) { safeAckOffset = offset } diff --git a/packages/agents-runtime/test/process-wake.test.ts b/packages/agents-runtime/test/process-wake.test.ts index d6960d21c2..c2a807e5a2 100644 --- a/packages/agents-runtime/test/process-wake.test.ts +++ b/packages/agents-runtime/test/process-wake.test.ts @@ -46,8 +46,12 @@ const { mockInitialManifests, mockEntityOnEvent, mockEntityOnBatch, + mockProducerLastSuccessfulOffset, } = vi.hoisted(() => ({ mockProducerAppend: vi.fn(), + mockProducerLastSuccessfulOffset: { + current: undefined as string | undefined, + }, mockProducerFlush: vi.fn().mockResolvedValue(undefined), mockProducerDetach: vi.fn().mockResolvedValue(undefined), mockConstructedProducers: [] as Array<{ @@ -131,6 +135,9 @@ vi.mock(`@durable-streams/client`, async (importOriginal) => { append = mockProducerAppend flush = mockProducerFlush detach = mockProducerDetach + get lastSuccessfulOffset() { + return mockProducerLastSuccessfulOffset.current + } } return { ...actual, @@ -457,6 +464,7 @@ describe(`processWake`, () => { mockSourceEvents.current = [] mockStreamOffset.value = `10_100` mockDbOffset.value = `10_100` + mockProducerLastSuccessfulOffset.current = undefined mockEntityOnEvent.current = null mockEntityOnBatch.current = null mockStreamHead.mockResolvedValue({ @@ -1166,6 +1174,85 @@ describe(`processWake`, () => { ]) }) + it(`does not ack a queued inbox when SIGINT cleanup writes advance the stream tail`, async () => { + const wakePayloads: Array = [] + let handlerStartedResolve: (() => void) | null = null + const handlerStarted = new Promise((resolve) => { + handlerStartedResolve = resolve + }) + let signalAbortedResolve: (() => void) | null = null + const signalAborted = new Promise((resolve) => { + signalAbortedResolve = resolve + }) + + defineEntity(`test-agent`, { + handler: async (ctx, wake) => { + wakePayloads.push(wake.payload) + handlerStartedResolve?.() + await new Promise((resolve) => { + ctx.signal.addEventListener( + `abort`, + () => { + signalAbortedResolve?.() + resolve() + }, + { once: true } + ) + }) + }, + }) + + const notification = makeNotification({ triggerEvent: `inbox` }) + notification.entity!.status = `running` + const wakePromise = processWake(notification, BASE_CONFIG) + await handlerStarted + + mockEntityOnBatch.current?.({ + items: [ + ev( + `signal`, + `sigint-handler`, + `insert`, + { signal: `SIGINT`, status: `unhandled` }, + { offset: `10_500` } + ), + ], + offset: `10_500`, + }) + mockEntityOnBatch.current?.({ + items: [ + ev( + `inbox`, + `m-after-sigint`, + `insert`, + { payload: `should run in a later wake` }, + { offset: `10_600` } + ), + ], + offset: `10_600`, + }) + + await signalAborted + // Simulate cleanup writes (e.g. signal handled / aborted run rows) being + // appended after the queued inbox. Acking this offset would swallow the + // unprocessed inbox and prevent Durable Streams from issuing next_wake. + mockProducerLastSuccessfulOffset.current = `10_700` + await wakePromise + + expect(wakePayloads).toEqual([undefined]) + const doneCalls = fetchMock.mock.calls.filter( + ([url, opts]) => + String(url).includes(`/_electric/wakes/wake-abc`) && + (opts?.body as string | undefined)?.includes(`"done":true`) + ) + const body = JSON.parse(doneCalls.at(-1)![1]!.body as string) as { + acks: Array<{ path: string; offset: string }> + } + expect(body.acks).toEqual([ + { path: `/streams/entity:agent-1`, offset: `10_500` }, + ]) + }) + it(`waits for async signal handlers even when the main handler fails`, async () => { let handlerStartedResolve: (() => void) | null = null const handlerStarted = new Promise((resolve) => { From 3532362498f19374cc222a5aeabe3409482c164e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 9 Jul 2026 09:27:26 -0600 Subject: [PATCH 083/105] fix(agents-runtime): avoid stack overflow on large live batches --- .changeset/large-live-batches.md | 5 +++++ packages/agents-runtime/src/process-wake.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changeset/large-live-batches.md diff --git a/.changeset/large-live-batches.md b/.changeset/large-live-batches.md new file mode 100644 index 0000000000..5a5128f468 --- /dev/null +++ b/.changeset/large-live-batches.md @@ -0,0 +1,5 @@ +--- +"@electric-ax/agents-runtime": patch +--- + +Avoid stack overflows when processing large live StreamDB batches. diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 472cba39b5..95ddc746aa 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -84,6 +84,12 @@ interface RawClaimCallbackResponse extends Omit { const DEFAULT_IDLE_TIMEOUT = 20_000 const DEFAULT_HEARTBEAT_INTERVAL = 10_000 + +function appendAll(target: Array, items: Array): void { + for (const item of items) { + target.push(item) + } +} type EntityStreamOptions = NonNullable< Parameters[3] > @@ -1075,7 +1081,7 @@ export async function processWake( // ctx.events so no child result is silently acked without being visible. pendingLiveBatches.shift() batches.push(batch) - deltaEvents.push(...changeEvents) + appendAll(deltaEvents, changeEvents) if (freshKind === `inbox`) { selectedKind = `inbox` @@ -1130,7 +1136,7 @@ export async function processWake( if (!preloaded) { const changeEvents = toChangeEvents(batch) if (changeEvents.length > 0) { - catchUpEvents.push(...changeEvents) + appendAll(catchUpEvents, changeEvents) } lastCatchUpOffset = batch.offset return @@ -1145,7 +1151,7 @@ export async function processWake( handleLatestSignalEvents(changeEvents) - catchUpEvents.push(...changeEvents) + appendAll(catchUpEvents, changeEvents) if ( resolveCurrentWakeReady !== null && From 3b30ad1ccfea18d369ae90e7f51e23d87f5fc7bc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 9 Jul 2026 15:17:31 -0600 Subject: [PATCH 084/105] Merge origin/fix-streamdb-stack-overflow-pr - resolve conflicts --- packages/agents-runtime/package.json | 2 +- .../package.json | 4 +- packages/agents-server-ui/package.json | 2 +- packages/agents-server/package.json | 2 +- packages/electric-ax/package.json | 4 +- pnpm-lock.yaml | 1067 ++++++----------- 6 files changed, 398 insertions(+), 683 deletions(-) diff --git a/packages/agents-runtime/package.json b/packages/agents-runtime/package.json index d886735c25..dcf2553628 100644 --- a/packages/agents-runtime/package.json +++ b/packages/agents-runtime/package.json @@ -108,7 +108,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@durable-streams/client": "^0.2.6", + "@durable-streams/client": "https://pkg.pr.new/@durable-streams/client@293", "@durable-streams/state": "^0.3.1", "@electric-ax/agents-mcp": "workspace:*", "@mariozechner/pi-agent-core": "^0.70.2", diff --git a/packages/agents-server-conformance-tests/package.json b/packages/agents-server-conformance-tests/package.json index 4e53be6917..5163966674 100644 --- a/packages/agents-server-conformance-tests/package.json +++ b/packages/agents-server-conformance-tests/package.json @@ -41,8 +41,8 @@ "stylecheck": "eslint . --quiet" }, "dependencies": { - "@durable-streams/client": "^0.2.6", - "@electric-sql/client": "^1.5.22", + "@durable-streams/client": "https://pkg.pr.new/@durable-streams/client@293", + "@electric-sql/client": "^1.5.21", "fast-check": "^4.6.0", "vitest": "^4.1.0" }, diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index ffc51a19c2..28eab3cff0 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", - "@durable-streams/client": "^0.2.6", + "@durable-streams/client": "https://pkg.pr.new/@durable-streams/client@293", "@durable-streams/state": "^0.3.1", "@electric-ax/agents-runtime": "workspace:*", "@handlewithcare/react-prosemirror": "^3.0.6", diff --git a/packages/agents-server/package.json b/packages/agents-server/package.json index dcc6c0f8b5..c67a366566 100644 --- a/packages/agents-server/package.json +++ b/packages/agents-server/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@durable-streams/client": "^0.2.6", + "@durable-streams/client": "https://pkg.pr.new/@durable-streams/client@293", "@durable-streams/server": "^0.3.7", "@durable-streams/state": "^0.3.1", "@electric-ax/agents-runtime": "workspace:*", diff --git a/packages/electric-ax/package.json b/packages/electric-ax/package.json index e9e3e39255..aa4d490ea3 100644 --- a/packages/electric-ax/package.json +++ b/packages/electric-ax/package.json @@ -46,7 +46,7 @@ "stylecheck": "eslint . --quiet" }, "dependencies": { - "@durable-streams/client": "^0.2.6", + "@durable-streams/client": "https://pkg.pr.new/@durable-streams/client@293", "@durable-streams/state": "^0.3.1", "@electric-ax/agents": "workspace:*", "@electric-ax/agents-runtime": "workspace:*", @@ -59,10 +59,10 @@ "react": "^19.2.0" }, "devDependencies": { - "@vitest/coverage-v8": "^4.1.0", "@types/node": "^22.19.15", "@types/omelette": "^0.4.5", "@types/react": "^19.2.14", + "@vitest/coverage-v8": "^4.1.0", "ink-testing-library": "^4.0.0", "tsdown": "^0.9.0", "tsx": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be33891451..891244b6e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -793,7 +793,7 @@ importers: version: link:../../packages/react-hooks next: specifier: ^14.2.5 - version: 14.2.17(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.17(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) pg: specifier: ^8.12.0 version: 8.13.1 @@ -1019,7 +1019,7 @@ importers: examples/tanstack-db-expo-starter: dependencies: '@electric-sql/client': - specifier: 1.5.24 + specifier: 1.5.21 version: link:../../packages/typescript-client '@expo/metro-runtime': specifier: ~5.0.4 @@ -1044,10 +1044,10 @@ importers: version: 4.1.0 drizzle-orm: specifier: ^0.44.3 - version: 0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) + version: 0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) drizzle-zod: specifier: ^0.8.2 - version: 0.8.2(drizzle-orm@0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7))(zod@4.0.10) + version: 0.8.2(drizzle-orm@0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7))(zod@4.0.10) expo: specifier: 53.0.20 version: 53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -1147,22 +1147,22 @@ importers: version: 11.7.2(typescript@5.7.2) better-auth: specifier: ^1.6.2 - version: 1.6.2(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.139.9(crossws@0.4.3(srvx@0.9.8))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)))(better-sqlite3@12.9.0)(drizzle-kit@0.30.6)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(next@14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(pg@8.16.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.7)(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.12(typescript@5.7.2)) + version: 1.6.2(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.139.9(crossws@0.4.3(srvx@0.9.8))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)))(better-sqlite3@12.9.0)(drizzle-kit@0.30.6)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(next@14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(pg@8.16.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.7)(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.12(typescript@5.7.2)) drizzle-kit: specifier: ^0.30.0 version: 0.30.6 drizzle-orm: specifier: ^0.39.0 - version: 0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) + version: 0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) drizzle-zod: specifier: ^0.7.1 - version: 0.7.1(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11) + version: 0.7.1(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11) lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.0) nitro: specifier: 3.0.1-alpha.1 - version: 3.0.1-alpha.1(@electric-sql/pglite@0.5.3)(aws4fetch@1.0.20)(better-sqlite3@12.9.0)(chokidar@4.0.3)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(lru-cache@11.5.1)(rollup@4.46.1)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(xml2js@0.6.2) + version: 3.0.1-alpha.1(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(better-sqlite3@12.9.0)(chokidar@4.0.3)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(lru-cache@11.3.5)(rollup@4.46.1)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(xml2js@0.6.2) pg: specifier: ^8.16.3 version: 8.16.3 @@ -1601,7 +1601,7 @@ importers: packages/agents-desktop: dependencies: '@electric-sql/client': - specifier: ^1.5.22 + specifier: ^1.5.21 version: link:../typescript-client '@mixmark-io/domino': specifier: ^2.2.0 @@ -1746,15 +1746,9 @@ importers: expo-router: specifier: ~6.0.24 version: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-splash-screen: - specifier: ~31.0.13 - version: 31.0.13(expo@54.0.35)(typescript@5.9.3) expo-status-bar: specifier: ~3.0.9 version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-system-ui: - specifier: ~6.0.9 - version: 6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) expo-web-browser: specifier: ~15.0.11 version: 15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) @@ -1823,8 +1817,8 @@ importers: specifier: ^0.78.0 version: 0.78.0(zod@4.3.6) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -1872,7 +1866,7 @@ importers: version: 13.1.3 react: specifier: '>=18' - version: 19.2.0 + version: 19.2.5 turndown: specifier: ^7.2.2 version: 7.2.4 @@ -1894,7 +1888,7 @@ importers: version: 0.3.7(@tanstack/db@0.6.7(typescript@5.9.3)) '@tanstack/react-db': specifier: ^0.1.85 - version: 0.1.85(react@19.2.0)(typescript@5.9.3) + version: 0.1.85(react@19.2.5)(typescript@5.9.3) '@types/dockerode': specifier: ^4.0.1 version: 4.0.1 @@ -1929,8 +1923,8 @@ importers: specifier: ^0.78.0 version: 0.78.0(zod@4.4.3) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/server': specifier: ^0.3.7 version: 0.3.7(@tanstack/db@0.6.7(typescript@5.8.3)) @@ -1941,7 +1935,7 @@ importers: specifier: workspace:* version: link:../agents-runtime '@electric-sql/client': - specifier: ^1.5.22 + specifier: ^1.5.21 version: link:../typescript-client '@mariozechner/pi-agent-core': specifier: ^0.70.2 @@ -1969,7 +1963,7 @@ importers: version: 5.5.0 drizzle-orm: specifier: ^0.44.0 - version: 0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) + version: 0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) fastq: specifier: ^1.20.1 version: 1.20.1 @@ -2029,10 +2023,10 @@ importers: packages/agents-server-conformance-tests: dependencies: '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@electric-sql/client': - specifier: ^1.5.22 + specifier: ^1.5.21 version: link:../typescript-client fast-check: specifier: ^4.6.0 @@ -2060,8 +2054,8 @@ importers: specifier: ^1.4.1 version: 1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -2166,20 +2160,11 @@ importers: specifier: ^4.1.0 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.1.7(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) - packages/durable-streams-rust: - devDependencies: - '@durable-streams/server-conformance-tests': - specifier: ^0.3.5 - version: 0.3.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) - vitest: - specifier: ^4.1.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) - packages/electric-ax: dependencies: '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -2190,7 +2175,7 @@ importers: specifier: workspace:* version: link:../agents-runtime '@electric-sql/client': - specifier: ^1.5.22 + specifier: ^1.5.21 version: link:../typescript-client '@tanstack/db': specifier: ^0.6.6 @@ -2559,11 +2544,11 @@ importers: website: dependencies: '@electric-sql/pglite': - specifier: 0.5.3 - version: 0.5.3 + specifier: 0.4.5 + version: 0.4.5 '@electric-sql/pglite-repl': - specifier: 0.4.3 - version: 0.4.3(@babel/runtime@7.29.7)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.5.3)(@lezer/common@1.5.2)(codemirror@6.0.1(@lezer/common@1.5.2)) + specifier: 0.3.5 + version: 0.3.5(@babel/runtime@7.29.7)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.4.5)(@lezer/common@1.5.2)(codemirror@6.0.1(@lezer/common@1.5.2)) posthog-js: specifier: ^1.236.4 version: 1.236.4 @@ -3005,10 +2990,6 @@ packages: resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.26.2': resolution: {integrity: sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==} engines: {node: '>=6.9.0'} @@ -3320,10 +3301,6 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - '@babel/highlight@7.25.9': resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} engines: {node: '>=6.9.0'} @@ -4590,9 +4567,6 @@ packages: '@codemirror/state@6.6.0': resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} - '@codemirror/state@6.7.0': - resolution: {integrity: sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==} - '@codemirror/theme-one-dark@6.1.2': resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==} @@ -4602,8 +4576,8 @@ packages: '@codemirror/view@6.41.1': resolution: {integrity: sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==} - '@codemirror/view@6.43.2': - resolution: {integrity: sha512-8kU6WNRYBKV9Sw3cxNz+uSvUvx3tt+1qgupGFPubnbLFDHOgh5qQdIGmXcD7bkA/PROK6LDKVhKMpcY7H++Amg==} + '@codemirror/view@6.43.0': + resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} '@connectrpc/connect-web@2.0.0-rc.3': resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} @@ -4672,14 +4646,6 @@ packages: css-tree: optional: true - '@csstools/css-syntax-patches-for-csstree@1.1.5': - resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} @@ -4778,8 +4744,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - '@durable-streams/server-conformance-tests@0.3.5': - resolution: {integrity: sha512-sXl2S562yVa+gO9ypZMJ3rC9lMpsUoc7XIBw24iY/k8g0RczgKdK3jYpgc10bttZLQLLGQMo3QxZ+pbGgIO5dw==} + '@durable-streams/client@https://pkg.pr.new/@durable-streams/client@293': + resolution: {tarball: https://pkg.pr.new/@durable-streams/client@293} + version: 0.2.6 engines: {node: '>=18.0.0'} hasBin: true @@ -4826,10 +4793,10 @@ packages: '@electric-sql/pglite': ^0.2.17 react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - '@electric-sql/pglite-react@0.4.3': - resolution: {integrity: sha512-FcRkdB9UzfHgloFAd5b4+G68H+f9SsySEzLG2wMUgQpJxnUx91ygC0qj44ZRG11M7xfhaqndpbZba57NkWWitQ==} + '@electric-sql/pglite-react@0.3.5': + resolution: {integrity: sha512-cuW/UFWkGgGlyYNmygqHqIanyvZxlEjpMW9CC3Io97Jedyengu2tgyA3CBgXrX4ky0hZ8RArAxx4ORw5pTJ/aA==} peerDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc '@electric-sql/pglite-repl@0.2.17': @@ -4840,10 +4807,10 @@ packages: '@electric-sql/pglite': optional: true - '@electric-sql/pglite-repl@0.4.3': - resolution: {integrity: sha512-h0DR+tEomHqg/k6OAlWReqFS2N/skr6zXguy+1wFlAyApO01LriYOz0vLDQwRDjTRW7RMMo8dJ+ZYbOdxyarCQ==} + '@electric-sql/pglite-repl@0.3.5': + resolution: {integrity: sha512-ervtpggth+47qoLGAF0jAyrc8aVOmyT0ioU9skfjrIN2cP0LxGBIfbhHNehCfCVVTJESvD/KnvdOdgT/lFBLWw==} peerDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 peerDependenciesMeta: '@electric-sql/pglite': optional: true @@ -4856,8 +4823,8 @@ packages: '@electric-sql/pglite@0.2.17': resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} - '@electric-sql/pglite@0.5.3': - resolution: {integrity: sha512-iTTYbA5Uesrl+N7zss0J5LopT7KE4j9aymYo+EZZh+rZbARQCUQOs+n2pay64JRUpc3fCkpfrniTNJnvYzOE+g==} + '@electric-sql/pglite@0.4.5': + resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==} '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} @@ -6070,15 +6037,6 @@ packages: '@noble/hashes': optional: true - '@exodus/bytes@1.15.1': - resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - '@exodus/schemasafe@1.3.0': resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} @@ -8815,8 +8773,8 @@ packages: '@types/react-dom': optional: true - '@react-grab/cli@0.1.48': - resolution: {integrity: sha512-KXRZFN0b78BeVa4Tq1FC9kiXPpC5lS4pQp/mvQ1azy9dZUJ3zfc7Ei84+yvGh+WoYdceMCFxXfBp6qhU/G056g==} + '@react-grab/cli@0.1.47': + resolution: {integrity: sha512-Cc7d8mSwvoV8gpeTQbE8dMPdeXIyO6w+yIhzgi3jY06i03WLNhb/6jIxNBNF1cVRI7ujnFQXZA66BbnBNTpBSw==} hasBin: true '@react-native-async-storage/async-storage@2.2.0': @@ -11595,11 +11553,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -12033,11 +11986,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.10.38: - resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} - engines: {node: '>=6.0.0'} - hasBin: true - basic-ftp@5.3.0: resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} engines: {node: '>=10.0.0'} @@ -12153,8 +12101,8 @@ packages: peerDependencies: react: '>=17.0.1' - bippy@0.5.43: - resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} + bippy@0.5.41: + resolution: {integrity: sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg==} peerDependencies: react: '>=17.0.1' @@ -12227,11 +12175,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -12373,9 +12316,6 @@ packages: caniuse-lite@1.0.30001791: resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} - caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -13690,9 +13630,6 @@ packages: electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} - electron-to-chromium@1.5.378: - resolution: {integrity: sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==} - electron-to-chromium@1.5.52: resolution: {integrity: sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ==} @@ -13790,10 +13727,6 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-abstract-get@1.0.0: - resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} - engines: {node: '>= 0.4'} - es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -13849,10 +13782,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.1: - resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} - engines: {node: '>= 0.4'} - es-toolkit@1.46.0: resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} @@ -14494,11 +14423,6 @@ packages: resolution: {integrity: sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==} engines: {node: '>=20.16.0'} - expo-splash-screen@31.0.13: - resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} - peerDependencies: - expo: '*' - expo-status-bar@2.2.3: resolution: {integrity: sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==} peerDependencies: @@ -14511,16 +14435,6 @@ packages: react: '*' react-native: '*' - expo-system-ui@6.0.9: - resolution: {integrity: sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==} - peerDependencies: - expo: '*' - react-native: '*' - react-native-web: '*' - peerDependenciesMeta: - react-native-web: - optional: true - expo-web-browser@15.0.11: resolution: {integrity: sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==} peerDependencies: @@ -14910,10 +14824,6 @@ packages: resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} - function.prototype.name@1.2.0: - resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} - engines: {node: '>= 0.4'} - functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} @@ -15611,10 +15521,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - is-document.all@1.0.0: - resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} - engines: {node: '>= 0.4'} - is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -16535,10 +16441,6 @@ packages: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -17372,10 +17274,6 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - node-releases@2.0.49: - resolution: {integrity: sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==} - engines: {node: '>=18'} - nodemon@3.1.7: resolution: {integrity: sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==} engines: {node: '>=10'} @@ -17633,8 +17531,8 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - ora@9.4.1: - resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} ordered-binary@1.6.1: @@ -18608,8 +18506,8 @@ packages: peerDependencies: react: '>=17.0.0' - react-grab@0.1.48: - resolution: {integrity: sha512-p3WnmK9LLvXE/c4ITPLlXcP1fkXo2VFEQqK94tIfcHIWKNdqdhYYFyNVioO50HR+uyHIwT63Z4txZDqJlVcD/Q==} + react-grab@0.1.47: + resolution: {integrity: sha512-1GNy24KMJ4CY1IxorYO9mydItGi0L1HkQB19uYU3t0BMsJB0K+D/QYiaBz+rugRynyY8LzmXIuOcon1TykLlCg==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -19968,14 +19866,6 @@ packages: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} - string.prototype.trim@1.2.11: - resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.10: - resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} - engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} engines: {node: '>= 0.4'} @@ -20727,10 +20617,6 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -21595,8 +21481,8 @@ packages: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + which-typed-array@1.1.21: + resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==} engines: {node: '>= 0.4'} which@1.3.1: @@ -22752,26 +22638,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/generator@7.26.2': dependencies: '@babel/parser': 7.29.2 @@ -22860,7 +22726,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -22916,13 +22782,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/traverse': 7.29.7 semver: 6.3.1 @@ -22943,9 +22809,9 @@ snapshots: regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 @@ -22972,17 +22838,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - '@babel/helper-globals@7.28.0': {} '@babel/helper-globals@7.29.7': {} @@ -23018,7 +22873,7 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -23090,9 +22945,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 @@ -23137,9 +22992,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-wrap-function': 7.29.7 '@babel/traverse': 7.29.7 @@ -23182,9 +23037,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7 @@ -23274,11 +23129,6 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - '@babel/highlight@7.25.9': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -23310,44 +23160,44 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: @@ -23389,9 +23239,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.0)': dependencies: @@ -23473,9 +23323,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.28.0)': @@ -23488,9 +23338,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.0)': @@ -23623,10 +23473,10 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.28.0)': @@ -23639,9 +23489,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.28.0)': @@ -23662,11 +23512,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.0) '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -23689,18 +23539,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.28.0)': @@ -23713,9 +23563,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.28.0)': @@ -23734,10 +23584,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -23750,10 +23600,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -23782,14 +23632,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0) '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -23806,9 +23656,9 @@ snapshots: '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.29.7 - '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 @@ -23833,47 +23683,47 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.28.0)': @@ -23886,9 +23736,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.0)': @@ -23919,9 +23769,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 transitivePeerDependencies: @@ -23945,18 +23795,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-literals@7.25.9(@babel/core@7.28.0)': @@ -23969,9 +23819,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.28.0)': @@ -23984,20 +23834,20 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -24026,28 +23876,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -24064,15 +23914,15 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.28.0)': @@ -24085,9 +23935,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.28.0)': @@ -24100,9 +23950,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.28.0)': @@ -24134,22 +23984,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.0) '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -24163,9 +24013,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.28.0)': @@ -24184,9 +24034,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 transitivePeerDependencies: @@ -24207,9 +24057,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.28.0)': @@ -24236,10 +24086,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -24271,18 +24121,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.0)': @@ -24384,20 +24234,20 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-runtime@7.28.0(@babel/core@7.28.0)': @@ -24434,9 +24284,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-spread@7.25.9(@babel/core@7.28.0)': @@ -24455,9 +24305,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 transitivePeerDependencies: @@ -24473,19 +24323,19 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.0)': @@ -24510,15 +24360,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.28.0)': @@ -24533,98 +24383,98 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + '@babel/preset-env@7.29.7(@babel/core@7.29.0)': dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) - '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.29.7 '@babel/types': 7.29.7 esutils: 2.0.3 @@ -24845,12 +24695,12 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 - '@better-auth/drizzle-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))': + '@better-auth/drizzle-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))': dependencies: '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) '@better-auth/kysely-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: @@ -25084,18 +24934,18 @@ snapshots: '@codemirror/view': 6.35.2 '@lezer/common': 1.5.2 - '@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.43.2)(@lezer/common@1.2.3)': + '@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.41.1)(@lezer/common@1.2.3)': dependencies: '@codemirror/language': 6.10.6 '@codemirror/state': 6.5.0 - '@codemirror/view': 6.43.2 + '@codemirror/view': 6.41.1 '@lezer/common': 1.2.3 - '@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2)': + '@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2)': dependencies: '@codemirror/language': 6.12.3 - '@codemirror/state': 6.7.0 - '@codemirror/view': 6.43.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 '@lezer/common': 1.5.2 '@codemirror/commands@6.7.1': @@ -25126,9 +24976,9 @@ snapshots: transitivePeerDependencies: - '@codemirror/view' - '@codemirror/lang-sql@6.8.0(@codemirror/view@6.43.2)': + '@codemirror/lang-sql@6.8.0(@codemirror/view@6.41.1)': dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.43.2)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.41.1)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 '@codemirror/state': 6.5.0 '@lezer/common': 1.2.3 @@ -25148,8 +24998,8 @@ snapshots: '@codemirror/language@6.12.3': dependencies: - '@codemirror/state': 6.7.0 - '@codemirror/view': 6.43.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -25175,15 +25025,11 @@ snapshots: dependencies: '@marijn/find-cluster-break': 1.0.2 - '@codemirror/state@6.7.0': - dependencies: - '@marijn/find-cluster-break': 1.0.2 - '@codemirror/theme-one-dark@6.1.2': dependencies: '@codemirror/language': 6.12.3 - '@codemirror/state': 6.7.0 - '@codemirror/view': 6.43.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 '@lezer/highlight': 1.2.3 '@codemirror/view@6.35.2': @@ -25199,9 +25045,9 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@codemirror/view@6.43.2': + '@codemirror/view@6.43.0': dependencies: - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.6.0 crelt: 1.0.6 style-mod: 4.1.3 w3c-keyname: 2.2.8 @@ -25259,11 +25105,6 @@ snapshots: optionalDependencies: css-tree: 3.2.1 - '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - optional: true - '@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@4.0.0': {} @@ -25454,25 +25295,10 @@ snapshots: '@microsoft/fetch-event-source': 2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188) fastq: 1.20.1 - '@durable-streams/server-conformance-tests@0.3.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))': + '@durable-streams/client@https://pkg.pr.new/@durable-streams/client@293': dependencies: - '@durable-streams/client': 0.2.6 - fast-check: 4.6.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) - transitivePeerDependencies: - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@types/node' - - '@vitest/browser-playwright' - - '@vitest/browser-preview' - - '@vitest/browser-webdriverio' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - happy-dom - - jsdom - - msw - - vite + '@microsoft/fetch-event-source': 2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188) + fastq: 1.20.1 '@durable-streams/server@0.3.7(@tanstack/db@0.6.7(typescript@5.8.3))': dependencies: @@ -25551,9 +25377,9 @@ snapshots: '@electric-sql/pglite': 0.2.17 react: 19.2.5 - '@electric-sql/pglite-react@0.4.3(@electric-sql/pglite@0.5.3)(react@19.2.5)': + '@electric-sql/pglite-react@0.3.5(@electric-sql/pglite@0.4.5)(react@19.2.5)': dependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 react: 19.2.5 '@electric-sql/pglite-repl@0.2.17(@babel/runtime@7.29.7)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.5.0)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.2.17)(@lezer/common@1.2.3)(codemirror@6.0.1(@lezer/common@1.2.3))': @@ -25582,23 +25408,23 @@ snapshots: - '@lezer/common' - codemirror - '@electric-sql/pglite-repl@0.4.3(@babel/runtime@7.29.7)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.5.3)(@lezer/common@1.5.2)(codemirror@6.0.1(@lezer/common@1.5.2))': + '@electric-sql/pglite-repl@0.3.5(@babel/runtime@7.29.7)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.4.5)(@lezer/common@1.5.2)(codemirror@6.0.1(@lezer/common@1.5.2))': dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2) '@codemirror/commands': 6.7.1 - '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.43.2) + '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.41.1) '@codemirror/language': 6.12.3 - '@codemirror/view': 6.43.2 - '@electric-sql/pglite-react': 0.4.3(@electric-sql/pglite@0.5.3)(react@19.2.5) - '@uiw/codemirror-theme-github': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) - '@uiw/codemirror-theme-xcode': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) - '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) - '@uiw/react-codemirror': 4.23.5(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.43.2)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@codemirror/view': 6.41.1 + '@electric-sql/pglite-react': 0.3.5(@electric-sql/pglite@0.4.5)(react@19.2.5) + '@uiw/codemirror-theme-github': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) + '@uiw/codemirror-theme-xcode': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) + '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) + '@uiw/react-codemirror': 4.23.5(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.41.1)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) psql-describe: 0.1.6 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 transitivePeerDependencies: - '@babel/runtime' - '@codemirror/lint' @@ -25615,7 +25441,7 @@ snapshots: '@electric-sql/pglite@0.2.17': {} - '@electric-sql/pglite@0.5.3': {} + '@electric-sql/pglite@0.4.5': {} '@electron/asar@3.4.1': dependencies: @@ -26471,11 +26297,6 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 - '@exodus/bytes@1.15.1(@noble/hashes@2.0.1)': - optionalDependencies: - '@noble/hashes': 2.0.1 - optional: true - '@exodus/schemasafe@1.3.0': {} '@expo/cli@0.24.20': @@ -29983,12 +29804,12 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@react-grab/cli@0.1.48': + '@react-grab/cli@0.1.47': dependencies: agent-install: 0.0.6 commander: 14.0.3 ignore: 7.0.5 - ora: 9.4.1 + ora: 9.4.0 package-manager-detector: 1.6.0 picocolors: 1.1.1 prompts: 2.4.2 @@ -30061,7 +29882,7 @@ snapshots: '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0) '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.28.0) - '@babel/template': 7.29.7 + '@babel/template': 7.28.6 '@react-native/babel-plugin-codegen': 0.79.5(@babel/core@7.28.0) babel-plugin-syntax-hermes-parser: 0.25.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.28.0) @@ -30502,9 +30323,9 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.79.2)': + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@2.79.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.29.7 '@rollup/pluginutils': 3.1.0(rollup@2.79.2) rollup: 2.79.2 @@ -31707,6 +31528,14 @@ snapshots: transitivePeerDependencies: - typescript + '@tanstack/react-db@0.1.85(react@19.2.5)(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.6.7(typescript@5.9.3) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + transitivePeerDependencies: + - typescript + '@tanstack/react-query-persist-client@5.59.20(@tanstack/react-query@5.59.20(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-persist-client-core': 5.59.20 @@ -33295,15 +33124,15 @@ snapshots: '@typescript-eslint/types': 8.46.0 eslint-visitor-keys: 4.2.1 - '@uiw/codemirror-extensions-basic-setup@4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2))(@codemirror/commands@6.7.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)': + '@uiw/codemirror-extensions-basic-setup@4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2))(@codemirror/commands@6.7.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2) '@codemirror/commands': 6.7.1 '@codemirror/language': 6.12.3 '@codemirror/lint': 6.8.4 '@codemirror/search': 6.5.8 - '@codemirror/state': 6.7.0 - '@codemirror/view': 6.43.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 '@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)(@lezer/common@1.2.3))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.6)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)': dependencies: @@ -33315,9 +33144,9 @@ snapshots: '@codemirror/state': 6.5.0 '@codemirror/view': 6.35.2 - '@uiw/codemirror-theme-github@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)': + '@uiw/codemirror-theme-github@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: - '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) + '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) transitivePeerDependencies: - '@codemirror/language' - '@codemirror/state' @@ -33331,9 +33160,9 @@ snapshots: - '@codemirror/state' - '@codemirror/view' - '@uiw/codemirror-theme-xcode@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)': + '@uiw/codemirror-theme-xcode@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: - '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) + '@uiw/codemirror-themes': 4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) transitivePeerDependencies: - '@codemirror/language' - '@codemirror/state' @@ -33347,11 +33176,11 @@ snapshots: - '@codemirror/state' - '@codemirror/view' - '@uiw/codemirror-themes@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)': + '@uiw/codemirror-themes@4.23.5(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: '@codemirror/language': 6.12.3 - '@codemirror/state': 6.7.0 - '@codemirror/view': 6.43.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 '@uiw/codemirror-themes@4.23.6(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.2)': dependencies: @@ -33359,14 +33188,14 @@ snapshots: '@codemirror/state': 6.5.0 '@codemirror/view': 6.35.2 - '@uiw/react-codemirror@4.23.5(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.43.2)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@uiw/react-codemirror@4.23.5(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.41.1)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.7 '@codemirror/commands': 6.7.1 - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.6.0 '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.43.2 - '@uiw/codemirror-extensions-basic-setup': 4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(@lezer/common@1.5.2))(@codemirror/commands@6.7.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2) + '@codemirror/view': 6.41.1 + '@uiw/codemirror-extensions-basic-setup': 4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)(@lezer/common@1.5.2))(@codemirror/commands@6.7.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1) codemirror: 6.0.1(@lezer/common@1.5.2) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -34020,8 +33849,6 @@ snapshots: acorn@8.16.0: {} - acorn@8.17.0: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -34478,15 +34305,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 @@ -34503,10 +34321,10 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color @@ -34525,13 +34343,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color - babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.0 @@ -34715,18 +34526,16 @@ snapshots: baseline-browser-mapping@2.10.23: {} - baseline-browser-mapping@2.10.38: {} - basic-ftp@5.3.0: {} bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 - better-auth@1.6.2(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.139.9(crossws@0.4.3(srvx@0.9.8))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)))(better-sqlite3@12.9.0)(drizzle-kit@0.30.6)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(next@14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(pg@8.16.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.7)(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.12(typescript@5.7.2)): + better-auth@1.6.2(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.139.9(crossws@0.4.3(srvx@0.9.8))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)))(better-sqlite3@12.9.0)(drizzle-kit@0.30.6)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(next@14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(pg@8.16.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.7)(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.12(typescript@5.7.2)): dependencies: '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) + '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) '@better-auth/kysely-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.1.11))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) @@ -34746,7 +34555,7 @@ snapshots: '@tanstack/react-start': 1.139.9(crossws@0.4.3(srvx@0.9.8))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) better-sqlite3: 12.9.0 drizzle-kit: 0.30.6 - drizzle-orm: 0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) next: 14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) pg: 8.16.3 react: 19.2.0 @@ -34806,7 +34615,7 @@ snapshots: dependencies: react: 19.1.0 - bippy@0.5.43(react@19.1.0): + bippy@0.5.41(react@19.1.0): dependencies: react: 19.1.0 @@ -34918,14 +34727,6 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - browserslist@4.28.4: - dependencies: - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.378 - node-releases: 2.0.49 - update-browserslist-db: 1.2.3(browserslist@4.28.4) - bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -35080,8 +34881,6 @@ snapshots: caniuse-lite@1.0.30001791: {} - caniuse-lite@1.0.30001799: {} - caseless@0.12.0: {} ccount@2.0.1: {} @@ -35909,11 +35708,11 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@electric-sql/pglite@0.5.3)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)): + db0@0.3.4(@electric-sql/pglite@0.4.5)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)): optionalDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 better-sqlite3: 12.9.0 - drizzle-orm: 0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) de-indent@1.0.2: {} @@ -36215,9 +36014,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7): + drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7): optionalDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.15.4 @@ -36226,9 +36025,9 @@ snapshots: pg: 8.16.3 postgres: 3.4.7 - drizzle-orm@0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7): + drizzle-orm@0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7): optionalDependencies: - '@electric-sql/pglite': 0.5.3 + '@electric-sql/pglite': 0.4.5 '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.15.4 @@ -36238,14 +36037,14 @@ snapshots: pg: 8.16.3 postgres: 3.4.7 - drizzle-zod@0.7.1(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11): + drizzle-zod@0.7.1(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11): dependencies: - drizzle-orm: 0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7) zod: 4.1.11 - drizzle-zod@0.8.2(drizzle-orm@0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7))(zod@4.0.10): + drizzle-zod@0.8.2(drizzle-orm@0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7))(zod@4.0.10): dependencies: - drizzle-orm: 0.44.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) + drizzle-orm: 0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(gel@2.2.0)(kysely@0.29.2)(pg@8.16.3)(postgres@3.4.7) zod: 4.0.10 dts-resolver@1.2.0: @@ -36346,8 +36145,6 @@ snapshots: electron-to-chromium@1.5.344: {} - electron-to-chromium@1.5.378: {} - electron-to-chromium@1.5.52: {} electron-updater@6.8.3: @@ -36440,7 +36237,7 @@ snapshots: array.prototype.flat: 1.3.3 cheerio: 1.2.0 enzyme-shallow-equal: 1.0.7 - function.prototype.name: 1.2.0 + function.prototype.name: 1.1.8 has: 1.0.4 html-element-map: 1.4.0 is-boolean-object: 1.2.2 @@ -36458,7 +36255,7 @@ snapshots: object.values: 1.2.1 raf: 3.4.1 rst-selector-parser: 2.2.3 - string.prototype.trim: 1.2.11 + string.prototype.trim: 1.2.10 err-code@2.0.3: {} @@ -36470,13 +36267,6 @@ snapshots: dependencies: stackframe: 1.3.4 - es-abstract-get@1.0.0: - dependencies: - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - is-callable: 1.2.7 - object-inspect: 1.13.4 - es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -36548,8 +36338,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.1 - function.prototype.name: 1.2.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -36581,15 +36371,15 @@ snapshots: safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.11 - string.prototype.trimend: 1.0.10 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + which-typed-array: 1.1.21 es-array-method-boxes-properly@1.0.0: {} @@ -36661,14 +36451,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es-to-primitive@1.3.1: - dependencies: - es-abstract-get: 1.0.0 - es-errors: 1.3.0 - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - es-toolkit@1.46.0: {} es6-error@4.1.1: @@ -37643,14 +37425,6 @@ snapshots: expo-server@1.0.7: {} - expo-splash-screen@31.0.13(expo@54.0.35)(typescript@5.9.3): - dependencies: - '@expo/prebuild-config': 54.0.8(expo@54.0.35)(typescript@5.9.3) - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - typescript - expo-status-bar@2.2.3(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -37664,17 +37438,6 @@ snapshots: react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-system-ui@6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): - dependencies: - '@react-native/normalize-colors': 0.81.5 - debug: 4.4.3 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - optionalDependencies: - react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - transitivePeerDependencies: - - supports-color - expo-web-browser@15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) @@ -38232,18 +37995,6 @@ snapshots: hasown: 2.0.3 is-callable: 1.2.7 - function.prototype.name@1.2.0: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - es-define-property: 1.0.1 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - hasown: 2.0.4 - is-callable: 1.2.7 - is-document.all: 1.0.0 - functions-have-names@1.2.3: {} funtypes@4.2.0: {} @@ -39100,10 +38851,6 @@ snapshots: is-docker@3.0.0: {} - is-document.all@1.0.0: - dependencies: - call-bound: 1.0.4 - is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -39406,7 +39153,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.29.7 + '@babel/code-frame': 7.29.0 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -39628,19 +39375,19 @@ snapshots: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) - '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) css-tree: 3.2.1 data-urls: 7.0.0(@noble/hashes@2.0.1) decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.1 + lru-cache: 11.3.5 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.25.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -40092,9 +39839,6 @@ snapshots: lru-cache@11.3.5: {} - lru-cache@11.5.1: - optional: true - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -40141,8 +39885,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: @@ -41372,7 +41116,7 @@ snapshots: - babel-plugin-macros optional: true - next@14.2.17(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.17(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.17 '@swc/helpers': 0.5.5 @@ -41382,7 +41126,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.29.7)(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.29.0)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 14.2.17 '@next/swc-darwin-x64': 14.2.17 @@ -41401,11 +41145,11 @@ snapshots: nf3@0.1.12: {} - nitro@3.0.1-alpha.1(@electric-sql/pglite@0.5.3)(aws4fetch@1.0.20)(better-sqlite3@12.9.0)(chokidar@4.0.3)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(lru-cache@11.5.1)(rollup@4.46.1)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(xml2js@0.6.2): + nitro@3.0.1-alpha.1(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(better-sqlite3@12.9.0)(chokidar@4.0.3)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7))(lru-cache@11.3.5)(rollup@4.46.1)(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))(xml2js@0.6.2): dependencies: consola: 3.4.2 crossws: 0.4.3(srvx@0.9.8) - db0: 0.3.4(@electric-sql/pglite@0.5.3)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) + db0: 0.3.4(@electric-sql/pglite@0.4.5)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) h3: 2.0.1-rc.5(crossws@0.4.3(srvx@0.9.8)) jiti: 2.6.1 nf3: 0.1.12 @@ -41416,7 +41160,7 @@ snapshots: srvx: 0.9.8 undici: 7.25.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.5(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.5.3)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.5(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.4.5)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)))(lru-cache@11.3.5)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.46.1 vite: 7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0) @@ -41519,8 +41263,6 @@ snapshots: node-releases@2.0.38: {} - node-releases@2.0.49: {} - nodemon@3.1.7: dependencies: chokidar: 3.6.0 @@ -41824,7 +41566,7 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - ora@9.4.1: + ora@9.4.0: dependencies: chalk: 5.6.2 cli-cursor: 5.0.0 @@ -42941,10 +42683,10 @@ snapshots: dependencies: react: 19.1.0 - react-grab@0.1.48(react@19.1.0): + react-grab@0.1.47(react@19.1.0): dependencies: - '@react-grab/cli': 0.1.48 - bippy: 0.5.43(react@19.1.0) + '@react-grab/cli': 0.1.47 + bippy: 0.5.41(react@19.1.0) optionalDependencies: react: 19.1.0 @@ -43297,7 +43039,7 @@ snapshots: prompts: 2.4.2 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-grab: 0.1.48(react@19.1.0) + react-grab: 0.1.47(react@19.1.0) optionalDependencies: esbuild: 0.27.7 unplugin: 2.1.0 @@ -44783,24 +44525,6 @@ snapshots: es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 - string.prototype.trim@1.2.11: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - has-property-descriptors: 1.0.2 - safe-regex-test: 1.1.0 - - string.prototype.trimend@1.0.10: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - string.prototype.trimend@1.0.9: dependencies: call-bind: 1.0.8 @@ -44919,12 +44643,12 @@ snapshots: '@babel/core': 7.28.4 optional: true - styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1): + styled-jsx@5.1.1(@babel/core@7.29.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 styleq@0.1.3: {} @@ -45163,7 +44887,7 @@ snapshots: terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -45688,9 +45412,6 @@ snapshots: undici@7.25.0: {} - undici@7.28.0: - optional: true - unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -45789,12 +45510,12 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.5(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.5.3)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.5(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.4.5)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)))(lru-cache@11.3.5)(ofetch@2.0.0-alpha.3): optionalDependencies: aws4fetch: 1.0.20 chokidar: 4.0.3 - db0: 0.3.4(@electric-sql/pglite@0.5.3)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) - lru-cache: 11.5.1 + db0: 0.3.4(@electric-sql/pglite@0.4.5)(better-sqlite3@12.9.0)(drizzle-orm@0.39.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@12.9.0)(kysely@0.28.17)(pg@8.16.3)(postgres@3.4.7)) + lru-cache: 11.3.5 ofetch: 2.0.0-alpha.3 upath@1.2.0: {} @@ -45811,12 +45532,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.4): - dependencies: - browserslist: 4.28.4 - escalade: 3.2.0 - picocolors: 1.1.1 - uri-js-replace@1.0.1: {} uri-js@4.4.1: @@ -47047,7 +46762,7 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 - which-typed-array@1.1.22: + which-typed-array@1.1.21: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 @@ -47104,10 +46819,10 @@ snapshots: workbox-build@7.3.0(@types/babel__core@7.20.5): dependencies: '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) - '@babel/core': 7.29.7 - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/preset-env': 7.29.7(@babel/core@7.29.0) '@babel/runtime': 7.29.7 - '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.79.2) + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@2.79.2) '@rollup/plugin-node-resolve': 15.3.1(rollup@2.79.2) '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) '@rollup/plugin-terser': 0.4.4(rollup@2.79.2) From e2b4205733c5c124b915f1b718abd827dd9001a5 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 13 Jul 2026 10:45:13 -0600 Subject: [PATCH 085/105] Upgrade pi agent packages and Codex defaults --- examples/agents-chat-starter/package.json | 1 - examples/deep-survey/package.json | 1 - .../deep-survey/src/server/orchestrator.ts | 2 +- .../src/credentials/model-picker.ts | 4 +- packages/agents-runtime/package.json | 4 +- .../src/compaction-summarize.ts | 2 +- packages/agents-runtime/src/model-runner.ts | 8 +- .../agents-runtime/src/moonshot-models.ts | 2 +- packages/agents-runtime/src/pi-adapter.ts | 26 +- packages/agents-runtime/src/tools/bash.ts | 2 +- .../agents-runtime/src/tools/brave-search.ts | 2 +- packages/agents-runtime/src/tools/edit.ts | 2 +- .../agents-runtime/src/tools/fetch-url.ts | 2 +- .../agents-runtime/src/tools/read-file.ts | 2 +- .../agents-runtime/src/tools/schedules.ts | 2 +- .../src/tools/webhook-sources.ts | 2 +- packages/agents-runtime/src/tools/write.ts | 2 +- packages/agents-runtime/src/types.ts | 10 +- .../test/compaction-trigger.test.ts | 2 +- .../test/context-factory.test.ts | 2 +- .../agents-runtime/test/model-runner.test.ts | 8 +- .../agents-runtime/test/pi-adapter.test.ts | 10 +- .../agents-runtime/test/process-wake.test.ts | 4 +- .../package.json | 2 +- .../src/mock-stream.ts | 2 +- packages/agents-server/package.json | 2 +- .../src/electric-agents/adapter-types.ts | 2 +- packages/agents-server/src/server.ts | 2 +- .../test/horton-pull-wake-e2e.test.ts | 2 +- packages/agents/package.json | 4 +- packages/agents/src/agents/horton.ts | 2 +- packages/agents/src/agents/worker.ts | 2 +- packages/agents/src/bootstrap.ts | 2 +- packages/agents/src/model-catalog.ts | 4 +- packages/agents/src/server.ts | 2 +- packages/agents/src/tools/fork.ts | 2 +- packages/agents/src/tools/observe-pg-sync.ts | 2 +- packages/agents/src/tools/set-title.ts | 2 +- packages/agents/src/tools/spawn-worker.ts | 2 +- .../agents/src/tools/unobserve-pg-sync.ts | 2 +- pnpm-lock.yaml | 1760 +++++------------ 41 files changed, 516 insertions(+), 1384 deletions(-) diff --git a/examples/agents-chat-starter/package.json b/examples/agents-chat-starter/package.json index 3505d09a10..af56f57e4f 100644 --- a/examples/agents-chat-starter/package.json +++ b/examples/agents-chat-starter/package.json @@ -12,7 +12,6 @@ "dependencies": { "@durable-streams/state": "^0.3.1", "@electric-ax/agents-runtime": "workspace:*", - "@mariozechner/pi-agent-core": "^0.57.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/themes": "^3.3.0", "@sinclair/typebox": "^0.34.0", diff --git a/examples/deep-survey/package.json b/examples/deep-survey/package.json index 5bf5e8e240..640c06e398 100644 --- a/examples/deep-survey/package.json +++ b/examples/deep-survey/package.json @@ -13,7 +13,6 @@ "dependencies": { "@durable-streams/state": "^0.3.1", "@electric-ax/agents-runtime": "workspace:*", - "@mariozechner/pi-agent-core": "^0.70.2", "@radix-ui/themes": "^3.3.0", "@sinclair/typebox": "^0.34.0", "@tanstack/db": "^0.6.6", diff --git a/examples/deep-survey/src/server/orchestrator.ts b/examples/deep-survey/src/server/orchestrator.ts index c593f760a9..e6f2f9dbaf 100644 --- a/examples/deep-survey/src/server/orchestrator.ts +++ b/examples/deep-survey/src/server/orchestrator.ts @@ -1,4 +1,5 @@ import type { + AgentTool, EntityRegistry, HandlerContext, SharedStateHandle, @@ -7,7 +8,6 @@ import { db } from '@electric-ax/agents-runtime' import { queryOnce } from '@durable-streams/state/db' import { Type } from '@sinclair/typebox' import { exec, execFile } from 'node:child_process' -import type { AgentTool } from '@mariozechner/pi-agent-core' import { swarmSharedSchema, type WikiEntry, type Xref } from './schema.js' import { explorerSpawnArgs } from './explorer.js' import { SURVEY_WORKER_ENTITY_TYPE } from './survey-worker.js' diff --git a/packages/agents-desktop/src/credentials/model-picker.ts b/packages/agents-desktop/src/credentials/model-picker.ts index 93f33db86a..02808799e5 100644 --- a/packages/agents-desktop/src/credentials/model-picker.ts +++ b/packages/agents-desktop/src/credentials/model-picker.ts @@ -17,8 +17,8 @@ export const DEFAULT_ENABLED_MODEL_VALUES = [ `anthropic:claude-opus-4-7`, `anthropic:claude-sonnet-4-6`, `openai:gpt-5.5`, - `openai-codex:gpt-5.4`, - `openai-codex:gpt-5.5`, + `openai-codex:gpt-5.6-terra`, + `openai-codex:gpt-5.6-sol`, `deepseek:deepseek-v4-flash`, `deepseek:deepseek-v4-pro`, `moonshot:kimi-k2.6`, diff --git a/packages/agents-runtime/package.json b/packages/agents-runtime/package.json index d886735c25..c06c620eb8 100644 --- a/packages/agents-runtime/package.json +++ b/packages/agents-runtime/package.json @@ -111,8 +111,8 @@ "@durable-streams/client": "^0.2.6", "@durable-streams/state": "^0.3.1", "@electric-ax/agents-mcp": "workspace:*", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", + "@earendil-works/pi-agent-core": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.5", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "^0.34.48", "@standard-schema/spec": "^1.1.0", diff --git a/packages/agents-runtime/src/compaction-summarize.ts b/packages/agents-runtime/src/compaction-summarize.ts index cfa3d9bfcc..f1bf492f89 100644 --- a/packages/agents-runtime/src/compaction-summarize.ts +++ b/packages/agents-runtime/src/compaction-summarize.ts @@ -1,4 +1,4 @@ -import { completeSimple } from '@mariozechner/pi-ai' +import { completeSimple } from '@earendil-works/pi-ai/compat' import { resolvePiModel, toAgentHistory } from './pi-adapter' import { COMPACTION_SUMMARIZATION_PROMPT, diff --git a/packages/agents-runtime/src/model-runner.ts b/packages/agents-runtime/src/model-runner.ts index aaacb5366b..9628ce3cd1 100644 --- a/packages/agents-runtime/src/model-runner.ts +++ b/packages/agents-runtime/src/model-runner.ts @@ -1,14 +1,14 @@ import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { completeSimple, getModel } from '@mariozechner/pi-ai' +import { completeSimple, getModel } from '@earendil-works/pi-ai/compat' import { MOONSHOT_PROVIDER, getMoonshotApiKey, getMoonshotModel, } from './moonshot-models' import type { AgentConfig } from './types' -import type { KnownProvider } from '@mariozechner/pi-ai' +import type { KnownProvider } from '@earendil-works/pi-ai/compat' export interface LowCostModelChoice { provider: KnownProvider | string @@ -29,7 +29,7 @@ export type LowCostModelConfig = Pick< const PREFERRED_IDS_BY_PROVIDER: Record> = { anthropic: [`claude-3-5-haiku-latest`, `claude-3-5-haiku-20241022`], openai: [`gpt-4.1-nano`, `gpt-4o-mini`, `gpt-4.1-mini`], - 'openai-codex': [`gpt-5.4-mini`, `gpt-5.1-codex-mini`], + 'openai-codex': [`gpt-5.6-luna`, `gpt-5.4-mini`, `gpt-5.1-codex-mini`], deepseek: [`deepseek-v4-flash`, `deepseek-v4-pro`], moonshot: [`kimi-k2.6`, `kimi-k2.5`, `moonshot-v1-8k`], } @@ -92,7 +92,7 @@ function envCatalog(): LowCostModelCatalog { if (providers.includes(`openai-codex`)) { choices.push({ provider: `openai-codex`, - id: `gpt-5.4-mini`, + id: `gpt-5.6-luna`, reasoning: false, }) } diff --git a/packages/agents-runtime/src/moonshot-models.ts b/packages/agents-runtime/src/moonshot-models.ts index 3e02656c9a..11ec3636d3 100644 --- a/packages/agents-runtime/src/moonshot-models.ts +++ b/packages/agents-runtime/src/moonshot-models.ts @@ -1,4 +1,4 @@ -import type { Model } from '@mariozechner/pi-ai' +import type { Model } from '@earendil-works/pi-ai/compat' export const MOONSHOT_PROVIDER = `moonshot` as const export type MoonshotProvider = typeof MOONSHOT_PROVIDER diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index a45de6d955..f5a9c74f1c 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -7,8 +7,8 @@ * - Delegating ID management and event writing to OutboundBridge */ -import { Agent } from '@mariozechner/pi-agent-core' -import { getModel, streamSimple } from '@mariozechner/pi-ai' +import { Agent } from '@earendil-works/pi-agent-core' +import { getModel, streamSimple } from '@earendil-works/pi-ai/compat' import { createOutboundBridge } from './outbound-bridge' import { MOONSHOT_PROVIDER, getMoonshotModel } from './moonshot-models' import { runtimeLog } from './log' @@ -25,13 +25,13 @@ import type { AgentMessage, AgentTool, StreamFn, -} from '@mariozechner/pi-agent-core' +} from '@earendil-works/pi-agent-core' import type { KnownProvider, Model, - Provider, + ProviderId, SimpleStreamOptions, -} from '@mariozechner/pi-ai' +} from '@earendil-works/pi-ai/compat' import type { LLMContentBlock, LLMMessage, LLMMessageContent } from './types' /** @@ -68,7 +68,7 @@ function parseReasoningSummary(text: string): { export interface PiAdapterOptions { systemPrompt: string model: string | Model - provider?: Provider + provider?: ProviderId tools: Array streamFn?: StreamFn getApiKey?: ( @@ -120,7 +120,7 @@ type PiAgentAdapterFactory = (config: PiAgentAdapterConfig) => PiAgentHandle export function resolvePiModel(opts: { model: string | Model - provider?: Provider + provider?: ProviderId }): Model { if (typeof opts.model !== `string`) { return opts.model @@ -169,15 +169,23 @@ function toAgentContent(content: LLMMessageContent): Array { return content.map(toAgentContentBlock) } +type AssistantAgentMessage = Extract + +function isAssistantAgentMessage( + message: AgentMessage | undefined +): message is AssistantAgentMessage { + return message?.role === `assistant` +} + export function toAgentHistory( messages: Array ): Array { const history: Array = [] const toolNamesById = new Map() - const lastAssistant = (): AgentMessage | undefined => { + const lastAssistant = (): AssistantAgentMessage | undefined => { const last = history[history.length - 1] - return last?.role === `assistant` ? last : undefined + return isAssistantAgentMessage(last) ? last : undefined } for (const message of messages) { diff --git a/packages/agents-runtime/src/tools/bash.ts b/packages/agents-runtime/src/tools/bash.ts index 545d058f84..85a6a83a3b 100644 --- a/packages/agents-runtime/src/tools/bash.ts +++ b/packages/agents-runtime/src/tools/bash.ts @@ -1,6 +1,6 @@ import { Type } from '@sinclair/typebox' import type { Sandbox } from '../sandbox/types' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' const DEFAULT_TIMEOUT_MS = 120_000 const MAX_TIMEOUT_MS = 600_000 diff --git a/packages/agents-runtime/src/tools/brave-search.ts b/packages/agents-runtime/src/tools/brave-search.ts index 46b5c42fa4..4316a4197f 100644 --- a/packages/agents-runtime/src/tools/brave-search.ts +++ b/packages/agents-runtime/src/tools/brave-search.ts @@ -1,6 +1,6 @@ import Anthropic from '@anthropic-ai/sdk' import { Type } from '@sinclair/typebox' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' const BRAVE_API_URL = `https://api.search.brave.com/res/v1/web/search` const SEARCH_FALLBACK_MODEL = `claude-haiku-4-5-20251001` diff --git a/packages/agents-runtime/src/tools/edit.ts b/packages/agents-runtime/src/tools/edit.ts index d21605d6ed..afc37c1e60 100644 --- a/packages/agents-runtime/src/tools/edit.ts +++ b/packages/agents-runtime/src/tools/edit.ts @@ -4,7 +4,7 @@ import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' import { SandboxError } from '../sandbox/types' import type { Sandbox } from '../sandbox/types' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' export function createEditTool(sandbox: Sandbox): AgentTool { return { diff --git a/packages/agents-runtime/src/tools/fetch-url.ts b/packages/agents-runtime/src/tools/fetch-url.ts index 63c6d60cff..7b08725eac 100644 --- a/packages/agents-runtime/src/tools/fetch-url.ts +++ b/packages/agents-runtime/src/tools/fetch-url.ts @@ -6,7 +6,7 @@ import TurndownService from 'turndown' import { completeWithLowCostModel } from '../model-runner' import { SandboxError } from '../sandbox/types' import type { Sandbox } from '../sandbox/types' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { LowCostModelCatalog, LowCostModelConfig } from '../model-runner' const MAX_RAW_CHARS = 100_000 diff --git a/packages/agents-runtime/src/tools/read-file.ts b/packages/agents-runtime/src/tools/read-file.ts index bfae4bb147..c1d23f2d59 100644 --- a/packages/agents-runtime/src/tools/read-file.ts +++ b/packages/agents-runtime/src/tools/read-file.ts @@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' import { SandboxError } from '../sandbox/types' import type { Sandbox } from '../sandbox/types' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' const MAX_FILE_SIZE = 512 * 1024 // 512 KB const DEFAULT_READ_LIMIT_LINES = 2_000 diff --git a/packages/agents-runtime/src/tools/schedules.ts b/packages/agents-runtime/src/tools/schedules.ts index b529ba80ad..2617008217 100644 --- a/packages/agents-runtime/src/tools/schedules.ts +++ b/packages/agents-runtime/src/tools/schedules.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { EntityStreamDBWithActions } from '../entity-stream-db' type ScheduleManifest = { diff --git a/packages/agents-runtime/src/tools/webhook-sources.ts b/packages/agents-runtime/src/tools/webhook-sources.ts index bcfbf98c6c..365849e34b 100644 --- a/packages/agents-runtime/src/tools/webhook-sources.ts +++ b/packages/agents-runtime/src/tools/webhook-sources.ts @@ -5,7 +5,7 @@ import { defaultWebhookSourceSubscriptionLifetime, webhookSourceSubscriptionManifestKey, } from '../webhook-sources' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { EntityStreamDBWithActions } from '../types' import type { WebhookSourceContract, diff --git a/packages/agents-runtime/src/tools/write.ts b/packages/agents-runtime/src/tools/write.ts index 838a118712..11665ec4a7 100644 --- a/packages/agents-runtime/src/tools/write.ts +++ b/packages/agents-runtime/src/tools/write.ts @@ -4,7 +4,7 @@ import { Type } from '@sinclair/typebox' import { runtimeLog } from '../log' import { SandboxError } from '../sandbox/types' import type { Sandbox } from '../sandbox/types' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' export function createWriteTool(sandbox: Sandbox): AgentTool { return { diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 8cee5d4722..1f608a1912 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -22,8 +22,12 @@ import type { import type { AgentTool as PiAgentTool, StreamFn, -} from '@mariozechner/pi-agent-core' -import type { Model, Provider, SimpleStreamOptions } from '@mariozechner/pi-ai' +} from '@earendil-works/pi-agent-core' +import type { + Model, + ProviderId, + SimpleStreamOptions, +} from '@earendil-works/pi-ai/compat' import type { EntityStreamDB as RuntimeEntityStreamDB, EntityStreamDBWithActions as RuntimeEntityStreamDBWithActions, @@ -953,7 +957,7 @@ export type AgentModel = string | Model export interface AgentConfig { systemPrompt: string model: AgentModel - provider?: Provider + provider?: ProviderId tools: Array streamFn?: StreamFn getApiKey?: ( diff --git a/packages/agents-runtime/test/compaction-trigger.test.ts b/packages/agents-runtime/test/compaction-trigger.test.ts index db0127d992..9f5dd860bd 100644 --- a/packages/agents-runtime/test/compaction-trigger.test.ts +++ b/packages/agents-runtime/test/compaction-trigger.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { createAssistantMessageEventStream } from '@earendil-works/pi-ai/compat' import { buildStreamFixture, createTestHandlerContext, diff --git a/packages/agents-runtime/test/context-factory.test.ts b/packages/agents-runtime/test/context-factory.test.ts index e8e4f7a4b8..881489585f 100644 --- a/packages/agents-runtime/test/context-factory.test.ts +++ b/packages/agents-runtime/test/context-factory.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { createAssistantMessageEventStream } from '@earendil-works/pi-ai/compat' import { getCronStreamPath } from '../src/cron-utils' import { createHandlerContext } from '../src/context-factory' import { ENTITY_COLLECTIONS } from '../src/entity-schema' diff --git a/packages/agents-runtime/test/model-runner.test.ts b/packages/agents-runtime/test/model-runner.test.ts index 2f082e1d17..1b3db40c7d 100644 --- a/packages/agents-runtime/test/model-runner.test.ts +++ b/packages/agents-runtime/test/model-runner.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' const completeSimple = vi.fn() -const getModel = vi.fn(() => ({ provider: `openai-codex`, id: `gpt-5.4-mini` })) +const getModel = vi.fn(() => ({ provider: `openai-codex`, id: `gpt-5.6-luna` })) -vi.mock(`@mariozechner/pi-ai`, () => ({ completeSimple, getModel })) +vi.mock(`@earendil-works/pi-ai/compat`, () => ({ completeSimple, getModel })) const { completeWithLowCostModel, @@ -33,7 +33,7 @@ describe(`completeWithLowCostModel`, () => { await completeWithLowCostModel({ catalog: { choices: [ - { provider: `openai-codex`, id: `gpt-5.4-mini`, reasoning: false }, + { provider: `openai-codex`, id: `gpt-5.6-luna`, reasoning: false }, ], }, purpose: `URL extraction`, @@ -54,7 +54,7 @@ describe(`completeWithLowCostModel`, () => { await completeWithLowCostModel({ catalog: { choices: [ - { provider: `openai-codex`, id: `gpt-5.4-mini`, reasoning: false }, + { provider: `openai-codex`, id: `gpt-5.6-luna`, reasoning: false }, ], }, purpose: `URL extraction`, diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 45a3bb8128..20994b67d5 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -4,18 +4,18 @@ import { resolvePiModel, toAgentHistory, } from '../src/pi-adapter' -import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { createAssistantMessageEventStream } from '@earendil-works/pi-ai/compat' import { Type } from '@sinclair/typebox' import type { OutboundIdSeed } from '../src/outbound-bridge' import type { LLMMessage } from '../src/types' import type { ChangeEvent } from '@durable-streams/state' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { AssistantMessage, Model, ToolResultMessage, UserMessage, -} from '@mariozechner/pi-ai' +} from '@earendil-works/pi-ai/compat' interface PiAgentAdapterConfig { entityUrl: string @@ -710,7 +710,7 @@ describe(`createPiAgentAdapter`, () => { content: [], api: `openai-codex-responses`, provider: `openai-codex`, - model: `gpt-5.4`, + model: `gpt-5.6-terra`, usage, stopReason: `stop`, timestamp: Date.now(), @@ -732,7 +732,7 @@ describe(`createPiAgentAdapter`, () => { const factory = createPiAgentAdapter({ systemPrompt: `Test system prompt`, provider: `openai-codex`, - model: `gpt-5.4`, + model: `gpt-5.6-terra`, tools: [], streamFn: () => { const stream = createAssistantMessageEventStream() diff --git a/packages/agents-runtime/test/process-wake.test.ts b/packages/agents-runtime/test/process-wake.test.ts index 49c3a5460f..b839cad063 100644 --- a/packages/agents-runtime/test/process-wake.test.ts +++ b/packages/agents-runtime/test/process-wake.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTransaction } from '@durable-streams/state/db' -import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { createAssistantMessageEventStream } from '@earendil-works/pi-ai/compat' import { getCronSourceRef } from '../src/cron-utils' import { buildWebhookSourceManifestEntry, @@ -19,7 +19,7 @@ import type { MockInstance } from 'vitest' import type { ProcessWakeConfig, WebhookNotification } from '../src/types' import type { ChangeEvent } from '@durable-streams/state' import type { ErrorEvent, Manifest } from '../src/entity-schema' -import type { AssistantMessage } from '@mariozechner/pi-ai' +import type { AssistantMessage } from '@earendil-works/pi-ai/compat' // --------------------------------------------------------------------------- // Mock @durable-streams/client diff --git a/packages/agents-server-conformance-tests/package.json b/packages/agents-server-conformance-tests/package.json index 4e53be6917..cc5308ebc5 100644 --- a/packages/agents-server-conformance-tests/package.json +++ b/packages/agents-server-conformance-tests/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@electric-ax/agents-server": "workspace:*", - "@mariozechner/pi-agent-core": "^0.70.2", + "@earendil-works/pi-agent-core": "^0.80.5", "tsdown": "^0.9.0", "typescript": "^5.0.0" }, diff --git a/packages/agents-server-conformance-tests/src/mock-stream.ts b/packages/agents-server-conformance-tests/src/mock-stream.ts index 97365a5338..1756117b32 100644 --- a/packages/agents-server-conformance-tests/src/mock-stream.ts +++ b/packages/agents-server-conformance-tests/src/mock-stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from '@mariozechner/pi-agent-core' +import type { StreamFn } from '@earendil-works/pi-agent-core' export function createMockStreamFn(text: string): StreamFn { return () => { diff --git a/packages/agents-server/package.json b/packages/agents-server/package.json index 7ddb70eb19..8dac86e37c 100644 --- a/packages/agents-server/package.json +++ b/packages/agents-server/package.json @@ -50,7 +50,7 @@ "@durable-streams/state": "^0.3.1", "@electric-ax/agents-runtime": "workspace:*", "@electric-sql/client": "^1.5.22", - "@mariozechner/pi-agent-core": "^0.70.2", + "@earendil-works/pi-agent-core": "^0.80.5", "@opentelemetry/api": "^1.9.1", "@sinclair/typebox": "^0.34.48", "@whatwg-node/server": "^0.10.18", diff --git a/packages/agents-server/src/electric-agents/adapter-types.ts b/packages/agents-server/src/electric-agents/adapter-types.ts index 36120fbc8e..f97f5161ae 100644 --- a/packages/agents-server/src/electric-agents/adapter-types.ts +++ b/packages/agents-server/src/electric-agents/adapter-types.ts @@ -5,7 +5,7 @@ * SDK events into State Protocol writes via the provided writeEvent callback. */ -import type { AgentTool, StreamFn } from '@mariozechner/pi-agent-core' +import type { AgentTool, StreamFn } from '@earendil-works/pi-agent-core' /** A State Protocol event to be written via the adapter's writeEvent callback. */ export interface WriteEvent { diff --git a/packages/agents-server/src/server.ts b/packages/agents-server/src/server.ts index 8e265dca4d..74892d34f1 100644 --- a/packages/agents-server/src/server.ts +++ b/packages/agents-server/src/server.ts @@ -24,7 +24,7 @@ import { createEd25519WebhookSigner } from './webhook-signing.js' import type { DrizzleDB, PgClient } from './db/index.js' import type { Server } from 'node:http' import type { DurableStreamTestServer } from '@durable-streams/server' -import type { StreamFn } from '@mariozechner/pi-agent-core' +import type { StreamFn } from '@earendil-works/pi-agent-core' import type { AgentModel, EntityRegistry, diff --git a/packages/agents-server/test/horton-pull-wake-e2e.test.ts b/packages/agents-server/test/horton-pull-wake-e2e.test.ts index 17bfd5ea79..da578d7cec 100644 --- a/packages/agents-server/test/horton-pull-wake-e2e.test.ts +++ b/packages/agents-server/test/horton-pull-wake-e2e.test.ts @@ -13,7 +13,7 @@ import { TEST_POSTGRES_URL, resetElectricAgentsTestBackend, } from './test-backend' -import type { StreamFn } from '@mariozechner/pi-agent-core' +import type { StreamFn } from '@earendil-works/pi-agent-core' function createMockStreamFn(responseText: string): StreamFn { return vi.fn(((model) => { diff --git a/packages/agents/package.json b/packages/agents/package.json index 0755d890c9..b39631673d 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -54,8 +54,8 @@ "@durable-streams/state": "^0.3.1", "@electric-ax/agents-mcp": "workspace:*", "@electric-ax/agents-runtime": "workspace:*", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", + "@earendil-works/pi-agent-core": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.5", "@sinclair/typebox": "^0.34.48", "better-sqlite3": "^12.9.0", "nanoid": "^3.3.11", diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 502dc5716e..d0d1928f02 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -16,7 +16,7 @@ import { type BuiltinAgentModelConfig, type BuiltinModelCatalog, } from '../model-catalog' -import type { AgentTool, StreamFn } from '@mariozechner/pi-agent-core' +import type { AgentTool, StreamFn } from '@earendil-works/pi-agent-core' import { GOAL_SLASH_COMMAND, buildSkillSlashCommands, diff --git a/packages/agents/src/agents/worker.ts b/packages/agents/src/agents/worker.ts index 1b25201d66..c45db121a9 100644 --- a/packages/agents/src/agents/worker.ts +++ b/packages/agents/src/agents/worker.ts @@ -18,7 +18,7 @@ import { type BuiltinModelCatalog, } from '../model-catalog' import type { WorkerToolName } from '../tools/spawn-worker' -import type { AgentTool, StreamFn } from '@mariozechner/pi-agent-core' +import type { AgentTool, StreamFn } from '@earendil-works/pi-agent-core' import type { EntityRegistry, HandlerContext, diff --git a/packages/agents/src/bootstrap.ts b/packages/agents/src/bootstrap.ts index fe88943c51..0b53b3dfc6 100644 --- a/packages/agents/src/bootstrap.ts +++ b/packages/agents/src/bootstrap.ts @@ -33,7 +33,7 @@ import type { ProcessWakeConfig, RuntimeHandler, } from '@electric-ax/agents-runtime' -import type { StreamFn } from '@mariozechner/pi-agent-core' +import type { StreamFn } from '@earendil-works/pi-agent-core' import type { IncomingMessage, ServerResponse } from 'node:http' import type { SkillsRegistry } from '@electric-ax/agents-runtime' diff --git a/packages/agents/src/model-catalog.ts b/packages/agents/src/model-catalog.ts index af901c79cf..cc19e3de60 100644 --- a/packages/agents/src/model-catalog.ts +++ b/packages/agents/src/model-catalog.ts @@ -1,4 +1,4 @@ -import { getModels } from '@mariozechner/pi-ai' +import { getModels } from '@earendil-works/pi-ai/compat' import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, @@ -62,7 +62,7 @@ export type BuiltinAgentModelConfig = Pick< const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6` const DEFAULT_OPENAI_MODEL = `gpt-4.1` -const DEFAULT_CODEX_MODEL = `gpt-5.4` +const DEFAULT_CODEX_MODEL = `gpt-5.6-terra` const DEFAULT_DEEPSEEK_MODEL = `deepseek-v4-flash` const DEFAULT_MOONSHOT_MODEL = `kimi-k2.6` diff --git a/packages/agents/src/server.ts b/packages/agents/src/server.ts index 8f4f772346..9a2af52e8c 100644 --- a/packages/agents/src/server.ts +++ b/packages/agents/src/server.ts @@ -31,7 +31,7 @@ import type { PullWakeRunnerConfig, } from '@electric-ax/agents-runtime' import type { DurableStreamsFetchCacheOptions } from './durable-streams-cache.js' -import type { StreamFn } from '@mariozechner/pi-agent-core' +import type { StreamFn } from '@earendil-works/pi-agent-core' export interface BuiltinAgentsServerOptions { agentServerUrl: string diff --git a/packages/agents/src/tools/fork.ts b/packages/agents/src/tools/fork.ts index 79779ca345..a30cc35f66 100644 --- a/packages/agents/src/tools/fork.ts +++ b/packages/agents/src/tools/fork.ts @@ -1,7 +1,7 @@ import { Type } from '@sinclair/typebox' import { nanoid } from 'nanoid' import { serverLog } from '../log' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { HandlerContext } from '@electric-ax/agents-runtime' export function createForkTool(ctx: HandlerContext): AgentTool { diff --git a/packages/agents/src/tools/observe-pg-sync.ts b/packages/agents/src/tools/observe-pg-sync.ts index c6c67f50a7..c581dfe6b3 100644 --- a/packages/agents/src/tools/observe-pg-sync.ts +++ b/packages/agents/src/tools/observe-pg-sync.ts @@ -1,6 +1,6 @@ import { Type } from '@sinclair/typebox' import { pgSync, type HandlerContext } from '@electric-ax/agents-runtime' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' function asToolResult(value: unknown) { return { diff --git a/packages/agents/src/tools/set-title.ts b/packages/agents/src/tools/set-title.ts index c0c40efc43..5e705e4a16 100644 --- a/packages/agents/src/tools/set-title.ts +++ b/packages/agents/src/tools/set-title.ts @@ -1,5 +1,5 @@ import { Type } from '@sinclair/typebox' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { HandlerContext } from '@electric-ax/agents-runtime' export function createSetTitleTool(ctx: HandlerContext): AgentTool { diff --git a/packages/agents/src/tools/spawn-worker.ts b/packages/agents/src/tools/spawn-worker.ts index e0a6a5f57d..06d070589d 100644 --- a/packages/agents/src/tools/spawn-worker.ts +++ b/packages/agents/src/tools/spawn-worker.ts @@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox' import { randomBytes } from 'node:crypto' import { serverLog } from '../log' import type { BuiltinAgentModelConfig } from '../model-catalog' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' import type { HandlerContext } from '@electric-ax/agents-runtime' export const WORKER_TOOL_NAMES = [ diff --git a/packages/agents/src/tools/unobserve-pg-sync.ts b/packages/agents/src/tools/unobserve-pg-sync.ts index bc83490451..86fce7bcc9 100644 --- a/packages/agents/src/tools/unobserve-pg-sync.ts +++ b/packages/agents/src/tools/unobserve-pg-sync.ts @@ -1,6 +1,6 @@ import { Type } from '@sinclair/typebox' import type { HandlerContext } from '@electric-ax/agents-runtime' -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool } from '@earendil-works/pi-agent-core' function asToolResult(value: unknown) { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 122fcc8ca3..fdd0285340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,6 @@ importers: '@electric-ax/agents-runtime': specifier: workspace:* version: link:../../packages/agents-runtime - '@mariozechner/pi-agent-core': - specifier: ^0.57.1 - version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.2(react@19.2.5) @@ -200,9 +197,6 @@ importers: '@electric-ax/agents-runtime': specifier: workspace:* version: link:../../packages/agents-runtime - '@mariozechner/pi-agent-core': - specifier: ^0.70.2 - version: 0.70.2(zod@4.3.6) '@radix-ui/themes': specifier: ^3.3.0 version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1536,18 +1530,18 @@ importers: '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) + '@earendil-works/pi-agent-core': + specifier: ^0.80.5 + version: 0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: ^0.80.5 + version: 0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@electric-ax/agents-mcp': specifier: workspace:* version: link:../agents-mcp '@electric-ax/agents-runtime': specifier: workspace:* version: link:../agents-runtime - '@mariozechner/pi-agent-core': - specifier: ^0.70.2 - version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@mariozechner/pi-ai': - specifier: ^0.70.2 - version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.49 @@ -1596,7 +1590,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) packages/agents-desktop: dependencies: @@ -1828,15 +1822,15 @@ importers: '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) + '@earendil-works/pi-agent-core': + specifier: ^0.80.5 + version: 0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: ^0.80.5 + version: 0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@electric-ax/agents-mcp': specifier: workspace:* version: link:../agents-mcp - '@mariozechner/pi-agent-core': - specifier: ^0.70.2 - version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@mariozechner/pi-ai': - specifier: ^0.70.2 - version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -1937,15 +1931,15 @@ importers: '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.8.3)) + '@earendil-works/pi-agent-core': + specifier: ^0.80.5 + version: 0.80.5(zod@4.4.3) '@electric-ax/agents-runtime': specifier: workspace:* version: link:../agents-runtime '@electric-sql/client': specifier: ^1.5.22 version: link:../typescript-client - '@mariozechner/pi-agent-core': - specifier: ^0.70.2 - version: 0.70.2(zod@4.4.3) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -2035,12 +2029,12 @@ importers: specifier: ^4.1.0 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) devDependencies: + '@earendil-works/pi-agent-core': + specifier: ^0.80.5 + version: 0.80.5 '@electric-ax/agents-server': specifier: workspace:* version: link:../agents-server - '@mariozechner/pi-agent-core': - specifier: ^0.70.2 - version: 0.70.2 tsdown: specifier: ^0.9.0 version: 0.9.9(typescript@5.8.3) @@ -2735,15 +2729,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/sdk@0.73.0': - resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@anthropic-ai/sdk@0.78.0': resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} hasBin: true @@ -2753,8 +2738,8 @@ packages: zod: optional: true - '@anthropic-ai/sdk@0.90.0': - resolution: {integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -2795,10 +2780,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -2812,136 +2793,92 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1037.0': - resolution: {integrity: sha512-Evla4DUdBf1pQpQa7pbfquj7jRaRktkI0qGoWBJBXWB9wQISzJ8OEI4sHugk/W6SF47C7hMP/o3Z/XBrfnejCw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.974.5': - resolution: {integrity: sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-env@3.972.31': - resolution: {integrity: sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.972.33': - resolution: {integrity: sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.972.35': - resolution: {integrity: sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.35': - resolution: {integrity: sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==} + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.36': - resolution: {integrity: sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==} + '@aws-sdk/core@3.975.1': + resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.31': - resolution: {integrity: sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==} + '@aws-sdk/credential-provider-env@3.972.57': + resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.35': - resolution: {integrity: sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==} + '@aws-sdk/credential-provider-http@3.972.59': + resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.35': - resolution: {integrity: sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==} + '@aws-sdk/credential-provider-ini@3.973.1': + resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.14': - resolution: {integrity: sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==} + '@aws-sdk/credential-provider-login@3.972.63': + resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.10': - resolution: {integrity: sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==} + '@aws-sdk/credential-provider-node@3.972.66': + resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.10': - resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + '@aws-sdk/credential-provider-process@3.972.57': + resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.10': - resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + '@aws-sdk/credential-provider-sso@3.973.1': + resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.11': - resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + '@aws-sdk/credential-provider-web-identity@3.972.63': + resolution: {integrity: sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.34': - resolution: {integrity: sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==} + '@aws-sdk/eventstream-handler-node@3.972.26': + resolution: {integrity: sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.35': - resolution: {integrity: sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==} + '@aws-sdk/middleware-eventstream@3.972.22': + resolution: {integrity: sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.16': - resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} + '@aws-sdk/middleware-websocket@3.972.39': + resolution: {integrity: sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.3': - resolution: {integrity: sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.13': - resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + '@aws-sdk/nested-clients@3.997.31': + resolution: {integrity: sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.22': - resolution: {integrity: sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==} + '@aws-sdk/signature-v4-multi-region@3.996.39': + resolution: {integrity: sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1036.0': - resolution: {integrity: sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==} + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1037.0': - resolution: {integrity: sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==} + '@aws-sdk/token-providers@3.1083.0': + resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.3': - resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.996.8': - resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.10': - resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} + '@aws-sdk/types@3.974.0': + resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.10': - resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} - - '@aws-sdk/util-user-agent-node@3.973.21': - resolution: {integrity: sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.972.19': - resolution: {integrity: sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==} + '@aws-sdk/xml-builder@3.972.34': + resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.10.4': @@ -4791,6 +4728,15 @@ packages: '@tanstack/db': optional: true + '@earendil-works/pi-agent-core@0.80.5': + resolution: {integrity: sha512-413NZ5BEwSdL+BAtoBlk7hcA3UGvltcdAPtFoMZ1uqLZRNIoFU0WhNXGwv5Sp1SPBmMR6MAc3neoA+MdOlWJ2w==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.80.5': + resolution: {integrity: sha512-+7UODYKu4o/X8sxTHG8HEV3+O+dOYKg5MeO3MJIFQ+wu65pIWDhjZGc759uWsRr79AhV7MSJEe4GWnFn4l68gw==} + engines: {node: '>=22.19.0'} + hasBin: true + '@ecies/ciphers@0.2.5': resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -6319,8 +6265,8 @@ packages: '@fontsource/alegreya-sans@5.1.1': resolution: {integrity: sha512-vQAwr25Pk5N5Y924AxaGpipZQY9IdulIRS4+WXbNiHCVwDS/i6k7c46UdyBlhvnPM33JII7ndv/gJ9BP8i11bA==} - '@google/genai@1.50.1': - resolution: {integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -6917,39 +6863,19 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} - '@mariozechner/pi-agent-core@0.57.1': - resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-agent-core instead going forward - - '@mariozechner/pi-agent-core@0.70.2': - resolution: {integrity: sha512-g1hIdKyDwmQOoBGO0R4OhpemKeMENeK0vE5FJtuQKqEcsdCAkVBgZAK6aZUARYZVxMA718JS6WPLFWoddzjD7g==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-agent-core instead going forward - - '@mariozechner/pi-ai@0.57.1': - resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-ai instead going forward - hasBin: true - - '@mariozechner/pi-ai@0.70.2': - resolution: {integrity: sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-ai instead going forward - hasBin: true - '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} - '@mistralai/mistralai@1.14.1': - resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} - - '@mistralai/mistralai@2.2.1': - resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -7097,9 +7023,6 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7128,6 +7051,10 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -9564,194 +9491,50 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@smithy/config-resolver@4.4.17': - resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.23.17': - resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.2.14': - resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-codec@4.2.14': - resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-browser@4.2.14': - resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-config-resolver@4.3.14': - resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-node@4.2.14': - resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + '@smithy/core@3.29.2': + resolution: {integrity: sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.14': - resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + '@smithy/credential-provider-imds@4.4.7': + resolution: {integrity: sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.17': - resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.2.14': - resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.14': - resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + '@smithy/fetch-http-handler@5.6.4': + resolution: {integrity: sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.2': - resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-content-length@4.2.14': - resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.32': - resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-retry@4.5.5': - resolution: {integrity: sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-serde@4.2.20': - resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-stack@4.2.14': - resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} - engines: {node: '>=18.0.0'} - - '@smithy/node-config-provider@4.3.14': - resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.6.1': - resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.14': - resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + '@smithy/node-http-handler@4.9.4': + resolution: {integrity: sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.14': - resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-builder@4.2.14': - resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-parser@4.2.14': - resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} - engines: {node: '>=18.0.0'} - - '@smithy/service-error-classification@4.3.0': - resolution: {integrity: sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.9': - resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.3.14': - resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.12.13': - resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + '@smithy/signature-v4@5.6.3': + resolution: {integrity: sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==} engines: {node: '>=18.0.0'} '@smithy/types@4.14.1': resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.14': - resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.2': - resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.2.2': - resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.3': - resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + '@smithy/types@4.16.0': + resolution: {integrity: sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.2': - resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.2.2': - resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.3.49': - resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.2.54': - resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.4.2': - resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.2': - resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.14': - resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.3.4': - resolution: {integrity: sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.25': - resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.2': - resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.2': - resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.2': - resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} - engines: {node: '>=18.0.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -10506,9 +10289,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@trpc/client@11.7.2': resolution: {integrity: sha512-OQxqUMfpDvjcszo9dbnqWQXnW2L5IbrKSz2H7l8s+mVM3EvYw7ztQ/gjFIN3iy0NcamiQfd4eE6qjcb9Lm+63A==} peerDependencies: @@ -11830,10 +11610,6 @@ packages: resolution: {integrity: sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg==} engines: {node: '>=16.14.0'} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -12032,10 +11808,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.0: - resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} - engines: {node: '>=10.0.0'} - bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -13078,10 +12850,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -13272,10 +13040,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - del@6.1.1: resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} engines: {node: '>=10'} @@ -14047,11 +13811,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -14655,13 +14414,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} - - fast-xml-parser@5.7.1: - resolution: {integrity: sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==} - hasBin: true - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -14990,10 +14742,6 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - getenv@2.0.0: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} @@ -16540,10 +16288,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@0.544.0: resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} peerDependencies: @@ -17250,10 +16994,6 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - next@14.2.17: resolution: {integrity: sha512-hNo/Zy701DDO3nzKkPmsLRlDfNCtb1OJxFUvjGEl04u7SFa3zwC6hqsOUzMajcaEOEV8ey1GjvByvrg0Qr5AiQ==} engines: {node: '>=18.17.0'} @@ -17707,14 +17447,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -17808,10 +17540,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -18424,10 +18152,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - proxy-compare@3.0.1: resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} @@ -19625,14 +19349,6 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} @@ -20042,9 +19758,6 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - strnum@2.2.3: - resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} - strtok3@6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} @@ -20607,8 +20320,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typebox@1.1.34: - resolution: {integrity: sha512-V0fM5W5DTXlEMDxqtX1dQ25HR1RQ11DPUVrIup4sJi1yQtIyI30SHfxBy/HjXKL1CtUqc5or2igA/wa/v4hMKQ==} + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -22101,12 +21814,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 - '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@anthropic-ai/sdk@0.78.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -22119,13 +21826,13 @@ snapshots: optionalDependencies: zod: 4.4.3 - '@anthropic-ai/sdk@0.90.0(zod@4.3.6)': + '@anthropic-ai/sdk@0.91.1(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 4.3.6 - '@anthropic-ai/sdk@0.90.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: @@ -22190,12 +21897,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 - tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -22222,393 +21923,198 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1037.0': + '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.5 - '@aws-sdk/credential-provider-node': 3.972.36 - '@aws-sdk/eventstream-handler-node': 3.972.14 - '@aws-sdk/middleware-eventstream': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.35 - '@aws-sdk/middleware-websocket': 3.972.16 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/token-providers': 3.1037.0 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.21 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 - '@smithy/eventstream-serde-browser': 4.2.14 - '@smithy/eventstream-serde-config-resolver': 4.3.14 - '@smithy/eventstream-serde-node': 4.2.14 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.5 - '@smithy/middleware-serde': 4.2.20 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.4 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.974.5': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.19 - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.4 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.972.31': - dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.33': - dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.972.35': - dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/credential-provider-env': 3.972.31 - '@aws-sdk/credential-provider-http': 3.972.33 - '@aws-sdk/credential-provider-login': 3.972.35 - '@aws-sdk/credential-provider-process': 3.972.31 - '@aws-sdk/credential-provider-sso': 3.972.35 - '@aws-sdk/credential-provider-web-identity': 3.972.35 - '@aws-sdk/nested-clients': 3.997.3 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.35': - dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/nested-clients': 3.997.3 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.36': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.31 - '@aws-sdk/credential-provider-http': 3.972.33 - '@aws-sdk/credential-provider-ini': 3.972.35 - '@aws-sdk/credential-provider-process': 3.972.31 - '@aws-sdk/credential-provider-sso': 3.972.35 - '@aws-sdk/credential-provider-web-identity': 3.972.35 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-node': 3.972.66 + '@aws-sdk/eventstream-handler-node': 3.972.26 + '@aws-sdk/middleware-eventstream': 3.972.22 + '@aws-sdk/middleware-websocket': 3.972.39 + '@aws-sdk/token-providers': 3.1048.0 '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-process@3.972.31': + '@aws-sdk/core@3.975.1': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.0 + '@aws-sdk/xml-builder': 3.972.34 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.35': + '@aws-sdk/credential-provider-env@3.972.57': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/nested-clients': 3.997.3 - '@aws-sdk/token-providers': 3.1036.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.35': + '@aws-sdk/credential-provider-http@3.972.59': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/nested-clients': 3.997.3 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/eventstream-handler-node@3.972.14': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 + '@aws-sdk/credential-provider-ini@3.973.1': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-login': 3.972.63 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.10': + '@aws-sdk/credential-provider-login@3.972.63': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/credential-provider-node@3.972.66': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-ini': 3.973.1 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.10': + '@aws-sdk/credential-provider-process@3.972.57': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.11': + '@aws-sdk/credential-provider-sso@3.973.1': dependencies: - '@aws-sdk/types': 3.973.8 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/token-providers': 3.1083.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.34': + '@aws-sdk/credential-provider-web-identity@3.972.63': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.35': + '@aws-sdk/eventstream-handler-node@3.972.26': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.23.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.4 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.16': + '@aws-sdk/middleware-eventstream@3.972.22': dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-format-url': 3.972.10 - '@smithy/eventstream-codec': 4.2.14 - '@smithy/eventstream-serde-browser': 4.2.14 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.3': + '@aws-sdk/middleware-websocket@3.972.39': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.5 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.35 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.22 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.21 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.5 - '@smithy/middleware-serde': 4.2.20 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.4 - '@smithy/util-utf8': 4.2.2 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/region-config-resolver@3.972.13': + '@aws-sdk/nested-clients@3.997.31': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/config-resolver': 4.4.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.22': + '@aws-sdk/signature-v4-multi-region@3.996.39': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.34 - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.974.0 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1036.0': + '@aws-sdk/token-providers@3.1048.0': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/nested-clients': 3.997.3 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/core': 3.29.2 '@smithy/types': 4.14.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.1037.0': + '@aws-sdk/token-providers@3.1083.0': dependencies: - '@aws-sdk/core': 3.974.5 - '@aws-sdk/nested-clients': 3.997.3 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.3': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.996.8': + '@aws-sdk/types@3.974.0': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-endpoints': 3.4.2 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.973.21': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.35 - '@aws-sdk/types': 3.973.8 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.19': + '@aws-sdk/xml-builder@3.972.34': dependencies: - '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.10.4': dependencies: @@ -22820,7 +22326,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -24340,7 +23846,7 @@ snapshots: '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -24351,7 +23857,7 @@ snapshots: '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -24815,7 +24321,7 @@ snapshots: '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.11 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -25500,6 +25006,111 @@ snapshots: optionalDependencies: '@tanstack/db': 0.6.7(typescript@5.9.3) + '@earendil-works/pi-agent-core@0.80.5': + dependencies: + '@earendil-works/pi-ai': 0.80.5 + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-agent-core@0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@earendil-works/pi-ai': 0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-agent-core@0.80.5(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.80.5(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.5': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@4.3.6) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@4.3.6) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.5(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 @@ -26475,7 +26086,7 @@ snapshots: '@expo/cli@0.24.20': dependencies: '@0no-co/graphql.web': 1.1.2 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@expo/code-signing-certificates': 0.0.5 '@expo/config': 11.0.13 '@expo/config-plugins': 10.1.2 @@ -26534,7 +26145,7 @@ snapshots: terminal-link: 2.1.1 undici: 6.25.0 wrap-ansi: 7.0.0 - ws: 8.18.0 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - graphql @@ -27096,7 +26707,7 @@ snapshots: '@fontsource/alegreya-sans@5.1.1': {} - '@google/genai@1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 @@ -27398,14 +27009,14 @@ snapshots: '@jimp/bmp@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 bmp-js: 0.1.0 '@jimp/core@0.16.13': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/utils': 0.16.13 any-base: 1.1.0 buffer: 5.7.1 @@ -27421,14 +27032,14 @@ snapshots: '@jimp/custom@0.16.13': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/core': 0.16.13 transitivePeerDependencies: - debug '@jimp/gif@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 gifwrap: 0.9.4 @@ -27436,39 +27047,39 @@ snapshots: '@jimp/jpeg@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 jpeg-js: 0.4.4 '@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-blur@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-circle@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-color@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 tinycolor2: 1.6.0 '@jimp/plugin-contain@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-scale@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13)))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-blit': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-resize': 0.16.13(@jimp/custom@0.16.13) @@ -27477,7 +27088,7 @@ snapshots: '@jimp/plugin-cover@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-crop@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-scale@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13)))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-crop': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-resize': 0.16.13(@jimp/custom@0.16.13) @@ -27486,62 +27097,62 @@ snapshots: '@jimp/plugin-crop@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-displace@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-dither@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-fisheye@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-flip@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-rotate@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-crop@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13)))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-rotate': 0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-crop@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13)) '@jimp/utils': 0.16.13 '@jimp/plugin-gaussian@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-invert@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-mask@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-normalize@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-print@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-blit': 0.16.13(@jimp/custom@0.16.13) '@jimp/utils': 0.16.13 @@ -27551,13 +27162,13 @@ snapshots: '@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 '@jimp/plugin-rotate@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blit@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-crop@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-blit': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-crop': 0.16.13(@jimp/custom@0.16.13) @@ -27566,14 +27177,14 @@ snapshots: '@jimp/plugin-scale@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-resize': 0.16.13(@jimp/custom@0.16.13) '@jimp/utils': 0.16.13 '@jimp/plugin-shadow@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-blur@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-blur': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-resize': 0.16.13(@jimp/custom@0.16.13) @@ -27581,7 +27192,7 @@ snapshots: '@jimp/plugin-threshold@0.16.13(@jimp/custom@0.16.13)(@jimp/plugin-color@0.16.13(@jimp/custom@0.16.13))(@jimp/plugin-resize@0.16.13(@jimp/custom@0.16.13))': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-color': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-resize': 0.16.13(@jimp/custom@0.16.13) @@ -27589,7 +27200,7 @@ snapshots: '@jimp/plugins@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugin-blit': 0.16.13(@jimp/custom@0.16.13) '@jimp/plugin-blur': 0.16.13(@jimp/custom@0.16.13) @@ -27618,20 +27229,20 @@ snapshots: '@jimp/png@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/utils': 0.16.13 pngjs: 3.4.0 '@jimp/tiff@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 utif: 2.0.1 '@jimp/types@0.16.13(@jimp/custom@0.16.13)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/bmp': 0.16.13(@jimp/custom@0.16.13) '@jimp/custom': 0.16.13 '@jimp/gif': 0.16.13(@jimp/custom@0.16.13) @@ -27642,7 +27253,7 @@ snapshots: '@jimp/utils@0.16.13': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 regenerator-runtime: 0.13.11 '@jridgewell/gen-mapping@0.3.12': @@ -27753,14 +27364,14 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -27771,202 +27382,20 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} - '@mariozechner/pi-agent-core@0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-agent-core@0.70.2': - dependencies: - '@mariozechner/pi-ai': 0.70.2 - typebox: 1.1.34 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-agent-core@0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - typebox: 1.1.34 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-agent-core@0.70.2(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.70.2(zod@4.3.6) - typebox: 1.1.34 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-agent-core@0.70.2(zod@4.4.3)': - dependencies: - '@mariozechner/pi-ai': 0.70.2(zod@4.4.3) - typebox: 1.1.34 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1037.0 - '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) - '@mistralai/mistralai': 1.14.1 - '@sinclair/typebox': 0.34.49 - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.70.2': - dependencies: - '@anthropic-ai/sdk': 0.90.0(zod@4.4.3) - '@aws-sdk/client-bedrock-runtime': 3.1037.0 - '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.1.34 - undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1037.0 - '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.1.34 - undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.70.2(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1037.0 - '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.1.34 - undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.70.2(zod@4.4.3)': - dependencies: - '@anthropic-ai/sdk': 0.90.0(zod@4.4.3) - '@aws-sdk/client-bedrock-runtime': 3.1037.0 - '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(zod@4.4.3) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.1.34 - undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mermaid-js/parser@1.1.0': dependencies: langium: 4.2.2 '@microsoft/fetch-event-source@2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188)': {} - '@mistralai/mistralai@1.14.1': - dependencies: - ws: 8.20.0 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@mistralai/mistralai@2.2.1': + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 ws: 8.20.0 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -28115,8 +27544,6 @@ snapshots: '@noble/hashes@2.0.1': {} - '@nodable/entities@2.1.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -28146,6 +27573,8 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@opentelemetry/semantic-conventions@1.41.1': {} @@ -30893,222 +30322,50 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@smithy/config-resolver@4.4.17': + '@smithy/core@3.29.2': dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/core@3.23.17': + '@smithy/credential-provider-imds@4.4.7': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.2 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.14': + '@smithy/fetch-http-handler@5.6.4': dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.2.14': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@4.2.14': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@4.3.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@4.2.14': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@4.2.14': - dependencies: - '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.3.17': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.2.14': - dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.2.14': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.32': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-serde': 4.2.20 - '@smithy/node-config-provider': 4.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-middleware': 4.2.14 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.5.5': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/service-error-classification': 4.3.0 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.4 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.20': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.14': - dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.6.1': + '@smithy/node-http-handler@4.7.3': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/property-provider@4.2.14': + '@smithy/node-http-handler@4.9.4': dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/protocol-http@5.3.14': + '@smithy/signature-v4@5.6.3': dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-uri-escape': 4.2.2 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.3.0': - dependencies: - '@smithy/types': 4.14.1 - - '@smithy/shared-ini-file-loader@4.4.9': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.14': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-uri-escape': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/smithy-client@4.12.13': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-stack': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@smithy/types@4.14.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.14': - dependencies: - '@smithy/querystring-parser': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.2': - dependencies: - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.2.3': + '@smithy/types@4.16.0': dependencies: tslib: 2.8.1 @@ -31117,82 +30374,11 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.2': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - tslib: 2.8.1 - - '@smithy/util-config-provider@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/util-defaults-mode-browser@4.3.49': - dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-defaults-mode-node@4.2.54': - dependencies: - '@smithy/config-resolver': 4.4.17 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-endpoints@3.4.2': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-hex-encoding@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/util-middleware@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-retry@4.3.4': - dependencies: - '@smithy/service-error-classification': 4.3.0 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.25': - dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.2.2': - dependencies: - tslib: 2.8.1 - '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.2': - dependencies: - '@smithy/util-buffer-from': 4.2.2 - tslib: 2.8.1 - - '@smithy/uuid@1.1.2': - dependencies: - tslib: 2.8.1 - '@standard-schema/spec@1.1.0': {} '@streamdown/math@1.0.2(react@19.1.0)': @@ -31555,7 +30741,7 @@ snapshots: '@tanstack/devtools-event-bus@0.3.3': dependencies: - ws: 8.18.3 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -31913,9 +31099,9 @@ snapshots: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) - '@babel/template': 7.28.6 + '@babel/template': 7.29.7 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@tanstack/directive-functions-plugin': 1.139.0(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) babel-dead-code-elimination: 1.0.10 tiny-invariant: 1.3.3 @@ -32202,8 +31388,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@trpc/client@11.7.2(@trpc/server@11.7.2(typescript@5.7.2))(typescript@5.7.2)': dependencies: '@trpc/server': 11.7.2(typescript@5.7.2) @@ -33621,7 +32805,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/expect@3.2.4': dependencies: @@ -34306,13 +33490,9 @@ snapshots: ast-kit@1.4.3: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 pathe: 2.0.3 - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -34700,8 +33880,6 @@ snapshots: baseline-browser-mapping@2.10.38: {} - basic-ftp@5.3.0: {} - bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -35843,8 +35021,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -35882,7 +35058,7 @@ snapshots: date-fns@2.30.0: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 date-fns@4.1.0: {} @@ -36002,12 +35178,6 @@ snapshots: defu@6.1.4: {} - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - del@6.1.1: dependencies: globby: 11.1.0 @@ -36960,14 +36130,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.32.0(jiti@2.6.1)): dependencies: eslint: 9.32.0(jiti@2.6.1) @@ -37921,17 +37083,6 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-builder@1.1.5: - dependencies: - path-expression-matcher: 1.5.0 - - fast-xml-parser@5.7.1: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 - path-expression-matcher: 1.5.0 - strnum: 2.2.3 - fastq@1.20.1: dependencies: reusify: 1.0.4 @@ -38316,14 +37467,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.0 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - getenv@2.0.0: {} getpass@0.1.7: @@ -39436,7 +38579,7 @@ snapshots: jimp@0.16.13: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@jimp/custom': 0.16.13 '@jimp/plugins': 0.16.13(@jimp/custom@0.16.13) '@jimp/types': 0.16.13(@jimp/custom@0.16.13) @@ -39653,7 +38796,7 @@ snapshots: json-schema-to-ts@3.1.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 json-schema-traverse@0.4.1: {} @@ -40086,8 +39229,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lucide-react@0.544.0(react@19.2.0): dependencies: react: 19.2.0 @@ -40605,17 +39746,17 @@ snapshots: metro-runtime@0.82.5: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 metro-runtime@0.83.3: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 metro-runtime@0.83.7: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 metro-source-map@0.82.5: @@ -41325,8 +40466,6 @@ snapshots: nested-error-stacks@2.0.1: {} - netmask@2.1.1: {} - next@14.2.17(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 14.2.17 @@ -41936,24 +41075,6 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - package-json-from-dist@1.0.1: {} package-manager-detector@0.2.2: {} @@ -42051,8 +41172,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -42312,7 +41431,7 @@ snapshots: polished@4.3.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 possible-typed-array-names@1.0.0: {} @@ -42333,7 +41452,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.4.47): dependencies: lilconfig: 3.1.2 - yaml: 2.8.1 + yaml: 2.9.0 optionalDependencies: postcss: 8.4.47 @@ -42683,19 +41802,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.5.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - proxy-compare@3.0.1: {} proxy-from-env@1.1.0: {} @@ -43184,7 +42290,7 @@ snapshots: react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/react-redux': 7.1.34 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -43479,7 +42585,7 @@ snapshots: redux@4.2.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 reflect.getprototypeof@1.0.10: dependencies: @@ -43510,7 +42616,7 @@ snapshots: regenerator-transform@0.15.2: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 regex-recursion@6.0.2: dependencies: @@ -44406,7 +43512,8 @@ snapshots: slugify@1.6.6: {} - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true smob@1.6.2: {} @@ -44415,19 +43522,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - solid-js@1.9.7: dependencies: csstype: 3.2.3 @@ -44854,8 +43948,6 @@ snapshots: dependencies: js-tokens: 9.0.1 - strnum@2.2.3: {} - strtok3@6.3.0: dependencies: '@tokenizer/token': 0.3.0 @@ -45558,7 +44650,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 - typebox@1.1.34: {} + typebox@1.1.38: {} typed-array-buffer@1.0.3: dependencies: @@ -46727,6 +45819,36 @@ snapshots: - tsx - yaml + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.17 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 29.1.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 From 85d4debd003411ca43e379d02c6ecba51175b91a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 13 Jul 2026 10:47:03 -0600 Subject: [PATCH 086/105] Add changeset for pi agent upgrade --- .changeset/upgrade-pi-agent-core-openai.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/upgrade-pi-agent-core-openai.md diff --git a/.changeset/upgrade-pi-agent-core-openai.md b/.changeset/upgrade-pi-agent-core-openai.md new file mode 100644 index 0000000000..00b41a6316 --- /dev/null +++ b/.changeset/upgrade-pi-agent-core-openai.md @@ -0,0 +1,9 @@ +--- +'@electric-ax/agents': patch +'@electric-ax/agents-runtime': patch +'@electric-ax/agents-server': patch +'@electric-ax/agents-server-conformance-tests': patch +'@electric-ax/agents-desktop': patch +--- + +Update the agents runtime to the renamed Earendil Works pi packages and latest OpenAI/Codex model catalog. Preserve custom provider id compatibility, move built-in Codex defaults to GPT-5.6 models, and remove deprecated direct pi-agent-core dependencies from agents examples. From fa36b936624b97ea20df8528498d592565454bfa Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 13 Jul 2026 12:12:13 -0600 Subject: [PATCH 087/105] Fix reconstructed pi assistant history --- packages/agents-desktop/package.json | 2 +- packages/agents-runtime/src/pi-adapter.ts | 49 ++++++++++++++----- .../agents-runtime/test/pi-adapter.test.ts | 36 ++++++++++++++ 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index 51e83deed0..6fb163a7db 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -9,7 +9,7 @@ "type": "module", "main": "./dist/main.cjs", "scripts": { - "build": "pnpm --filter @electric-ax/agents build && pnpm --filter electric-ax build:desktop && pnpm --filter @electric-ax/agents-server-ui build:desktop && vite build", + "build": "pnpm --filter @electric-ax/agents-runtime build && pnpm --filter @electric-ax/agents build && pnpm --filter electric-ax build:desktop && pnpm --filter @electric-ax/agents-server-ui build:desktop && vite build", "dev": "pnpm run ensure:electron && concurrently -k -n ui,desktop -c cyan,green \"pnpm run dev:ui\" \"pnpm run dev:desktop\"", "dev:ui": "pnpm --filter @electric-ax/agents-server-ui dev:desktop", "dev:desktop": "wait-on -d 200 http-get://localhost:${ELECTRIC_DESKTOP_UI_PORT:-5183} && vite", diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index f5a9c74f1c..4a24476f41 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -171,6 +171,37 @@ function toAgentContent(content: LLMMessageContent): Array { type AssistantAgentMessage = Extract +type HistoryModel = Pick, `api` | `provider` | `id`> + +function createHistoryAssistantMessage( + content: Array, + model?: HistoryModel +): AssistantAgentMessage { + return { + role: `assistant`, + content, + api: model?.api ?? `unknown`, + provider: model?.provider ?? `unknown`, + model: model?.id ?? `unknown`, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } as AssistantAgentMessage +} + function isAssistantAgentMessage( message: AgentMessage | undefined ): message is AssistantAgentMessage { @@ -178,7 +209,8 @@ function isAssistantAgentMessage( } export function toAgentHistory( - messages: Array + messages: Array, + model?: HistoryModel ): Array { const history: Array = [] const toolNamesById = new Map() @@ -220,11 +252,7 @@ export function toAgentHistory( prevContent.push(...content) } } else { - history.push({ - role: `assistant`, - content, - timestamp: Date.now(), - } as AgentMessage) + history.push(createHistoryAssistantMessage(content, model)) } break } @@ -242,11 +270,7 @@ export function toAgentHistory( if (prev) { ;(prev.content as Array).push(block) } else { - history.push({ - role: `assistant`, - content: [block], - timestamp: Date.now(), - } as AgentMessage) + history.push(createHistoryAssistantMessage([block], model)) } break } @@ -280,8 +304,6 @@ export function createPiAgentAdapter( config.writeEvent, opts.onStepEnd ? { onStepEnd: opts.onStepEnd } : undefined ) - const history = toAgentHistory(config.messages) - let running = false let disposed = false let stepStartTime = 0 @@ -294,6 +316,7 @@ export function createPiAgentAdapter( model: opts.model, ...(opts.provider && { provider: opts.provider }), }) + const history = toAgentHistory(config.messages, model) const modelTimeoutMs = opts.modelTimeoutMs ?? DEFAULT_MODEL_TIMEOUT_MS const modelMaxRetries = opts.modelMaxRetries ?? DEFAULT_MODEL_MAX_RETRIES diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 20994b67d5..ea7db607e8 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -10,6 +10,7 @@ import type { OutboundIdSeed } from '../src/outbound-bridge' import type { LLMMessage } from '../src/types' import type { ChangeEvent } from '@durable-streams/state' import type { AgentTool } from '@earendil-works/pi-agent-core' +import { estimateContextTokens } from '@earendil-works/pi-agent-core' import type { AssistantMessage, Model, @@ -958,6 +959,41 @@ describe(`toAgentHistory`, () => { expect(msg?.role).toBe(`assistant`) const content = msg?.content as Array<{ type: string; text: string }> expect(content[0]?.text).toBe(`Hello world`) + expect(msg?.usage).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }) + expect(() => estimateContextTokens(history)).not.toThrow() + }) + + it(`uses the active model metadata for reconstructed assistant messages`, () => { + const model = resolvePiModel({ + provider: `openai-codex`, + model: `gpt-5.6-terra`, + }) + + const history = toAgentHistory( + [{ role: `assistant`, content: `Prior response` }], + model + ) + const assistant = history[0] as AssistantMessage + + expect(assistant).toMatchObject({ + api: model.api, + provider: model.provider, + model: model.id, + stopReason: `stop`, + }) }) it(`includes custom event turns from the context pipeline as user messages`, () => { From 52168b161db9b8b9e10d2f7a2c2c3adb1890bcc4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 16:28:18 -0600 Subject: [PATCH 088/105] docs: design desktop sleep resume wake recovery --- ...-sleep-resume-pull-wake-recovery-design.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md diff --git a/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md b/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md new file mode 100644 index 0000000000..8b925620c0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md @@ -0,0 +1,78 @@ +# Desktop Sleep/Resume Pull-Wake Recovery + +## Problem + +Closing and reopening a laptop can leave the Node/Electron Durable Streams long-poll used by the pull-wake runner pending on a stale socket. The Durable Streams client retries rejected requests, but a request that never settles gives its retry loop no opportunity to reconnect. Restarting the complete built-in runtime replaces the connection, but also risks interrupting active LLM and tool work. + +This change is limited to the Electric Agents repository. A client-side request deadline in Durable Streams remains appropriate upstream work, but is outside this branch. + +## Goals + +- Reconnect the pull-wake subscription immediately when Electron reports system resume. +- Preserve active wake handlers, LLM calls, tool calls, and child runs. +- Resume the stream from the pull-wake runner's current offset. +- Make repeated or overlapping resume signals safe and idempotent. +- Retain the existing heartbeat-gap detector as a platform-independent fallback. + +## Non-goals + +- Restarting the entire `BuiltinAgentsServer` on every resume. +- Adding timeout escalation or runtime health supervision. +- Modifying the Durable Streams repository or package. +- Changing wake routing, claim leases, or server recovery policy. + +## Design + +### Pull-wake runner + +Expose an explicit reconnect operation on `PullWakeRunner`. Calling it sends the existing state machine a stream-reset event. The state machine aborts the current Durable Streams request and follows its normal reconnect path using the runner's in-memory committed offset. + +The operation does not stop the runner, clear deferred wake notifications, or call `runtime.abortWakes()`. Existing wake handlers continue running. Calls made while stopped are no-ops. Multiple reset requests while a reset is already in progress must not create parallel stream readers. + +The existing elapsed-heartbeat-gap reset remains unchanged and acts as defense in depth for non-Electron runtimes and missed power events. + +### Built-in agents server + +Add a public `reconnectPullWake()` method to `BuiltinAgentsServer`. It delegates to the current pull-wake runner when one exists and otherwise returns without error. This gives embedders a narrow recovery API without exposing runner internals or restarting the rest of the runtime. + +### Desktop lifecycle + +Register Electron `powerMonitor` listeners after `app.whenReady()`: + +- `suspend`: record/log the event only. +- `resume`: iterate current runtime entries and call `reconnectPullWake()` only for entries whose desired state is connected, whose local runtime is enabled/running, and whose runtime instance exists. + +The listener delegates through the desktop controller/runtime lifecycle boundary rather than reaching into context state from `main.ts`. Registration happens once per desktop process. Shutdown removes listeners or relies on a single process-lifetime registration, with tests proving duplicate registration does not occur. + +A resume does not call `startRuntime()` or stop the current runtime. Existing reconnect timers and startup state remain independent. + +## Data flow + +1. macOS wakes after lid-open. +2. Electron emits `powerMonitor.resume`. +3. Desktop lifecycle selects connected local runtime entries. +4. Each `BuiltinAgentsServer.reconnectPullWake()` delegates to its runner. +5. Runner sends `STREAM_RESET` to its state machine. +6. The stale fetch is aborted and the normal reconnect state opens one replacement stream. +7. The replacement request uses the runner's current offset; active wake processing continues independently. + +## Error handling and observability + +The resume handler is best-effort and handles each runtime independently so one failure cannot block others. It logs resume detection and reconnect failures with the server ID. Since reconnect initiation is synchronous/idempotent, no desktop-wide restart or retry policy is added. The runner's existing reconnect backoff and `onError` diagnostics remain authoritative. + +## Testing + +Tests follow red-green regression discipline: + +1. **Pull-wake runner:** begin with a stream whose request remains pending, call explicit reconnect, and assert the request is aborted, exactly one replacement stream opens, and the last committed offset is reused. Repeated reconnect calls must not create parallel streams. Verify active runtime wakes are not aborted. +2. **BuiltinAgentsServer:** verify delegation when running and safe no-op behavior before startup and after shutdown. +3. **Desktop:** mock `powerMonitor`, emit resume, and verify only connected local running entries receive reconnect calls. Cover disconnected, disabled, absent, and starting runtimes, plus one-runtime failure isolation and single listener registration. +4. Keep the heartbeat-gap recovery test to prove the non-Electron fallback remains intact. + +## Acceptance criteria + +- Lid-open triggers pull-wake reconnection without restarting the built-in runtime. +- Active wake handlers remain active. +- Reconnection preserves cursor/offset continuity and opens no parallel stream readers. +- Ineligible desktop runtime entries are untouched. +- All new regression tests fail against the pre-change behavior and pass after implementation. From 603aef0fa2bb710aa19c5e25ca33f24ca4bd8faa Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 16:33:46 -0600 Subject: [PATCH 089/105] docs: plan desktop sleep resume wake recovery --- ...desktop-sleep-resume-pull-wake-recovery.md | 824 ++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md diff --git a/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md b/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md new file mode 100644 index 0000000000..c3c5499e72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md @@ -0,0 +1,824 @@ +# Desktop Sleep/Resume Pull-Wake Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reconnect only the stale pull-wake Durable Streams subscription when Electron reports system resume, without restarting the built-in runtime or interrupting active wake handlers. + +**Architecture:** Add an idempotent `reconnect()` command to the pull-wake runner's existing XState lifecycle, expose it narrowly as `BuiltinAgentsServer.reconnectPullWake()`, and route Electron `powerMonitor` suspend/resume events through a focused desktop power-monitor controller. Desktop resume selection remains in runtime lifecycle code, where each eligible runtime is reconnected independently. + +**Tech Stack:** TypeScript, XState, Electron `powerMonitor`, Vitest, pnpm workspaces + +## Global Constraints + +- Limit changes to the Electric repository; do not modify the Durable Streams repository or dependency. +- Reconnect the pull-wake stream without restarting `BuiltinAgentsServer`. +- Do not abort active wakes, LLM calls, tool calls, or child runs. +- Preserve the pull-wake runner's current offset across reconnection. +- Repeated or overlapping resume/reconnect signals must not create parallel stream readers. +- Keep the existing heartbeat-gap stream reset as a platform-independent fallback. +- Do not add timeout escalation, runtime health supervision, wake-routing changes, claim-lease changes, or server recovery-policy changes. +- Use Node `>=22.19.0` when installing dependencies because `@earendil-works/pi-agent-core@0.80.5` declares that engine floor. + +## File Structure + +- Modify `packages/agents-runtime/src/pull-wake-runner.ts` — expose an explicit runner reconnect command that delegates to the existing lifecycle machine. +- Modify `packages/agents-runtime/test/pull-wake-runner.test.ts` — prove hung-stream cancellation, offset continuity, idempotency, and active-wake preservation. +- Modify `packages/agents/src/server.ts` — expose a narrow embedder-facing `reconnectPullWake()` method. +- Modify `packages/agents/test/builtin-pull-wake-registration.test.ts` — verify delegation and lifecycle-safe no-op behavior. +- Modify `packages/agents-desktop/src/runtime/lifecycle.ts` — select eligible connected/running local runtimes and isolate reconnect failures. +- Modify `packages/agents-desktop/src/runtime/lifecycle.test.ts` — verify eligibility filtering and per-runtime failure isolation. +- Create `packages/agents-desktop/src/app/power-monitor.ts` — own idempotent Electron suspend/resume listener registration and cleanup. +- Create `packages/agents-desktop/src/app/power-monitor.test.ts` — verify suspend/resume behavior, error reporting, and duplicate-listener prevention. +- Modify `packages/agents-desktop/src/app/controller.ts` — compose the power-monitor controller with runtime lifecycle recovery and stop it during shutdown. +- Modify `packages/agents-desktop/src/main.ts` — start power-monitor recovery after Electron is ready. +- Modify `.changeset/reliable-repeated-wakes-and-desktop-restart.md` — describe explicit sleep/resume pull-wake recovery. + +--- + +### Task 1: Explicit Pull-Wake Runner Reconnect + +**Files:** + +- Modify: `packages/agents-runtime/src/pull-wake-runner.ts:57-65,650-700` +- Test: `packages/agents-runtime/test/pull-wake-runner.test.ts:90-180` + +**Interfaces:** + +- Consumes: existing `PullWakeMachineEvent` `{ type: 'STREAM_RESET'; error: Error }` and the machine's streaming/connecting/reconnecting guards. +- Produces: `PullWakeRunner.reconnect(): void`, safe in every lifecycle state. + +- [ ] **Step 1: Write the failing runner regression test** + +Add a test near the other lifecycle/reconnect tests. It must establish a first response that advances the runner offset and then hangs until `cancel()` is called, issue two reconnect requests, and verify only one replacement stream is created from the committed offset: + +```ts +it(`explicitly reconnects a stalled stream without aborting active wakes`, async () => { + vi.useFakeTimers() + const firstCancelled = deferred() + const secondCancelled = deferred() + let firstOffset = `-1` + const firstResponse: PullWakeStreamResponse = { + get offset() { + return firstOffset + }, + async *jsonStream() { + firstOffset = `42` + yield wakeEvent(`one`) + await firstCancelled.promise + }, + cancel: vi.fn(() => firstCancelled.resolve()), + closed: firstCancelled.promise, + } + const secondResponse: PullWakeStreamResponse = { + offset: `42`, + async *jsonStream() { + await secondCancelled.promise + }, + cancel: vi.fn(() => secondCancelled.resolve()), + closed: secondCancelled.promise, + } + const streamFactory = vi + .fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse) + vi.stubGlobal( + `fetch`, + vi.fn(async () => new Response(null, { status: 204 })) + ) + const testRuntime = runtime() + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: testRuntime, + heartbeatIntervalMs: 0, + streamFactory, + }) + + runner.start() + await vi.waitFor(() => expect(runner.offset).toBe(`42`)) + + runner.reconnect() + runner.reconnect() + await vi.waitFor(() => expect(firstResponse.cancel).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(streamFactory).toHaveBeenCalledTimes(2)) + + expect(streamFactory).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ offset: `42` }) + ) + expect(testRuntime.abortWakes).not.toHaveBeenCalled() + expect(testRuntime.drainWakes).not.toHaveBeenCalled() + + await runner.stop() +}) +``` + +If `vi.waitFor` and fake timers conflict in this Vitest version, replace each wait with `await Promise.resolve()` plus `await vi.advanceTimersByTimeAsync(...)`; do not weaken the assertions. + +- [ ] **Step 2: Run the focused test to verify RED** + +Run: + +```bash +pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -t "explicitly reconnects" +``` + +Expected: TypeScript/test transform failure or runtime failure because `PullWakeRunner` has no `reconnect()` method. + +- [ ] **Step 3: Add the minimal runner API** + +Extend the interface: + +```ts +export interface PullWakeRunner { + start: () => void + reconnect: () => void + stop: () => Promise + waitForStopped: () => Promise + readonly running: boolean + readonly offset: string | undefined + getHealth: () => PullWakeRunnerHealth +} +``` + +Add the method to the returned object immediately after `start()`: + +```ts +reconnect() { + if (!isRunningState()) return + actor.send({ + type: `STREAM_RESET`, + error: new Error(`Pull-wake runner reconnect requested`), + }) +}, +``` + +Do not call `controller.abort()`, clear deferred events, or invoke runtime shutdown APIs. The existing XState machine ignores repeated reset events once `streamResetError` is set and ignores reset events in `reconnecting`, preventing parallel readers. + +- [ ] **Step 4: Run runner regression and full runner suite to verify GREEN** + +Run: + +```bash +pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -t "explicitly reconnects" +pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts +pnpm --dir packages/agents-runtime typecheck +``` + +Expected: focused test passes; all pull-wake runner tests pass; typecheck exits 0. + +- [ ] **Step 5: Commit the runner change** + +```bash +git add packages/agents-runtime/src/pull-wake-runner.ts \ + packages/agents-runtime/test/pull-wake-runner.test.ts +git commit -m "fix(agents-runtime): reconnect stalled pull-wake streams" +``` + +--- + +### Task 2: Built-in Runtime Reconnect Facade + +**Files:** + +- Modify: `packages/agents/src/server.ts:105-120` +- Test: `packages/agents/test/builtin-pull-wake-registration.test.ts:1-35,76-170` + +**Interfaces:** + +- Consumes: `PullWakeRunner.reconnect(): void` from Task 1. +- Produces: `BuiltinAgentsServer.reconnectPullWake(): void`, safe before start, while running, and after stop. + +- [ ] **Step 1: Make the mocked runner observable and write failing facade tests** + +Replace the test's anonymous runner factory with hoisted spies: + +```ts +const pullWakeRunnerMocks = vi.hoisted(() => ({ + start: vi.fn(), + reconnect: vi.fn(), + stop: vi.fn(async () => {}), + waitForStopped: vi.fn(async () => {}), +})) + +vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + createPullWakeRunner: vi.fn(() => ({ + ...pullWakeRunnerMocks, + get running() { + return false + }, + get offset() { + return undefined + }, + })), + } +}) +``` + +Import `beforeEach` from Vitest and reset every shared runner spy before each test so assertions are isolated: + +```ts +beforeEach(() => { + pullWakeRunnerMocks.start.mockClear() + pullWakeRunnerMocks.reconnect.mockClear() + pullWakeRunnerMocks.stop.mockClear() + pullWakeRunnerMocks.waitForStopped.mockClear() +}) +``` + +Then add these tests: + +```ts +it(`reconnects the pull-wake runner without restarting the built-in runtime`, async () => { + agentsServer = await startRecordingAgentsServer() + builtinServer = new BuiltinAgentsServer({ + agentServerUrl: agentsServer.url, + mockStreamFn, + pullWake: { runnerId: `test-runner` }, + }) + await builtinServer.start() + + builtinServer.reconnectPullWake() + + expect(pullWakeRunnerMocks.reconnect).toHaveBeenCalledTimes(1) + expect(pullWakeRunnerMocks.stop).not.toHaveBeenCalled() +}) + +it(`treats pull-wake reconnect as a no-op outside the running lifecycle`, async () => { + agentsServer = await startRecordingAgentsServer() + builtinServer = new BuiltinAgentsServer({ + agentServerUrl: agentsServer.url, + mockStreamFn, + pullWake: { runnerId: `test-runner` }, + }) + + builtinServer.reconnectPullWake() + expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() + + await builtinServer.start() + await builtinServer.stop() + pullWakeRunnerMocks.reconnect.mockClear() + builtinServer.reconnectPullWake() + + expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() +}) +``` + +- [ ] **Step 2: Run the facade tests to verify RED** + +Run: + +```bash +pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts -t "pull-wake reconnect|reconnects the pull-wake" +``` + +Expected: test transform failure because `BuiltinAgentsServer.reconnectPullWake()` does not exist. + +- [ ] **Step 3: Add the facade method** + +Add beside the `mcpRegistry` getter in `BuiltinAgentsServer`: + +```ts +/** Reconnect only the pull-wake subscription, preserving active wake work. */ +reconnectPullWake(): void { + this.pullWakeRunner?.reconnect() +} +``` + +Do not call `start()`, `stop()`, `runtime.abortWakes()`, or MCP lifecycle methods. + +- [ ] **Step 4: Run facade tests and package verification to verify GREEN** + +Run: + +```bash +pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts +pnpm --dir packages/agents typecheck +``` + +Expected: all tests in the file pass and typecheck exits 0. + +- [ ] **Step 5: Commit the facade change** + +```bash +git add packages/agents/src/server.ts \ + packages/agents/test/builtin-pull-wake-registration.test.ts +git commit -m "feat(agents): expose pull-wake reconnect" +``` + +--- + +### Task 3: Desktop Runtime Resume Selection + +**Files:** + +- Modify: `packages/agents-desktop/src/runtime/lifecycle.ts:115-145` +- Test: `packages/agents-desktop/src/runtime/lifecycle.test.ts:1-105` + +**Interfaces:** + +- Consumes: `RuntimeLifecycleDeps.runtimeEntries`, `RuntimeLifecycleDeps.findServer()`, and `BuiltinAgentsServer.reconnectPullWake(): void` from Task 2. +- Produces: `reconnectPullWakesAfterResume(deps: RuntimeLifecycleDeps): void`. + +- [ ] **Step 1: Generalize the lifecycle test fixture and add failing selection/isolation tests** + +Update the `@electric-ax/agents` mock type surface to include `reconnectPullWake`, and add a fixture helper that can append server/entry pairs. Then add: + +```ts +it(`reconnects only connected running local runtimes after resume`, () => { + const { deps, entry } = setup() + const reconnectRunning = vi.fn() + entry.status = `connected` + entry.localRuntimeStatus = `running` + entry.runtime = { + reconnectPullWake: reconnectRunning, + } as RuntimeEntry[`runtime`] + + const disconnected = addRuntime(deps, { + id: `disconnected`, + desiredState: `disconnected`, + localRuntimeEnabled: true, + localRuntimeStatus: `running`, + }) + const disabled = addRuntime(deps, { + id: `disabled`, + desiredState: `connected`, + localRuntimeEnabled: false, + localRuntimeStatus: `disabled`, + }) + const starting = addRuntime(deps, { + id: `starting`, + desiredState: `connected`, + localRuntimeEnabled: true, + localRuntimeStatus: `starting`, + }) + + reconnectPullWakesAfterResume(deps) + + expect(reconnectRunning).toHaveBeenCalledTimes(1) + expect(disconnected.reconnect).not.toHaveBeenCalled() + expect(disabled.reconnect).not.toHaveBeenCalled() + expect(starting.reconnect).not.toHaveBeenCalled() +}) + +it(`isolates pull-wake reconnect failures between runtimes`, () => { + const { deps, entry } = setup() + entry.status = `connected` + entry.localRuntimeStatus = `running` + entry.runtime = { + reconnectPullWake: vi.fn(() => { + throw new Error(`first failed`) + }), + } as RuntimeEntry[`runtime`] + const second = addRuntime(deps, { + id: `second`, + desiredState: `connected`, + localRuntimeEnabled: true, + localRuntimeStatus: `running`, + }) + + expect(() => reconnectPullWakesAfterResume(deps)).not.toThrow() + expect(second.reconnect).toHaveBeenCalledTimes(1) +}) +``` + +Add this complete fixture helper above the `describe` block: + +```ts +type AddedRuntimeOptions = { + id: string + desiredState: ServerConfig[`desiredState`] + localRuntimeEnabled: boolean + localRuntimeStatus: RuntimeEntry[`localRuntimeStatus`] +} + +function addRuntime( + deps: RuntimeLifecycleDeps, + options: AddedRuntimeOptions +): { + reconnect: ReturnType + server: ServerConfig + entry: RuntimeEntry +} { + const reconnect = vi.fn() + const server: ServerConfig = { + id: options.id, + name: options.id, + url: `http://localhost/${options.id}`, + source: `manual`, + desiredState: options.desiredState, + localRuntimeEnabled: options.localRuntimeEnabled, + } + const entry: RuntimeEntry = { + serverId: server.id, + desiredState: options.desiredState, + status: options.desiredState === `connected` ? `connected` : `disconnected`, + localRuntimeStatus: options.localRuntimeStatus, + runtime: { reconnectPullWake: reconnect } as RuntimeEntry[`runtime`], + runtimeUrl: null, + runtimeError: null, + reconnectTimer: null, + reconnectAttempt: 0, + generation: 0, + lastError: null, + lastConnectedAt: null, + mcpUnsubscribe: null, + } + deps.settings.servers.push(server) + deps.runtimeEntries.set(server.id, entry) + return { reconnect, server, entry } +} +``` + +In `setup()`, define `deps` first and make `findServer` search the mutable settings collection: + +```ts +findServer: (serverId) => + deps.settings.servers.find((candidate) => candidate.id === serverId) ?? null, +``` + +Because the function closes over `deps` but is not called until after initialization completes, this is safe and allows the helper's added servers to be discovered. + +- [ ] **Step 2: Run the desktop lifecycle tests to verify RED** + +Run: + +```bash +pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts -t "after resume|between runtimes" +``` + +Expected: test transform failure because `reconnectPullWakesAfterResume` does not exist. + +- [ ] **Step 3: Implement runtime eligibility and failure isolation** + +Add to `packages/agents-desktop/src/runtime/lifecycle.ts` near `restartConnectedRuntimes`: + +```ts +export function reconnectPullWakesAfterResume( + deps: RuntimeLifecycleDeps +): void { + for (const entry of deps.runtimeEntries.values()) { + const server = deps.findServer(entry.serverId) + if ( + !server?.localRuntimeEnabled || + entry.desiredState !== `connected` || + entry.localRuntimeStatus !== `running` || + !entry.runtime + ) { + continue + } + + try { + entry.runtime.reconnectPullWake() + } catch (error) { + console.warn( + `[agents-desktop] Failed to reconnect pull-wake after resume for ${entry.serverId}:`, + error + ) + } + } +} +``` + +Do not call `startRuntime`, `restartRuntime`, `stopRuntimeEntry`, or mutate entry status. + +- [ ] **Step 4: Run desktop lifecycle verification to verify GREEN** + +Run: + +```bash +pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts +pnpm --dir packages/agents-desktop typecheck +``` + +Expected: all lifecycle tests pass and typecheck exits 0. + +- [ ] **Step 5: Commit runtime selection** + +```bash +git add packages/agents-desktop/src/runtime/lifecycle.ts \ + packages/agents-desktop/src/runtime/lifecycle.test.ts \ + packages/agents-desktop/package.json pnpm-lock.yaml +git commit -m "fix(agents-desktop): select runtimes for resume recovery" +``` + +--- + +### Task 4: Idempotent Electron Power-Monitor Controller + +**Files:** + +- Create: `packages/agents-desktop/src/app/power-monitor.ts` +- Test: `packages/agents-desktop/src/app/power-monitor.test.ts` + +**Interfaces:** + +- Consumes: an Electron-compatible monitor with `on('suspend' | 'resume', listener)` and `removeListener(...)`, plus `onResume(): void`. +- Produces: `createPowerMonitorRecovery(options): { start(): void; stop(): void }`. + +- [ ] **Step 1: Write failing power-monitor controller tests** + +Create `packages/agents-desktop/src/app/power-monitor.test.ts` with a small fake event emitter: + +```ts +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { createPowerMonitorRecovery } from './power-monitor' + +class FakePowerMonitor extends EventEmitter { + listenerCountFor(event: `suspend` | `resume`): number { + return this.listenerCount(event) + } +} + +describe(`power monitor recovery`, () => { + it(`runs resume recovery once and does not duplicate listeners`, () => { + const monitor = new FakePowerMonitor() + const onResume = vi.fn() + const recovery = createPowerMonitorRecovery({ monitor, onResume }) + + recovery.start() + recovery.start() + expect(monitor.listenerCountFor(`suspend`)).toBe(1) + expect(monitor.listenerCountFor(`resume`)).toBe(1) + + monitor.emit(`suspend`) + monitor.emit(`resume`) + expect(onResume).toHaveBeenCalledTimes(1) + + recovery.stop() + expect(monitor.listenerCountFor(`suspend`)).toBe(0) + expect(monitor.listenerCountFor(`resume`)).toBe(0) + }) + + it(`reports resume callback failures without throwing from the event`, () => { + const monitor = new FakePowerMonitor() + const onError = vi.fn() + const recovery = createPowerMonitorRecovery({ + monitor, + onResume: () => { + throw new Error(`resume failed`) + }, + onError, + }) + recovery.start() + + expect(() => monitor.emit(`resume`)).not.toThrow() + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: `resume failed` }) + ) + }) +}) +``` + +- [ ] **Step 2: Run the power-monitor tests to verify RED** + +Run: + +```bash +pnpm --dir packages/agents-desktop test --run src/app/power-monitor.test.ts +``` + +Expected: module resolution failure because `./power-monitor` does not exist. + +- [ ] **Step 3: Implement the focused power-monitor controller** + +Create `packages/agents-desktop/src/app/power-monitor.ts`: + +```ts +import { powerMonitor } from 'electron' + +export type PowerMonitorLike = { + on: (event: `suspend` | `resume`, listener: () => void) => unknown + removeListener: (event: `suspend` | `resume`, listener: () => void) => unknown +} + +export type PowerMonitorRecovery = { + start: () => void + stop: () => void +} + +export function createPowerMonitorRecovery(options: { + monitor?: PowerMonitorLike + onResume: () => void + onError?: (error: Error) => void +}): PowerMonitorRecovery { + const monitor = options.monitor ?? powerMonitor + let started = false + let suspendedAt: number | null = null + + const handleSuspend = (): void => { + suspendedAt = Date.now() + console.info(`[agents-desktop] System suspended.`) + } + const handleResume = (): void => { + const elapsedMs = suspendedAt === null ? null : Date.now() - suspendedAt + suspendedAt = null + console.info( + `[agents-desktop] System resumed${elapsedMs === null ? `` : ` after ${elapsedMs}ms`}; reconnecting pull-wake streams.` + ) + try { + options.onResume() + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)) + if (options.onError) options.onError(error) + else console.warn(`[agents-desktop] Resume recovery failed:`, error) + } + } + + return { + start(): void { + if (started) return + started = true + monitor.on(`suspend`, handleSuspend) + monitor.on(`resume`, handleResume) + }, + stop(): void { + if (!started) return + started = false + monitor.removeListener(`suspend`, handleSuspend) + monitor.removeListener(`resume`, handleResume) + suspendedAt = null + }, + } +} +``` + +The callback is synchronous because `reconnectPullWake()` only signals an XState actor. Keep asynchronous restart/escalation out of this controller. + +- [ ] **Step 4: Run power-monitor verification to verify GREEN** + +Run: + +```bash +pnpm --dir packages/agents-desktop test --run src/app/power-monitor.test.ts +pnpm --dir packages/agents-desktop typecheck +``` + +Expected: both power-monitor tests pass and typecheck exits 0. + +- [ ] **Step 5: Commit the power-monitor controller** + +```bash +git add packages/agents-desktop/src/app/power-monitor.ts \ + packages/agents-desktop/src/app/power-monitor.test.ts +git commit -m "feat(agents-desktop): monitor system resume" +``` + +--- + +### Task 5: Wire Resume Recovery Into Desktop Startup and Shutdown + +**Files:** + +- Modify: `packages/agents-desktop/src/app/controller.ts:1-70,300-330,575-625` +- Modify: `packages/agents-desktop/src/main.ts:95-135` +- Modify: `.changeset/reliable-repeated-wakes-and-desktop-restart.md` + +**Interfaces:** + +- Consumes: `createPowerMonitorRecovery()` from Task 4 and `reconnectPullWakesAfterResume()` from Task 3. +- Produces: `DesktopMainController.startPowerMonitorRecovery(): void`; shutdown removes registered listeners. + +- [ ] **Step 1: Add a composition-level test assertion before wiring** + +Extend `packages/agents-desktop/src/app/power-monitor.test.ts` with a test using a real callback that invokes a spy named like the lifecycle operation: + +```ts +it(`routes resume through the supplied runtime recovery boundary`, () => { + const monitor = new FakePowerMonitor() + const reconnectPullWakesAfterResume = vi.fn() + const recovery = createPowerMonitorRecovery({ + monitor, + onResume: reconnectPullWakesAfterResume, + }) + recovery.start() + + monitor.emit(`resume`) + + expect(reconnectPullWakesAfterResume).toHaveBeenCalledTimes(1) +}) +``` + +This passes at the module boundary after Task 4; the RED gate for final wiring is the TypeScript compiler after adding the new controller return member call in `main.ts` before defining it. + +- [ ] **Step 2: Add the startup call first and verify wiring is RED** + +In `packages/agents-desktop/src/main.ts`, after settings/cloud initialization and immediately before `controller.connectConfiguredServers()`, add: + +```ts +controller.startPowerMonitorRecovery() +``` + +Run: + +```bash +pnpm --dir packages/agents-desktop typecheck +``` + +Expected: FAIL because `DesktopMainController` does not yet expose `startPowerMonitorRecovery`. + +- [ ] **Step 3: Compose and expose power-monitor recovery in the controller** + +Import the factory and add the lifecycle function to the existing lifecycle import: + +```ts +import { createPowerMonitorRecovery } from './power-monitor' +import { + desktopSkillDirectories, + reconnectPullWakesAfterResume, +} from '../runtime/lifecycle' +``` + +After `const runtime = createRuntimeController(...)`, construct exactly one process-lifetime recovery object: + +```ts +const powerMonitorRecovery = createPowerMonitorRecovery({ + onResume: () => reconnectPullWakesAfterResume(runtime.lifecycleDeps), +}) +``` + +Add this returned controller method: + +```ts +startPowerMonitorRecovery: powerMonitorRecovery.start, +``` + +At the beginning of the returned `quitApp` method, before stopping runtimes, add: + +```ts +powerMonitorRecovery.stop() +``` + +This keeps listener ownership in the controller and ensures repeated `startPowerMonitorRecovery()` calls remain idempotent through Task 4's controller. + +- [ ] **Step 4: Update the changeset** + +Append to `.changeset/reliable-repeated-wakes-and-desktop-restart.md`: + +```md +Reconnect the pull-wake Durable Streams subscription when Electron reports system resume, preserving active agent work while replacing stale long-poll connections. +``` + +Do not add a Durable Streams package changeset. + +- [ ] **Step 5: Run complete verification** + +Run: + +```bash +pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts +pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts +pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts src/app/power-monitor.test.ts +pnpm --dir packages/agents-runtime typecheck +pnpm --dir packages/agents typecheck +pnpm --dir packages/agents-desktop typecheck +pnpm prettier --check \ + packages/agents-runtime/src/pull-wake-runner.ts \ + packages/agents-runtime/test/pull-wake-runner.test.ts \ + packages/agents/src/server.ts \ + packages/agents/test/builtin-pull-wake-registration.test.ts \ + packages/agents-desktop/src/runtime/lifecycle.ts \ + packages/agents-desktop/src/runtime/lifecycle.test.ts \ + packages/agents-desktop/src/app/power-monitor.ts \ + packages/agents-desktop/src/app/power-monitor.test.ts \ + packages/agents-desktop/src/app/controller.ts \ + packages/agents-desktop/src/main.ts \ + .changeset/reliable-repeated-wakes-and-desktop-restart.md +git diff --check +``` + +Expected: all focused suites and typechecks pass; Prettier reports all files formatted; `git diff --check` exits 0. + +Also run the agents build because the desktop consumes the built package boundary: + +```bash +./scripts/dev.sh build +``` + +Expected: all agents packages build successfully. If installation fails solely because the shell's Node version is below `22.19.0`, switch to a compliant Node version and rerun; do not bypass the package engine requirement. + +- [ ] **Step 6: Commit final desktop wiring** + +```bash +git add packages/agents-desktop/src/app/controller.ts \ + packages/agents-desktop/src/main.ts \ + packages/agents-desktop/src/app/power-monitor.test.ts \ + .changeset/reliable-repeated-wakes-and-desktop-restart.md +git commit -m "fix(agents-desktop): reconnect pull wakes on resume" +``` + +- [ ] **Step 7: Review branch state without pushing** + +Run: + +```bash +git status --short +git log --oneline --decorate -6 +``` + +Expected: only pre-existing/unrelated intended work remains uncommitted; the implementation commits are local. Do not push implementation commits unless the user explicitly asks. From dc9455d57bcadae83080af6e0cc13fdefcb5370e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:05:59 -0600 Subject: [PATCH 090/105] fix: improve agent wake reliability --- ...able-repeated-wakes-and-desktop-restart.md | 8 ++ packages/agents-desktop/package.json | 6 +- .../src/runtime/lifecycle.test.ts | 99 +++++++++++++++++++ .../agents-desktop/src/runtime/lifecycle.ts | 31 +++++- .../agents-runtime/src/pull-wake-runner.ts | 20 ++-- .../test/pull-wake-runner.test.ts | 16 +-- pnpm-lock.yaml | 3 + 7 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 .changeset/reliable-repeated-wakes-and-desktop-restart.md create mode 100644 packages/agents-desktop/src/runtime/lifecycle.test.ts diff --git a/.changeset/reliable-repeated-wakes-and-desktop-restart.md b/.changeset/reliable-repeated-wakes-and-desktop-restart.md new file mode 100644 index 0000000000..e956973ec5 --- /dev/null +++ b/.changeset/reliable-repeated-wakes-and-desktop-restart.md @@ -0,0 +1,8 @@ +--- +'@electric-ax/agents-runtime': patch +'@electric-ax/agents-desktop': patch +--- + +Preserve every deferred same-stream pull-wake notification instead of coalescing later generations, so repeated child completions reliably wake their parent. + +Make desktop runtime startup failures observable and retryable even when they occur before the built-in runtime instance starts, allowing configured agents to recover after relaunch. diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index 6fb163a7db..438fcb5152 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -24,7 +24,8 @@ "rebuild:native": "electron-builder install-app-deps", "start": "pnpm run ensure:electron && electron .", "coverage": "true", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@electric-sql/client": "^1.5.22", @@ -53,6 +54,7 @@ "typescript": "^5.8.3", "vite": "^7.1.7", "vite-plugin-electron": "^0.29.1", - "wait-on": "^9.0.1" + "wait-on": "^9.0.1", + "vitest": "^3.2.4" } } diff --git a/packages/agents-desktop/src/runtime/lifecycle.test.ts b/packages/agents-desktop/src/runtime/lifecycle.test.ts new file mode 100644 index 0000000000..0bde5d03ab --- /dev/null +++ b/packages/agents-desktop/src/runtime/lifecycle.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeEntry, ServerConfig } from '../shared/types' +import type { RuntimeLifecycleDeps } from './lifecycle' + +vi.mock(`@electric-ax/agents`, () => ({ + BuiltinAgentsServer: vi.fn(), +})) + +vi.mock(`electron`, () => ({ + app: { getPath: () => `/tmp` }, + powerSaveBlocker: { + isStarted: () => false, + start: () => 1, + stop: vi.fn(), + }, +})) + +vi.mock(`./health`, async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + checkAgentsServerHealth: vi.fn(async () => ({ ok: true })), + } +}) + +import { startRuntime } from './lifecycle' + +function setup(): { + deps: RuntimeLifecycleDeps + entry: RuntimeEntry +} { + const server: ServerConfig = { + id: `local`, + name: `Local`, + url: `http://localhost:4437`, + source: `manual`, + desiredState: `connected`, + localRuntimeEnabled: true, + } + const entry: RuntimeEntry = { + serverId: server.id, + desiredState: `connected`, + status: `disconnected`, + localRuntimeStatus: `stopped`, + runtime: null, + runtimeUrl: null, + runtimeError: null, + reconnectTimer: null, + reconnectAttempt: 0, + generation: 0, + lastError: null, + lastConnectedAt: null, + mcpUnsubscribe: null, + } + const deps: RuntimeLifecycleDeps = { + settings: { + servers: [server], + defaultServerId: server.id, + pullWakeRunnerId: null, + }, + runtimeEntries: new Map([[server.id, entry]]), + windowSelections: new Map(), + findServer: (serverId) => (serverId === server.id ? server : null), + ensureRuntimeEntry: () => entry, + saveSettings: vi.fn(async () => undefined), + refreshDesktopState: vi.fn(), + setState: vi.fn(), + setCredentialsRestartPending: vi.fn(), + injectDevPrincipalHeaders: (value) => value, + configureRuntimeEnvironment: vi.fn(), + applyApiKeys: vi.fn(), + syncCodexEnvironment: vi.fn(async () => undefined), + broadcastMcpSnapshot: vi.fn(), + handleAuthorizeUrl: vi.fn(async () => undefined), + getCloudAgentServers: vi.fn(), + getCloudAuthState: vi.fn(), + } + return { deps, entry } +} + +describe(`desktop runtime lifecycle`, () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it(`records and retries failures that happen before the built-in runtime starts`, async () => { + const { deps, entry } = setup() + + await expect(startRuntime(deps, `local`)).resolves.toBeUndefined() + + expect(entry.status).toBe(`error`) + expect(entry.localRuntimeStatus).toBe(`error`) + expect(entry.lastError).toBe( + `Desktop built-in agents require a pull-wake runner id` + ) + expect(entry.reconnectAttempt).toBe(1) + expect(entry.reconnectTimer).not.toBeNull() + }) +}) diff --git a/packages/agents-desktop/src/runtime/lifecycle.ts b/packages/agents-desktop/src/runtime/lifecycle.ts index e6264d79f1..b17fd21aad 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.ts @@ -185,7 +185,7 @@ export async function stopRuntimeEntry( } } -export async function startRuntime( +async function startRuntimeAttempt( deps: RuntimeLifecycleDeps, serverId: string ): Promise { @@ -391,6 +391,35 @@ export async function startRuntime( } } +export async function startRuntime( + deps: RuntimeLifecycleDeps, + serverId: string +): Promise { + try { + await startRuntimeAttempt(deps, serverId) + } catch (error) { + const activeServer = deps.findServer(serverId) + if (!activeServer) return + const entry = deps.ensureRuntimeEntry(activeServer) + if (entry.desiredState !== `connected`) return + + const message = error instanceof Error ? error.message : String(error) + console.error( + `[agents-desktop] Failed to start built-in agents runtime for ${activeServer.url}:`, + error + ) + entry.status = `error` + entry.localRuntimeStatus = `error` + entry.runtimeUrl = null + entry.runtimeError = message + entry.lastError = message + entry.reconnectAttempt += 1 + refreshPowerSaveBlocker(deps) + deps.refreshDesktopState() + scheduleReconnect(deps, serverId) + } +} + export async function connectServer( deps: RuntimeLifecycleDeps, serverId: string, diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 994aa4d197..6ae5391503 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -135,7 +135,7 @@ export function createPullWakeRunner( let stopPromise: Promise | null = null const claimActors = new Set>() const claimingStreamPaths = new Set() - const deferredWakeEventsByStreamPath = new Map() + const deferredWakeEventsByStreamPath = new Map() const deferredWakeTimersByStreamPath = new Map< string, ReturnType @@ -399,8 +399,8 @@ export function createPullWakeRunner( deferredWakeTimersByStreamPath.delete(streamPath) } - const deferredEvent = deferredWakeEventsByStreamPath.get(streamPath) - if (!deferredEvent || signal.aborted || !isRunningState()) { + const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) + if (!deferredEvents?.length || signal.aborted || !isRunningState()) { deferredWakeEventsByStreamPath.delete(streamPath) return } @@ -409,8 +409,14 @@ export function createPullWakeRunner( return } - deferredWakeEventsByStreamPath.delete(streamPath) + const deferredEvent = deferredEvents.shift()! + if (deferredEvents.length === 0) { + deferredWakeEventsByStreamPath.delete(streamPath) + } spawnClaimActor(deferredEvent, signal) + if (deferredEvents.length > 0) { + scheduleDeferredWakeRetry(streamPath, signal) + } } const scheduleDeferredWakeRetry = ( @@ -432,9 +438,9 @@ export function createPullWakeRunner( signal: AbortSignal ): void => { const streamPath = normalizeStreamPath(event.stream) - if (!deferredWakeEventsByStreamPath.has(streamPath)) { - deferredWakeEventsByStreamPath.set(streamPath, event) - } + const deferredEvents = deferredWakeEventsByStreamPath.get(streamPath) ?? [] + deferredEvents.push(event) + deferredWakeEventsByStreamPath.set(streamPath, deferredEvents) if (hasActiveStreamClaim(streamPath)) { recordClaimSkipped() scheduleDeferredWakeRetry(streamPath, signal) diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index 58a78045c1..d6d7bff566 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -279,13 +279,13 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) - it(`coalesces wake notifications skipped while the same stream is already being claimed`, async () => { + it(`retries every wake notification skipped while the same stream is already being claimed`, async () => { const events = [ { ...wakeEvent(`parent`), generation: 1 }, { ...wakeEvent(`parent`), generation: 2 }, { ...wakeEvent(`parent`), generation: 3 }, ] - const claims = [`one`, `coalesced`].map(notification) + const claims = [`one`, `two`, `three`].map(notification) const firstClaimResponse = deferred() const fetchMock = vi .fn() @@ -295,6 +295,9 @@ describe(`createPullWakeRunner`, () => { .mockImplementationOnce(async (_input: RequestInfo | URL) => Response.json(claims[1]) ) + .mockImplementationOnce(async (_input: RequestInfo | URL) => + Response.json(claims[2]) + ) vi.stubGlobal(`fetch`, fetchMock) const testRuntime = runtime() const streamFactory = vi.fn(async () => ({ @@ -322,10 +325,10 @@ describe(`createPullWakeRunner`, () => { firstClaimResponse.resolve(Response.json(claims[0])) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(3) }) await waitFor(() => { - expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(2) + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(3) }) await runner.stop() @@ -375,8 +378,9 @@ describe(`createPullWakeRunner`, () => { await waitFor(() => { expect(requestGenerations).toEqual([1, 2]) }) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(requestGenerations).toEqual([1, 2]) + await waitFor(() => { + expect(requestGenerations).toEqual([1, 2, 3]) + }) await runner.stop() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd0285340..50e1054643 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1667,6 +1667,9 @@ importers: vite-plugin-electron: specifier: ^0.29.1 version: 0.29.1 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.17)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.1)(terser@5.48.0) wait-on: specifier: ^9.0.1 version: 9.0.5 From 356d11034d1983ab58b34ce3a543b82ecce3ba0d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:07:32 -0600 Subject: [PATCH 091/105] fix(agents-runtime): reconnect stalled pull-wake streams --- .../agents-runtime/src/pull-wake-runner.ts | 8 +++ .../test/pull-wake-runner.test.ts | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/agents-runtime/src/pull-wake-runner.ts b/packages/agents-runtime/src/pull-wake-runner.ts index 6ae5391503..b3345f24b9 100644 --- a/packages/agents-runtime/src/pull-wake-runner.ts +++ b/packages/agents-runtime/src/pull-wake-runner.ts @@ -63,6 +63,7 @@ export interface PullWakeStreamResponse { export interface PullWakeRunner { start: () => void + reconnect: () => void stop: () => Promise waitForStopped: () => Promise readonly running: boolean @@ -667,6 +668,13 @@ export function createPullWakeRunner( startHeartbeat(controller.signal) actor.send({ type: `START` }) }, + reconnect() { + if (!isRunningState()) return + actor.send({ + type: `STREAM_RESET`, + error: new Error(`Pull-wake runner reconnect requested`), + }) + }, async stop() { if (stopPromise) return stopPromise if (actor.getSnapshot().matches(`stopped`)) return diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index d6d7bff566..fd62152df7 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -123,6 +123,69 @@ describe(`createPullWakeRunner`, () => { await runner.stop() }) + it(`explicitly reconnects a stalled stream without aborting active wakes`, async () => { + vi.useFakeTimers() + const firstCancelled = deferred() + const secondCancelled = deferred() + let firstOffset = `-1` + const firstResponse: PullWakeStreamResponse = { + get offset() { + return firstOffset + }, + async *jsonStream() { + firstOffset = `42` + yield wakeEvent(`one`) + await firstCancelled.promise + }, + cancel: vi.fn(() => firstCancelled.resolve()), + closed: firstCancelled.promise, + } + const secondResponse: PullWakeStreamResponse = { + offset: `42`, + async *jsonStream() { + await secondCancelled.promise + }, + cancel: vi.fn(() => secondCancelled.resolve()), + closed: secondCancelled.promise, + } + const streamFactory = vi + .fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse) + vi.stubGlobal( + `fetch`, + vi.fn(async () => new Response(null, { status: 204 })) + ) + const testRuntime = runtime() + const runner = createPullWakeRunner({ + baseUrl: `http://server`, + runnerId: `runner-1`, + runtime: testRuntime, + heartbeatIntervalMs: 0, + streamFactory, + }) + + runner.start() + await vi.waitFor(() => expect(runner.offset).toBe(`42`)) + + runner.reconnect() + runner.reconnect() + await vi.waitFor(() => + expect(firstResponse.cancel).toHaveBeenCalledTimes(1) + ) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(streamFactory).toHaveBeenCalledTimes(2)) + + expect(streamFactory).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ offset: `42` }) + ) + expect(testRuntime.abortWakes).not.toHaveBeenCalled() + expect(testRuntime.drainWakes).not.toHaveBeenCalled() + + await runner.stop() + }) + it(`claims compact DS wake events before dispatching runtime wakes`, async () => { const event = wakeEvent(`one`) const claimed = notification(`one`) From e62ce04502b9719b31e4e5b44394e979c31a17f3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:10:10 -0600 Subject: [PATCH 092/105] test(agents-runtime): cover active wake reconnect --- .../test/pull-wake-runner.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index fd62152df7..ddb45587c3 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -152,11 +152,24 @@ describe(`createPullWakeRunner`, () => { .fn() .mockResolvedValueOnce(firstResponse) .mockResolvedValueOnce(secondResponse) + const activeWakeReleased = deferred() + const activeWakeCompleted = vi.fn() + let activeWake = false vi.stubGlobal( `fetch`, - vi.fn(async () => new Response(null, { status: 204 })) + vi.fn(async () => Response.json(notification(`one`))) ) - const testRuntime = runtime() + const testRuntime = { + ...runtime(), + dispatchWake: vi.fn(() => { + activeWake = true + void activeWakeReleased.promise.then(() => { + activeWake = false + activeWakeCompleted() + }) + }), + isWakeActive: vi.fn(() => activeWake), + } const runner = createPullWakeRunner({ baseUrl: `http://server`, runnerId: `runner-1`, @@ -167,6 +180,10 @@ describe(`createPullWakeRunner`, () => { runner.start() await vi.waitFor(() => expect(runner.offset).toBe(`42`)) + await vi.waitFor(() => + expect(testRuntime.dispatchWake).toHaveBeenCalledTimes(1) + ) + expect(testRuntime.isWakeActive(`/chat/one/main`)).toBe(true) runner.reconnect() runner.reconnect() @@ -182,6 +199,12 @@ describe(`createPullWakeRunner`, () => { ) expect(testRuntime.abortWakes).not.toHaveBeenCalled() expect(testRuntime.drainWakes).not.toHaveBeenCalled() + expect(testRuntime.isWakeActive(`/chat/one/main`)).toBe(true) + expect(activeWakeCompleted).not.toHaveBeenCalled() + + activeWakeReleased.resolve() + await vi.waitFor(() => expect(activeWakeCompleted).toHaveBeenCalledTimes(1)) + expect(testRuntime.isWakeActive(`/chat/one/main`)).toBe(false) await runner.stop() }) From 3a65584bc7bfedd2339c834f1df9716bc363c8de Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:14:18 -0600 Subject: [PATCH 093/105] feat(agents): expose pull-wake reconnect --- packages/agents/src/server.ts | 5 ++ .../builtin-pull-wake-registration.test.ts | 59 +++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/agents/src/server.ts b/packages/agents/src/server.ts index 9a2af52e8c..a923e2eebb 100644 --- a/packages/agents/src/server.ts +++ b/packages/agents/src/server.ts @@ -114,6 +114,11 @@ export class BuiltinAgentsServer { return this._mcpRegistry } + /** Reconnect only the pull-wake subscription, preserving active wake work. */ + reconnectPullWake(): void { + this.pullWakeRunner?.reconnect() + } + /** * Replace the in-memory `extras` list and re-apply the merged config * against the last-known workspace `mcp.json` state. Workspace diff --git a/packages/agents/test/builtin-pull-wake-registration.test.ts b/packages/agents/test/builtin-pull-wake-registration.test.ts index 524728ccd9..9d3821a3cd 100644 --- a/packages/agents/test/builtin-pull-wake-registration.test.ts +++ b/packages/agents/test/builtin-pull-wake-registration.test.ts @@ -1,15 +1,20 @@ import { createServer, type IncomingMessage, type Server } from 'node:http' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BuiltinAgentsServer } from '../src/server' +const pullWakeRunnerMocks = vi.hoisted(() => ({ + start: vi.fn(), + reconnect: vi.fn(), + stop: vi.fn(async () => {}), + waitForStopped: vi.fn(async () => {}), +})) + vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { const actual = await importOriginal() return { ...(actual as object), createPullWakeRunner: vi.fn(() => ({ - start: vi.fn(), - stop: vi.fn(async () => {}), - waitForStopped: vi.fn(async () => {}), + ...pullWakeRunnerMocks, get running() { return false }, @@ -79,6 +84,13 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { ReturnType > | null + beforeEach(() => { + pullWakeRunnerMocks.start.mockClear() + pullWakeRunnerMocks.reconnect.mockClear() + pullWakeRunnerMocks.stop.mockClear() + pullWakeRunnerMocks.waitForStopped.mockClear() + }) + afterEach(async () => { await builtinServer?.stop() builtinServer = null @@ -91,6 +103,7 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { builtinServer = new BuiltinAgentsServer({ agentServerUrl: agentsServer.url, mockStreamFn, + durableStreamsFetchCache: false, pullWake: { runnerId: `test-runner` }, }) @@ -109,6 +122,7 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { builtinServer = new BuiltinAgentsServer({ agentServerUrl: agentsServer.url, mockStreamFn, + durableStreamsFetchCache: false, pullWake: { runnerId: `test-runner` }, }) @@ -147,6 +161,7 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { builtinServer = new BuiltinAgentsServer({ agentServerUrl: `${agentsServer.url}/t/svc-agent-1/v1`, mockStreamFn, + durableStreamsFetchCache: false, pullWake: { runnerId: `test-runner`, registerRunner: true }, }) @@ -159,4 +174,40 @@ describe(`BuiltinAgentsServer pull-wake registration`, () => { `/t/svc-agent-1/v1/_electric/runners` ) }) + + it(`reconnects the pull-wake runner without restarting the built-in runtime`, async () => { + agentsServer = await startRecordingAgentsServer() + builtinServer = new BuiltinAgentsServer({ + agentServerUrl: agentsServer.url, + mockStreamFn, + durableStreamsFetchCache: false, + pullWake: { runnerId: `test-runner` }, + }) + await builtinServer.start() + + builtinServer.reconnectPullWake() + + expect(pullWakeRunnerMocks.reconnect).toHaveBeenCalledTimes(1) + expect(pullWakeRunnerMocks.stop).not.toHaveBeenCalled() + }) + + it(`treats pull-wake reconnect as a no-op outside the running lifecycle`, async () => { + agentsServer = await startRecordingAgentsServer() + builtinServer = new BuiltinAgentsServer({ + agentServerUrl: agentsServer.url, + mockStreamFn, + durableStreamsFetchCache: false, + pullWake: { runnerId: `test-runner` }, + }) + + builtinServer.reconnectPullWake() + expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() + + await builtinServer.start() + await builtinServer.stop() + pullWakeRunnerMocks.reconnect.mockClear() + builtinServer.reconnectPullWake() + + expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() + }) }) From 9968811e106ce6c4f035ded27a5ae27c8a035574 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:17:58 -0600 Subject: [PATCH 094/105] fix(agents-desktop): select runtimes for resume recovery --- .../src/runtime/lifecycle.test.ts | 112 +++++++++++++++++- .../agents-desktop/src/runtime/lifecycle.ts | 25 ++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/packages/agents-desktop/src/runtime/lifecycle.test.ts b/packages/agents-desktop/src/runtime/lifecycle.test.ts index 0bde5d03ab..11afd25afb 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.test.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.test.ts @@ -3,7 +3,9 @@ import type { RuntimeEntry, ServerConfig } from '../shared/types' import type { RuntimeLifecycleDeps } from './lifecycle' vi.mock(`@electric-ax/agents`, () => ({ - BuiltinAgentsServer: vi.fn(), + BuiltinAgentsServer: vi.fn(() => ({ + reconnectPullWake: vi.fn(), + })), })) vi.mock(`electron`, () => ({ @@ -23,7 +25,53 @@ vi.mock(`./health`, async (importOriginal) => { } }) -import { startRuntime } from './lifecycle' +import { reconnectPullWakesAfterResume, startRuntime } from './lifecycle' + +type AddedRuntimeOptions = { + id: string + desiredState: ServerConfig[`desiredState`] + localRuntimeEnabled: boolean + localRuntimeStatus: RuntimeEntry[`localRuntimeStatus`] +} + +function addRuntime( + deps: RuntimeLifecycleDeps, + options: AddedRuntimeOptions +): { + reconnect: ReturnType + server: ServerConfig + entry: RuntimeEntry +} { + const reconnect = vi.fn() + const server: ServerConfig = { + id: options.id, + name: options.id, + url: `http://localhost/${options.id}`, + source: `manual`, + desiredState: options.desiredState, + localRuntimeEnabled: options.localRuntimeEnabled, + } + const entry: RuntimeEntry = { + serverId: server.id, + desiredState: options.desiredState, + status: options.desiredState === `connected` ? `connected` : `disconnected`, + localRuntimeStatus: options.localRuntimeStatus, + runtime: { + reconnectPullWake: reconnect, + } as unknown as RuntimeEntry[`runtime`], + runtimeUrl: null, + runtimeError: null, + reconnectTimer: null, + reconnectAttempt: 0, + generation: 0, + lastError: null, + lastConnectedAt: null, + mcpUnsubscribe: null, + } + deps.settings.servers.push(server) + deps.runtimeEntries.set(server.id, entry) + return { reconnect, server, entry } +} function setup(): { deps: RuntimeLifecycleDeps @@ -60,7 +108,9 @@ function setup(): { }, runtimeEntries: new Map([[server.id, entry]]), windowSelections: new Map(), - findServer: (serverId) => (serverId === server.id ? server : null), + findServer: (serverId) => + deps.settings.servers.find((candidate) => candidate.id === serverId) ?? + null, ensureRuntimeEntry: () => entry, saveSettings: vi.fn(async () => undefined), refreshDesktopState: vi.fn(), @@ -83,6 +133,62 @@ describe(`desktop runtime lifecycle`, () => { vi.useFakeTimers() }) + it(`reconnects only connected running local runtimes after resume`, () => { + const { deps, entry } = setup() + const reconnectRunning = vi.fn() + entry.status = `connected` + entry.localRuntimeStatus = `running` + entry.runtime = { + reconnectPullWake: reconnectRunning, + } as unknown as RuntimeEntry[`runtime`] + + const disconnected = addRuntime(deps, { + id: `disconnected`, + desiredState: `disconnected`, + localRuntimeEnabled: true, + localRuntimeStatus: `running`, + }) + const disabled = addRuntime(deps, { + id: `disabled`, + desiredState: `connected`, + localRuntimeEnabled: false, + localRuntimeStatus: `disabled`, + }) + const starting = addRuntime(deps, { + id: `starting`, + desiredState: `connected`, + localRuntimeEnabled: true, + localRuntimeStatus: `starting`, + }) + + reconnectPullWakesAfterResume(deps) + + expect(reconnectRunning).toHaveBeenCalledTimes(1) + expect(disconnected.reconnect).not.toHaveBeenCalled() + expect(disabled.reconnect).not.toHaveBeenCalled() + expect(starting.reconnect).not.toHaveBeenCalled() + }) + + it(`isolates pull-wake reconnect failures between runtimes`, () => { + const { deps, entry } = setup() + entry.status = `connected` + entry.localRuntimeStatus = `running` + entry.runtime = { + reconnectPullWake: vi.fn(() => { + throw new Error(`first failed`) + }), + } as unknown as RuntimeEntry[`runtime`] + const second = addRuntime(deps, { + id: `second`, + desiredState: `connected`, + localRuntimeEnabled: true, + localRuntimeStatus: `running`, + }) + + expect(() => reconnectPullWakesAfterResume(deps)).not.toThrow() + expect(second.reconnect).toHaveBeenCalledTimes(1) + }) + it(`records and retries failures that happen before the built-in runtime starts`, async () => { const { deps, entry } = setup() diff --git a/packages/agents-desktop/src/runtime/lifecycle.ts b/packages/agents-desktop/src/runtime/lifecycle.ts index b17fd21aad..a70aa7d5f1 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.ts @@ -118,6 +118,31 @@ export function refreshPowerSaveBlocker(deps: RuntimeLifecycleDeps): void { powerSaveBlockerId = null } +export function reconnectPullWakesAfterResume( + deps: RuntimeLifecycleDeps +): void { + for (const entry of deps.runtimeEntries.values()) { + const server = deps.findServer(entry.serverId) + if ( + !server?.localRuntimeEnabled || + entry.desiredState !== `connected` || + entry.localRuntimeStatus !== `running` || + !entry.runtime + ) { + continue + } + + try { + entry.runtime.reconnectPullWake() + } catch (error) { + console.warn( + `[agents-desktop] Failed to reconnect pull-wake after resume for ${entry.serverId}:`, + error + ) + } + } +} + export async function restartConnectedRuntimes( deps: RuntimeLifecycleDeps ): Promise { From 2da64922a8a2baa2fcc1b99260bb620542b13a03 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:20:22 -0600 Subject: [PATCH 095/105] test(agents-desktop): fix lifecycle type import --- packages/agents-desktop/src/runtime/lifecycle.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agents-desktop/src/runtime/lifecycle.test.ts b/packages/agents-desktop/src/runtime/lifecycle.test.ts index 11afd25afb..bdc743fd1a 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.test.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.test.ts @@ -18,6 +18,7 @@ vi.mock(`electron`, () => ({ })) vi.mock(`./health`, async (importOriginal) => { + // prettier-ignore const actual = await importOriginal() return { ...actual, From b310ed937cb0293a256d1d7f9b8f08ae1ccf75e3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:20:53 -0600 Subject: [PATCH 096/105] test(agents-desktop): preserve valid type import literal --- packages/agents-desktop/src/runtime/lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-desktop/src/runtime/lifecycle.test.ts b/packages/agents-desktop/src/runtime/lifecycle.test.ts index bdc743fd1a..d58540e372 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.test.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.test.ts @@ -19,7 +19,7 @@ vi.mock(`electron`, () => ({ vi.mock(`./health`, async (importOriginal) => { // prettier-ignore - const actual = await importOriginal() + const actual = await importOriginal() // eslint-disable-line quotes return { ...actual, checkAgentsServerHealth: vi.fn(async () => ({ ok: true })), From 4560f59d7301dd7187e8b7769b81c2f9d588f1e3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:24:06 -0600 Subject: [PATCH 097/105] feat(agents-desktop): monitor system resume --- .../src/app/power-monitor.test.ts | 52 +++++++++++++++++ .../agents-desktop/src/app/power-monitor.ts | 56 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/agents-desktop/src/app/power-monitor.test.ts create mode 100644 packages/agents-desktop/src/app/power-monitor.ts diff --git a/packages/agents-desktop/src/app/power-monitor.test.ts b/packages/agents-desktop/src/app/power-monitor.test.ts new file mode 100644 index 0000000000..0e46c5b9d3 --- /dev/null +++ b/packages/agents-desktop/src/app/power-monitor.test.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +vi.mock(`electron`, () => ({ + powerMonitor: new EventEmitter(), +})) + +import { createPowerMonitorRecovery } from './power-monitor' + +class FakePowerMonitor extends EventEmitter { + listenerCountFor(event: `suspend` | `resume`): number { + return this.listenerCount(event) + } +} + +describe(`power monitor recovery`, () => { + it(`runs resume recovery once and does not duplicate listeners`, () => { + const monitor = new FakePowerMonitor() + const onResume = vi.fn() + const recovery = createPowerMonitorRecovery({ monitor, onResume }) + + recovery.start() + recovery.start() + expect(monitor.listenerCountFor(`suspend`)).toBe(1) + expect(monitor.listenerCountFor(`resume`)).toBe(1) + + monitor.emit(`suspend`) + monitor.emit(`resume`) + expect(onResume).toHaveBeenCalledTimes(1) + + recovery.stop() + expect(monitor.listenerCountFor(`suspend`)).toBe(0) + expect(monitor.listenerCountFor(`resume`)).toBe(0) + }) + + it(`reports resume callback failures without throwing from the event`, () => { + const monitor = new FakePowerMonitor() + const onError = vi.fn() + const recovery = createPowerMonitorRecovery({ + monitor, + onResume: () => { + throw new Error(`resume failed`) + }, + onError, + }) + recovery.start() + + expect(() => monitor.emit(`resume`)).not.toThrow() + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: `resume failed` }) + ) + }) +}) diff --git a/packages/agents-desktop/src/app/power-monitor.ts b/packages/agents-desktop/src/app/power-monitor.ts new file mode 100644 index 0000000000..1c3eaa5724 --- /dev/null +++ b/packages/agents-desktop/src/app/power-monitor.ts @@ -0,0 +1,56 @@ +import { powerMonitor } from 'electron' + +export type PowerMonitorLike = { + on: (event: `suspend` | `resume`, listener: () => void) => unknown + removeListener: (event: `suspend` | `resume`, listener: () => void) => unknown +} + +export type PowerMonitorRecovery = { + start: () => void + stop: () => void +} + +export function createPowerMonitorRecovery(options: { + monitor?: PowerMonitorLike + onResume: () => void + onError?: (error: Error) => void +}): PowerMonitorRecovery { + const monitor = options.monitor ?? powerMonitor + let started = false + let suspendedAt: number | null = null + + const handleSuspend = (): void => { + suspendedAt = Date.now() + console.info(`[agents-desktop] System suspended.`) + } + const handleResume = (): void => { + const elapsedMs = suspendedAt === null ? null : Date.now() - suspendedAt + suspendedAt = null + console.info( + `[agents-desktop] System resumed${elapsedMs === null ? `` : ` after ${elapsedMs}ms`}; reconnecting pull-wake streams.` + ) + try { + options.onResume() + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)) + if (options.onError) options.onError(error) + else console.warn(`[agents-desktop] Resume recovery failed:`, error) + } + } + + return { + start(): void { + if (started) return + started = true + monitor.on(`suspend`, handleSuspend) + monitor.on(`resume`, handleResume) + }, + stop(): void { + if (!started) return + started = false + monitor.removeListener(`suspend`, handleSuspend) + monitor.removeListener(`resume`, handleResume) + suspendedAt = null + }, + } +} From 9bee418eace6e202127798b4eadaf98a92b41f05 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:30:17 -0600 Subject: [PATCH 098/105] fix(agents-desktop): reconnect pull wakes on resume --- .../reliable-repeated-wakes-and-desktop-restart.md | 2 ++ packages/agents-desktop/src/app/controller.ts | 12 +++++++++++- .../agents-desktop/src/app/power-monitor.test.ts | 14 ++++++++++++++ packages/agents-desktop/src/main.ts | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.changeset/reliable-repeated-wakes-and-desktop-restart.md b/.changeset/reliable-repeated-wakes-and-desktop-restart.md index e956973ec5..43b9874e2c 100644 --- a/.changeset/reliable-repeated-wakes-and-desktop-restart.md +++ b/.changeset/reliable-repeated-wakes-and-desktop-restart.md @@ -6,3 +6,5 @@ Preserve every deferred same-stream pull-wake notification instead of coalescing later generations, so repeated child completions reliably wake their parent. Make desktop runtime startup failures observable and retryable even when they occur before the built-in runtime instance starts, allowing configured agents to recover after relaunch. + +Reconnect the pull-wake Durable Streams subscription when Electron reports system resume, preserving active agent work while replacing stale long-poll connections. diff --git a/packages/agents-desktop/src/app/controller.ts b/packages/agents-desktop/src/app/controller.ts index 387e55d928..4c671ae8b1 100644 --- a/packages/agents-desktop/src/app/controller.ts +++ b/packages/agents-desktop/src/app/controller.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import type { DesktopAppContext } from './context' import * as AppLifecycle from './lifecycle' import * as LoginItems from './login-items' +import { createPowerMonitorRecovery } from './power-monitor' import { createDesktopUpdater } from './updater' import * as CloudAuthInjection from '../cloud/auth-injection' import * as ServerFetch from '../cloud/server-fetch' @@ -11,7 +12,10 @@ import { createLocalDiscoveryLoop } from '../discovery/local-discovery' import * as DesktopIpc from '../ipc/register' import { ensureRuntimeEntry as ensureRuntimeEntryInStore } from '../runtime/entries' import { createRuntimeController } from '../runtime/controller' -import { desktopSkillDirectories } from '../runtime/lifecycle' +import { + desktopSkillDirectories, + reconnectPullWakesAfterResume, +} from '../runtime/lifecycle' import * as SettingsBootstrap from '../settings/bootstrap' import * as ServerSelection from '../settings/selection' import { @@ -179,6 +183,10 @@ export function createDesktopMainController(ctx: DesktopAppContext) { selectedServerIdForWindow, }) + const powerMonitorRecovery = createPowerMonitorRecovery({ + onResume: () => reconnectPullWakesAfterResume(runtime.lifecycleDeps), + }) + function hasConnectedLocalRuntime(): boolean { return runtime.hasConnectedLocalRuntime() } @@ -599,9 +607,11 @@ export function createDesktopMainController(ctx: DesktopAppContext) { showOrCreateWindow, syncLaunchAtLoginSetting, connectConfiguredServers, + startPowerMonitorRecovery: powerMonitorRecovery.start, startDiscoveryLoop: localDiscovery.startDiscoveryLoop, initializeUpdater: updater.initialize, quitApp: async (): Promise => { + powerMonitorRecovery.stop() for (const watcher of skillDirectoryWatchers) watcher.close() skillDirectoryWatchers = [] if (skillReloadTimer) clearTimeout(skillReloadTimer) diff --git a/packages/agents-desktop/src/app/power-monitor.test.ts b/packages/agents-desktop/src/app/power-monitor.test.ts index 0e46c5b9d3..11109b11f5 100644 --- a/packages/agents-desktop/src/app/power-monitor.test.ts +++ b/packages/agents-desktop/src/app/power-monitor.test.ts @@ -13,6 +13,20 @@ class FakePowerMonitor extends EventEmitter { } describe(`power monitor recovery`, () => { + it(`routes resume through the supplied runtime recovery boundary`, () => { + const monitor = new FakePowerMonitor() + const reconnectPullWakesAfterResume = vi.fn() + const recovery = createPowerMonitorRecovery({ + monitor, + onResume: reconnectPullWakesAfterResume, + }) + recovery.start() + + monitor.emit(`resume`) + + expect(reconnectPullWakesAfterResume).toHaveBeenCalledTimes(1) + }) + it(`runs resume recovery once and does not duplicate listeners`, () => { const monitor = new FakePowerMonitor() const onResume = vi.fn() diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index 7972220313..664477a7af 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -129,6 +129,7 @@ async function main(): Promise { if (!LoginItems.isBackgroundLaunch()) { controller.createWindow() } + controller.startPowerMonitorRecovery() controller.connectConfiguredServers() controller.startDiscoveryLoop() controller.initializeUpdater() From 7f2302a5d009f0670416311d0c7a328cf9a8fe05 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:34:29 -0600 Subject: [PATCH 099/105] test(agents-runtime): type active wake mock --- packages/agents-runtime/test/pull-wake-runner.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-runtime/test/pull-wake-runner.test.ts b/packages/agents-runtime/test/pull-wake-runner.test.ts index ddb45587c3..a3b7a88d4a 100644 --- a/packages/agents-runtime/test/pull-wake-runner.test.ts +++ b/packages/agents-runtime/test/pull-wake-runner.test.ts @@ -168,7 +168,7 @@ describe(`createPullWakeRunner`, () => { activeWakeCompleted() }) }), - isWakeActive: vi.fn(() => activeWake), + isWakeActive: vi.fn((_streamPath: string) => activeWake), } const runner = createPullWakeRunner({ baseUrl: `http://server`, From cbd13bdfaa62cac1c88461d320f27f039a292239 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 14 Jul 2026 17:38:34 -0600 Subject: [PATCH 100/105] chore(changeset): release agents wake API --- .changeset/reliable-repeated-wakes-and-desktop-restart.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/reliable-repeated-wakes-and-desktop-restart.md b/.changeset/reliable-repeated-wakes-and-desktop-restart.md index 43b9874e2c..defc06e0ce 100644 --- a/.changeset/reliable-repeated-wakes-and-desktop-restart.md +++ b/.changeset/reliable-repeated-wakes-and-desktop-restart.md @@ -1,4 +1,5 @@ --- +'@electric-ax/agents': patch '@electric-ax/agents-runtime': patch '@electric-ax/agents-desktop': patch --- From a6b5b3a7470b0504f0bc832d1d1cd7a95a72f2c0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 15 Jul 2026 16:25:41 -0600 Subject: [PATCH 101/105] Apply code simplification and review fixes --- ...desktop-sleep-resume-pull-wake-recovery.md | 824 ------------------ ...-sleep-resume-pull-wake-recovery-design.md | 78 -- .../agents-desktop/src/runtime/lifecycle.ts | 11 +- 3 files changed, 5 insertions(+), 908 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md delete mode 100644 docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md diff --git a/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md b/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md deleted file mode 100644 index c3c5499e72..0000000000 --- a/docs/superpowers/plans/2026-07-14-desktop-sleep-resume-pull-wake-recovery.md +++ /dev/null @@ -1,824 +0,0 @@ -# Desktop Sleep/Resume Pull-Wake Recovery Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Reconnect only the stale pull-wake Durable Streams subscription when Electron reports system resume, without restarting the built-in runtime or interrupting active wake handlers. - -**Architecture:** Add an idempotent `reconnect()` command to the pull-wake runner's existing XState lifecycle, expose it narrowly as `BuiltinAgentsServer.reconnectPullWake()`, and route Electron `powerMonitor` suspend/resume events through a focused desktop power-monitor controller. Desktop resume selection remains in runtime lifecycle code, where each eligible runtime is reconnected independently. - -**Tech Stack:** TypeScript, XState, Electron `powerMonitor`, Vitest, pnpm workspaces - -## Global Constraints - -- Limit changes to the Electric repository; do not modify the Durable Streams repository or dependency. -- Reconnect the pull-wake stream without restarting `BuiltinAgentsServer`. -- Do not abort active wakes, LLM calls, tool calls, or child runs. -- Preserve the pull-wake runner's current offset across reconnection. -- Repeated or overlapping resume/reconnect signals must not create parallel stream readers. -- Keep the existing heartbeat-gap stream reset as a platform-independent fallback. -- Do not add timeout escalation, runtime health supervision, wake-routing changes, claim-lease changes, or server recovery-policy changes. -- Use Node `>=22.19.0` when installing dependencies because `@earendil-works/pi-agent-core@0.80.5` declares that engine floor. - -## File Structure - -- Modify `packages/agents-runtime/src/pull-wake-runner.ts` — expose an explicit runner reconnect command that delegates to the existing lifecycle machine. -- Modify `packages/agents-runtime/test/pull-wake-runner.test.ts` — prove hung-stream cancellation, offset continuity, idempotency, and active-wake preservation. -- Modify `packages/agents/src/server.ts` — expose a narrow embedder-facing `reconnectPullWake()` method. -- Modify `packages/agents/test/builtin-pull-wake-registration.test.ts` — verify delegation and lifecycle-safe no-op behavior. -- Modify `packages/agents-desktop/src/runtime/lifecycle.ts` — select eligible connected/running local runtimes and isolate reconnect failures. -- Modify `packages/agents-desktop/src/runtime/lifecycle.test.ts` — verify eligibility filtering and per-runtime failure isolation. -- Create `packages/agents-desktop/src/app/power-monitor.ts` — own idempotent Electron suspend/resume listener registration and cleanup. -- Create `packages/agents-desktop/src/app/power-monitor.test.ts` — verify suspend/resume behavior, error reporting, and duplicate-listener prevention. -- Modify `packages/agents-desktop/src/app/controller.ts` — compose the power-monitor controller with runtime lifecycle recovery and stop it during shutdown. -- Modify `packages/agents-desktop/src/main.ts` — start power-monitor recovery after Electron is ready. -- Modify `.changeset/reliable-repeated-wakes-and-desktop-restart.md` — describe explicit sleep/resume pull-wake recovery. - ---- - -### Task 1: Explicit Pull-Wake Runner Reconnect - -**Files:** - -- Modify: `packages/agents-runtime/src/pull-wake-runner.ts:57-65,650-700` -- Test: `packages/agents-runtime/test/pull-wake-runner.test.ts:90-180` - -**Interfaces:** - -- Consumes: existing `PullWakeMachineEvent` `{ type: 'STREAM_RESET'; error: Error }` and the machine's streaming/connecting/reconnecting guards. -- Produces: `PullWakeRunner.reconnect(): void`, safe in every lifecycle state. - -- [ ] **Step 1: Write the failing runner regression test** - -Add a test near the other lifecycle/reconnect tests. It must establish a first response that advances the runner offset and then hangs until `cancel()` is called, issue two reconnect requests, and verify only one replacement stream is created from the committed offset: - -```ts -it(`explicitly reconnects a stalled stream without aborting active wakes`, async () => { - vi.useFakeTimers() - const firstCancelled = deferred() - const secondCancelled = deferred() - let firstOffset = `-1` - const firstResponse: PullWakeStreamResponse = { - get offset() { - return firstOffset - }, - async *jsonStream() { - firstOffset = `42` - yield wakeEvent(`one`) - await firstCancelled.promise - }, - cancel: vi.fn(() => firstCancelled.resolve()), - closed: firstCancelled.promise, - } - const secondResponse: PullWakeStreamResponse = { - offset: `42`, - async *jsonStream() { - await secondCancelled.promise - }, - cancel: vi.fn(() => secondCancelled.resolve()), - closed: secondCancelled.promise, - } - const streamFactory = vi - .fn() - .mockResolvedValueOnce(firstResponse) - .mockResolvedValueOnce(secondResponse) - vi.stubGlobal( - `fetch`, - vi.fn(async () => new Response(null, { status: 204 })) - ) - const testRuntime = runtime() - const runner = createPullWakeRunner({ - baseUrl: `http://server`, - runnerId: `runner-1`, - runtime: testRuntime, - heartbeatIntervalMs: 0, - streamFactory, - }) - - runner.start() - await vi.waitFor(() => expect(runner.offset).toBe(`42`)) - - runner.reconnect() - runner.reconnect() - await vi.waitFor(() => expect(firstResponse.cancel).toHaveBeenCalledTimes(1)) - await vi.advanceTimersByTimeAsync(1_000) - await vi.waitFor(() => expect(streamFactory).toHaveBeenCalledTimes(2)) - - expect(streamFactory).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ offset: `42` }) - ) - expect(testRuntime.abortWakes).not.toHaveBeenCalled() - expect(testRuntime.drainWakes).not.toHaveBeenCalled() - - await runner.stop() -}) -``` - -If `vi.waitFor` and fake timers conflict in this Vitest version, replace each wait with `await Promise.resolve()` plus `await vi.advanceTimersByTimeAsync(...)`; do not weaken the assertions. - -- [ ] **Step 2: Run the focused test to verify RED** - -Run: - -```bash -pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -t "explicitly reconnects" -``` - -Expected: TypeScript/test transform failure or runtime failure because `PullWakeRunner` has no `reconnect()` method. - -- [ ] **Step 3: Add the minimal runner API** - -Extend the interface: - -```ts -export interface PullWakeRunner { - start: () => void - reconnect: () => void - stop: () => Promise - waitForStopped: () => Promise - readonly running: boolean - readonly offset: string | undefined - getHealth: () => PullWakeRunnerHealth -} -``` - -Add the method to the returned object immediately after `start()`: - -```ts -reconnect() { - if (!isRunningState()) return - actor.send({ - type: `STREAM_RESET`, - error: new Error(`Pull-wake runner reconnect requested`), - }) -}, -``` - -Do not call `controller.abort()`, clear deferred events, or invoke runtime shutdown APIs. The existing XState machine ignores repeated reset events once `streamResetError` is set and ignores reset events in `reconnecting`, preventing parallel readers. - -- [ ] **Step 4: Run runner regression and full runner suite to verify GREEN** - -Run: - -```bash -pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -t "explicitly reconnects" -pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -pnpm --dir packages/agents-runtime typecheck -``` - -Expected: focused test passes; all pull-wake runner tests pass; typecheck exits 0. - -- [ ] **Step 5: Commit the runner change** - -```bash -git add packages/agents-runtime/src/pull-wake-runner.ts \ - packages/agents-runtime/test/pull-wake-runner.test.ts -git commit -m "fix(agents-runtime): reconnect stalled pull-wake streams" -``` - ---- - -### Task 2: Built-in Runtime Reconnect Facade - -**Files:** - -- Modify: `packages/agents/src/server.ts:105-120` -- Test: `packages/agents/test/builtin-pull-wake-registration.test.ts:1-35,76-170` - -**Interfaces:** - -- Consumes: `PullWakeRunner.reconnect(): void` from Task 1. -- Produces: `BuiltinAgentsServer.reconnectPullWake(): void`, safe before start, while running, and after stop. - -- [ ] **Step 1: Make the mocked runner observable and write failing facade tests** - -Replace the test's anonymous runner factory with hoisted spies: - -```ts -const pullWakeRunnerMocks = vi.hoisted(() => ({ - start: vi.fn(), - reconnect: vi.fn(), - stop: vi.fn(async () => {}), - waitForStopped: vi.fn(async () => {}), -})) - -vi.mock(`@electric-ax/agents-runtime`, async (importOriginal) => { - const actual = await importOriginal() - return { - ...(actual as object), - createPullWakeRunner: vi.fn(() => ({ - ...pullWakeRunnerMocks, - get running() { - return false - }, - get offset() { - return undefined - }, - })), - } -}) -``` - -Import `beforeEach` from Vitest and reset every shared runner spy before each test so assertions are isolated: - -```ts -beforeEach(() => { - pullWakeRunnerMocks.start.mockClear() - pullWakeRunnerMocks.reconnect.mockClear() - pullWakeRunnerMocks.stop.mockClear() - pullWakeRunnerMocks.waitForStopped.mockClear() -}) -``` - -Then add these tests: - -```ts -it(`reconnects the pull-wake runner without restarting the built-in runtime`, async () => { - agentsServer = await startRecordingAgentsServer() - builtinServer = new BuiltinAgentsServer({ - agentServerUrl: agentsServer.url, - mockStreamFn, - pullWake: { runnerId: `test-runner` }, - }) - await builtinServer.start() - - builtinServer.reconnectPullWake() - - expect(pullWakeRunnerMocks.reconnect).toHaveBeenCalledTimes(1) - expect(pullWakeRunnerMocks.stop).not.toHaveBeenCalled() -}) - -it(`treats pull-wake reconnect as a no-op outside the running lifecycle`, async () => { - agentsServer = await startRecordingAgentsServer() - builtinServer = new BuiltinAgentsServer({ - agentServerUrl: agentsServer.url, - mockStreamFn, - pullWake: { runnerId: `test-runner` }, - }) - - builtinServer.reconnectPullWake() - expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() - - await builtinServer.start() - await builtinServer.stop() - pullWakeRunnerMocks.reconnect.mockClear() - builtinServer.reconnectPullWake() - - expect(pullWakeRunnerMocks.reconnect).not.toHaveBeenCalled() -}) -``` - -- [ ] **Step 2: Run the facade tests to verify RED** - -Run: - -```bash -pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts -t "pull-wake reconnect|reconnects the pull-wake" -``` - -Expected: test transform failure because `BuiltinAgentsServer.reconnectPullWake()` does not exist. - -- [ ] **Step 3: Add the facade method** - -Add beside the `mcpRegistry` getter in `BuiltinAgentsServer`: - -```ts -/** Reconnect only the pull-wake subscription, preserving active wake work. */ -reconnectPullWake(): void { - this.pullWakeRunner?.reconnect() -} -``` - -Do not call `start()`, `stop()`, `runtime.abortWakes()`, or MCP lifecycle methods. - -- [ ] **Step 4: Run facade tests and package verification to verify GREEN** - -Run: - -```bash -pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts -pnpm --dir packages/agents typecheck -``` - -Expected: all tests in the file pass and typecheck exits 0. - -- [ ] **Step 5: Commit the facade change** - -```bash -git add packages/agents/src/server.ts \ - packages/agents/test/builtin-pull-wake-registration.test.ts -git commit -m "feat(agents): expose pull-wake reconnect" -``` - ---- - -### Task 3: Desktop Runtime Resume Selection - -**Files:** - -- Modify: `packages/agents-desktop/src/runtime/lifecycle.ts:115-145` -- Test: `packages/agents-desktop/src/runtime/lifecycle.test.ts:1-105` - -**Interfaces:** - -- Consumes: `RuntimeLifecycleDeps.runtimeEntries`, `RuntimeLifecycleDeps.findServer()`, and `BuiltinAgentsServer.reconnectPullWake(): void` from Task 2. -- Produces: `reconnectPullWakesAfterResume(deps: RuntimeLifecycleDeps): void`. - -- [ ] **Step 1: Generalize the lifecycle test fixture and add failing selection/isolation tests** - -Update the `@electric-ax/agents` mock type surface to include `reconnectPullWake`, and add a fixture helper that can append server/entry pairs. Then add: - -```ts -it(`reconnects only connected running local runtimes after resume`, () => { - const { deps, entry } = setup() - const reconnectRunning = vi.fn() - entry.status = `connected` - entry.localRuntimeStatus = `running` - entry.runtime = { - reconnectPullWake: reconnectRunning, - } as RuntimeEntry[`runtime`] - - const disconnected = addRuntime(deps, { - id: `disconnected`, - desiredState: `disconnected`, - localRuntimeEnabled: true, - localRuntimeStatus: `running`, - }) - const disabled = addRuntime(deps, { - id: `disabled`, - desiredState: `connected`, - localRuntimeEnabled: false, - localRuntimeStatus: `disabled`, - }) - const starting = addRuntime(deps, { - id: `starting`, - desiredState: `connected`, - localRuntimeEnabled: true, - localRuntimeStatus: `starting`, - }) - - reconnectPullWakesAfterResume(deps) - - expect(reconnectRunning).toHaveBeenCalledTimes(1) - expect(disconnected.reconnect).not.toHaveBeenCalled() - expect(disabled.reconnect).not.toHaveBeenCalled() - expect(starting.reconnect).not.toHaveBeenCalled() -}) - -it(`isolates pull-wake reconnect failures between runtimes`, () => { - const { deps, entry } = setup() - entry.status = `connected` - entry.localRuntimeStatus = `running` - entry.runtime = { - reconnectPullWake: vi.fn(() => { - throw new Error(`first failed`) - }), - } as RuntimeEntry[`runtime`] - const second = addRuntime(deps, { - id: `second`, - desiredState: `connected`, - localRuntimeEnabled: true, - localRuntimeStatus: `running`, - }) - - expect(() => reconnectPullWakesAfterResume(deps)).not.toThrow() - expect(second.reconnect).toHaveBeenCalledTimes(1) -}) -``` - -Add this complete fixture helper above the `describe` block: - -```ts -type AddedRuntimeOptions = { - id: string - desiredState: ServerConfig[`desiredState`] - localRuntimeEnabled: boolean - localRuntimeStatus: RuntimeEntry[`localRuntimeStatus`] -} - -function addRuntime( - deps: RuntimeLifecycleDeps, - options: AddedRuntimeOptions -): { - reconnect: ReturnType - server: ServerConfig - entry: RuntimeEntry -} { - const reconnect = vi.fn() - const server: ServerConfig = { - id: options.id, - name: options.id, - url: `http://localhost/${options.id}`, - source: `manual`, - desiredState: options.desiredState, - localRuntimeEnabled: options.localRuntimeEnabled, - } - const entry: RuntimeEntry = { - serverId: server.id, - desiredState: options.desiredState, - status: options.desiredState === `connected` ? `connected` : `disconnected`, - localRuntimeStatus: options.localRuntimeStatus, - runtime: { reconnectPullWake: reconnect } as RuntimeEntry[`runtime`], - runtimeUrl: null, - runtimeError: null, - reconnectTimer: null, - reconnectAttempt: 0, - generation: 0, - lastError: null, - lastConnectedAt: null, - mcpUnsubscribe: null, - } - deps.settings.servers.push(server) - deps.runtimeEntries.set(server.id, entry) - return { reconnect, server, entry } -} -``` - -In `setup()`, define `deps` first and make `findServer` search the mutable settings collection: - -```ts -findServer: (serverId) => - deps.settings.servers.find((candidate) => candidate.id === serverId) ?? null, -``` - -Because the function closes over `deps` but is not called until after initialization completes, this is safe and allows the helper's added servers to be discovered. - -- [ ] **Step 2: Run the desktop lifecycle tests to verify RED** - -Run: - -```bash -pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts -t "after resume|between runtimes" -``` - -Expected: test transform failure because `reconnectPullWakesAfterResume` does not exist. - -- [ ] **Step 3: Implement runtime eligibility and failure isolation** - -Add to `packages/agents-desktop/src/runtime/lifecycle.ts` near `restartConnectedRuntimes`: - -```ts -export function reconnectPullWakesAfterResume( - deps: RuntimeLifecycleDeps -): void { - for (const entry of deps.runtimeEntries.values()) { - const server = deps.findServer(entry.serverId) - if ( - !server?.localRuntimeEnabled || - entry.desiredState !== `connected` || - entry.localRuntimeStatus !== `running` || - !entry.runtime - ) { - continue - } - - try { - entry.runtime.reconnectPullWake() - } catch (error) { - console.warn( - `[agents-desktop] Failed to reconnect pull-wake after resume for ${entry.serverId}:`, - error - ) - } - } -} -``` - -Do not call `startRuntime`, `restartRuntime`, `stopRuntimeEntry`, or mutate entry status. - -- [ ] **Step 4: Run desktop lifecycle verification to verify GREEN** - -Run: - -```bash -pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts -pnpm --dir packages/agents-desktop typecheck -``` - -Expected: all lifecycle tests pass and typecheck exits 0. - -- [ ] **Step 5: Commit runtime selection** - -```bash -git add packages/agents-desktop/src/runtime/lifecycle.ts \ - packages/agents-desktop/src/runtime/lifecycle.test.ts \ - packages/agents-desktop/package.json pnpm-lock.yaml -git commit -m "fix(agents-desktop): select runtimes for resume recovery" -``` - ---- - -### Task 4: Idempotent Electron Power-Monitor Controller - -**Files:** - -- Create: `packages/agents-desktop/src/app/power-monitor.ts` -- Test: `packages/agents-desktop/src/app/power-monitor.test.ts` - -**Interfaces:** - -- Consumes: an Electron-compatible monitor with `on('suspend' | 'resume', listener)` and `removeListener(...)`, plus `onResume(): void`. -- Produces: `createPowerMonitorRecovery(options): { start(): void; stop(): void }`. - -- [ ] **Step 1: Write failing power-monitor controller tests** - -Create `packages/agents-desktop/src/app/power-monitor.test.ts` with a small fake event emitter: - -```ts -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import { createPowerMonitorRecovery } from './power-monitor' - -class FakePowerMonitor extends EventEmitter { - listenerCountFor(event: `suspend` | `resume`): number { - return this.listenerCount(event) - } -} - -describe(`power monitor recovery`, () => { - it(`runs resume recovery once and does not duplicate listeners`, () => { - const monitor = new FakePowerMonitor() - const onResume = vi.fn() - const recovery = createPowerMonitorRecovery({ monitor, onResume }) - - recovery.start() - recovery.start() - expect(monitor.listenerCountFor(`suspend`)).toBe(1) - expect(monitor.listenerCountFor(`resume`)).toBe(1) - - monitor.emit(`suspend`) - monitor.emit(`resume`) - expect(onResume).toHaveBeenCalledTimes(1) - - recovery.stop() - expect(monitor.listenerCountFor(`suspend`)).toBe(0) - expect(monitor.listenerCountFor(`resume`)).toBe(0) - }) - - it(`reports resume callback failures without throwing from the event`, () => { - const monitor = new FakePowerMonitor() - const onError = vi.fn() - const recovery = createPowerMonitorRecovery({ - monitor, - onResume: () => { - throw new Error(`resume failed`) - }, - onError, - }) - recovery.start() - - expect(() => monitor.emit(`resume`)).not.toThrow() - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: `resume failed` }) - ) - }) -}) -``` - -- [ ] **Step 2: Run the power-monitor tests to verify RED** - -Run: - -```bash -pnpm --dir packages/agents-desktop test --run src/app/power-monitor.test.ts -``` - -Expected: module resolution failure because `./power-monitor` does not exist. - -- [ ] **Step 3: Implement the focused power-monitor controller** - -Create `packages/agents-desktop/src/app/power-monitor.ts`: - -```ts -import { powerMonitor } from 'electron' - -export type PowerMonitorLike = { - on: (event: `suspend` | `resume`, listener: () => void) => unknown - removeListener: (event: `suspend` | `resume`, listener: () => void) => unknown -} - -export type PowerMonitorRecovery = { - start: () => void - stop: () => void -} - -export function createPowerMonitorRecovery(options: { - monitor?: PowerMonitorLike - onResume: () => void - onError?: (error: Error) => void -}): PowerMonitorRecovery { - const monitor = options.monitor ?? powerMonitor - let started = false - let suspendedAt: number | null = null - - const handleSuspend = (): void => { - suspendedAt = Date.now() - console.info(`[agents-desktop] System suspended.`) - } - const handleResume = (): void => { - const elapsedMs = suspendedAt === null ? null : Date.now() - suspendedAt - suspendedAt = null - console.info( - `[agents-desktop] System resumed${elapsedMs === null ? `` : ` after ${elapsedMs}ms`}; reconnecting pull-wake streams.` - ) - try { - options.onResume() - } catch (cause) { - const error = cause instanceof Error ? cause : new Error(String(cause)) - if (options.onError) options.onError(error) - else console.warn(`[agents-desktop] Resume recovery failed:`, error) - } - } - - return { - start(): void { - if (started) return - started = true - monitor.on(`suspend`, handleSuspend) - monitor.on(`resume`, handleResume) - }, - stop(): void { - if (!started) return - started = false - monitor.removeListener(`suspend`, handleSuspend) - monitor.removeListener(`resume`, handleResume) - suspendedAt = null - }, - } -} -``` - -The callback is synchronous because `reconnectPullWake()` only signals an XState actor. Keep asynchronous restart/escalation out of this controller. - -- [ ] **Step 4: Run power-monitor verification to verify GREEN** - -Run: - -```bash -pnpm --dir packages/agents-desktop test --run src/app/power-monitor.test.ts -pnpm --dir packages/agents-desktop typecheck -``` - -Expected: both power-monitor tests pass and typecheck exits 0. - -- [ ] **Step 5: Commit the power-monitor controller** - -```bash -git add packages/agents-desktop/src/app/power-monitor.ts \ - packages/agents-desktop/src/app/power-monitor.test.ts -git commit -m "feat(agents-desktop): monitor system resume" -``` - ---- - -### Task 5: Wire Resume Recovery Into Desktop Startup and Shutdown - -**Files:** - -- Modify: `packages/agents-desktop/src/app/controller.ts:1-70,300-330,575-625` -- Modify: `packages/agents-desktop/src/main.ts:95-135` -- Modify: `.changeset/reliable-repeated-wakes-and-desktop-restart.md` - -**Interfaces:** - -- Consumes: `createPowerMonitorRecovery()` from Task 4 and `reconnectPullWakesAfterResume()` from Task 3. -- Produces: `DesktopMainController.startPowerMonitorRecovery(): void`; shutdown removes registered listeners. - -- [ ] **Step 1: Add a composition-level test assertion before wiring** - -Extend `packages/agents-desktop/src/app/power-monitor.test.ts` with a test using a real callback that invokes a spy named like the lifecycle operation: - -```ts -it(`routes resume through the supplied runtime recovery boundary`, () => { - const monitor = new FakePowerMonitor() - const reconnectPullWakesAfterResume = vi.fn() - const recovery = createPowerMonitorRecovery({ - monitor, - onResume: reconnectPullWakesAfterResume, - }) - recovery.start() - - monitor.emit(`resume`) - - expect(reconnectPullWakesAfterResume).toHaveBeenCalledTimes(1) -}) -``` - -This passes at the module boundary after Task 4; the RED gate for final wiring is the TypeScript compiler after adding the new controller return member call in `main.ts` before defining it. - -- [ ] **Step 2: Add the startup call first and verify wiring is RED** - -In `packages/agents-desktop/src/main.ts`, after settings/cloud initialization and immediately before `controller.connectConfiguredServers()`, add: - -```ts -controller.startPowerMonitorRecovery() -``` - -Run: - -```bash -pnpm --dir packages/agents-desktop typecheck -``` - -Expected: FAIL because `DesktopMainController` does not yet expose `startPowerMonitorRecovery`. - -- [ ] **Step 3: Compose and expose power-monitor recovery in the controller** - -Import the factory and add the lifecycle function to the existing lifecycle import: - -```ts -import { createPowerMonitorRecovery } from './power-monitor' -import { - desktopSkillDirectories, - reconnectPullWakesAfterResume, -} from '../runtime/lifecycle' -``` - -After `const runtime = createRuntimeController(...)`, construct exactly one process-lifetime recovery object: - -```ts -const powerMonitorRecovery = createPowerMonitorRecovery({ - onResume: () => reconnectPullWakesAfterResume(runtime.lifecycleDeps), -}) -``` - -Add this returned controller method: - -```ts -startPowerMonitorRecovery: powerMonitorRecovery.start, -``` - -At the beginning of the returned `quitApp` method, before stopping runtimes, add: - -```ts -powerMonitorRecovery.stop() -``` - -This keeps listener ownership in the controller and ensures repeated `startPowerMonitorRecovery()` calls remain idempotent through Task 4's controller. - -- [ ] **Step 4: Update the changeset** - -Append to `.changeset/reliable-repeated-wakes-and-desktop-restart.md`: - -```md -Reconnect the pull-wake Durable Streams subscription when Electron reports system resume, preserving active agent work while replacing stale long-poll connections. -``` - -Do not add a Durable Streams package changeset. - -- [ ] **Step 5: Run complete verification** - -Run: - -```bash -pnpm --dir packages/agents-runtime test --run test/pull-wake-runner.test.ts -pnpm --dir packages/agents test --run test/builtin-pull-wake-registration.test.ts -pnpm --dir packages/agents-desktop test --run src/runtime/lifecycle.test.ts src/app/power-monitor.test.ts -pnpm --dir packages/agents-runtime typecheck -pnpm --dir packages/agents typecheck -pnpm --dir packages/agents-desktop typecheck -pnpm prettier --check \ - packages/agents-runtime/src/pull-wake-runner.ts \ - packages/agents-runtime/test/pull-wake-runner.test.ts \ - packages/agents/src/server.ts \ - packages/agents/test/builtin-pull-wake-registration.test.ts \ - packages/agents-desktop/src/runtime/lifecycle.ts \ - packages/agents-desktop/src/runtime/lifecycle.test.ts \ - packages/agents-desktop/src/app/power-monitor.ts \ - packages/agents-desktop/src/app/power-monitor.test.ts \ - packages/agents-desktop/src/app/controller.ts \ - packages/agents-desktop/src/main.ts \ - .changeset/reliable-repeated-wakes-and-desktop-restart.md -git diff --check -``` - -Expected: all focused suites and typechecks pass; Prettier reports all files formatted; `git diff --check` exits 0. - -Also run the agents build because the desktop consumes the built package boundary: - -```bash -./scripts/dev.sh build -``` - -Expected: all agents packages build successfully. If installation fails solely because the shell's Node version is below `22.19.0`, switch to a compliant Node version and rerun; do not bypass the package engine requirement. - -- [ ] **Step 6: Commit final desktop wiring** - -```bash -git add packages/agents-desktop/src/app/controller.ts \ - packages/agents-desktop/src/main.ts \ - packages/agents-desktop/src/app/power-monitor.test.ts \ - .changeset/reliable-repeated-wakes-and-desktop-restart.md -git commit -m "fix(agents-desktop): reconnect pull wakes on resume" -``` - -- [ ] **Step 7: Review branch state without pushing** - -Run: - -```bash -git status --short -git log --oneline --decorate -6 -``` - -Expected: only pre-existing/unrelated intended work remains uncommitted; the implementation commits are local. Do not push implementation commits unless the user explicitly asks. diff --git a/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md b/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md deleted file mode 100644 index 8b925620c0..0000000000 --- a/docs/superpowers/specs/2026-07-14-desktop-sleep-resume-pull-wake-recovery-design.md +++ /dev/null @@ -1,78 +0,0 @@ -# Desktop Sleep/Resume Pull-Wake Recovery - -## Problem - -Closing and reopening a laptop can leave the Node/Electron Durable Streams long-poll used by the pull-wake runner pending on a stale socket. The Durable Streams client retries rejected requests, but a request that never settles gives its retry loop no opportunity to reconnect. Restarting the complete built-in runtime replaces the connection, but also risks interrupting active LLM and tool work. - -This change is limited to the Electric Agents repository. A client-side request deadline in Durable Streams remains appropriate upstream work, but is outside this branch. - -## Goals - -- Reconnect the pull-wake subscription immediately when Electron reports system resume. -- Preserve active wake handlers, LLM calls, tool calls, and child runs. -- Resume the stream from the pull-wake runner's current offset. -- Make repeated or overlapping resume signals safe and idempotent. -- Retain the existing heartbeat-gap detector as a platform-independent fallback. - -## Non-goals - -- Restarting the entire `BuiltinAgentsServer` on every resume. -- Adding timeout escalation or runtime health supervision. -- Modifying the Durable Streams repository or package. -- Changing wake routing, claim leases, or server recovery policy. - -## Design - -### Pull-wake runner - -Expose an explicit reconnect operation on `PullWakeRunner`. Calling it sends the existing state machine a stream-reset event. The state machine aborts the current Durable Streams request and follows its normal reconnect path using the runner's in-memory committed offset. - -The operation does not stop the runner, clear deferred wake notifications, or call `runtime.abortWakes()`. Existing wake handlers continue running. Calls made while stopped are no-ops. Multiple reset requests while a reset is already in progress must not create parallel stream readers. - -The existing elapsed-heartbeat-gap reset remains unchanged and acts as defense in depth for non-Electron runtimes and missed power events. - -### Built-in agents server - -Add a public `reconnectPullWake()` method to `BuiltinAgentsServer`. It delegates to the current pull-wake runner when one exists and otherwise returns without error. This gives embedders a narrow recovery API without exposing runner internals or restarting the rest of the runtime. - -### Desktop lifecycle - -Register Electron `powerMonitor` listeners after `app.whenReady()`: - -- `suspend`: record/log the event only. -- `resume`: iterate current runtime entries and call `reconnectPullWake()` only for entries whose desired state is connected, whose local runtime is enabled/running, and whose runtime instance exists. - -The listener delegates through the desktop controller/runtime lifecycle boundary rather than reaching into context state from `main.ts`. Registration happens once per desktop process. Shutdown removes listeners or relies on a single process-lifetime registration, with tests proving duplicate registration does not occur. - -A resume does not call `startRuntime()` or stop the current runtime. Existing reconnect timers and startup state remain independent. - -## Data flow - -1. macOS wakes after lid-open. -2. Electron emits `powerMonitor.resume`. -3. Desktop lifecycle selects connected local runtime entries. -4. Each `BuiltinAgentsServer.reconnectPullWake()` delegates to its runner. -5. Runner sends `STREAM_RESET` to its state machine. -6. The stale fetch is aborted and the normal reconnect state opens one replacement stream. -7. The replacement request uses the runner's current offset; active wake processing continues independently. - -## Error handling and observability - -The resume handler is best-effort and handles each runtime independently so one failure cannot block others. It logs resume detection and reconnect failures with the server ID. Since reconnect initiation is synchronous/idempotent, no desktop-wide restart or retry policy is added. The runner's existing reconnect backoff and `onError` diagnostics remain authoritative. - -## Testing - -Tests follow red-green regression discipline: - -1. **Pull-wake runner:** begin with a stream whose request remains pending, call explicit reconnect, and assert the request is aborted, exactly one replacement stream opens, and the last committed offset is reused. Repeated reconnect calls must not create parallel streams. Verify active runtime wakes are not aborted. -2. **BuiltinAgentsServer:** verify delegation when running and safe no-op behavior before startup and after shutdown. -3. **Desktop:** mock `powerMonitor`, emit resume, and verify only connected local running entries receive reconnect calls. Cover disconnected, disabled, absent, and starting runtimes, plus one-runtime failure isolation and single listener registration. -4. Keep the heartbeat-gap recovery test to prove the non-Electron fallback remains intact. - -## Acceptance criteria - -- Lid-open triggers pull-wake reconnection without restarting the built-in runtime. -- Active wake handlers remain active. -- Reconnection preserves cursor/offset continuity and opens no parallel stream readers. -- Ineligible desktop runtime entries are untouched. -- All new regression tests fail against the pre-change behavior and pass after implementation. diff --git a/packages/agents-desktop/src/runtime/lifecycle.ts b/packages/agents-desktop/src/runtime/lifecycle.ts index a70aa7d5f1..27b090432d 100644 --- a/packages/agents-desktop/src/runtime/lifecycle.ts +++ b/packages/agents-desktop/src/runtime/lifecycle.ts @@ -401,15 +401,14 @@ async function startRuntimeAttempt( error, activeServer.url ) + const message = + startupNetworkError ?? + (error instanceof Error ? error.message : String(error)) entry.status = `error` entry.localRuntimeStatus = `error` entry.runtimeUrl = null - entry.runtimeError = - startupNetworkError ?? - (error instanceof Error ? error.message : String(error)) - entry.lastError = - startupNetworkError ?? - (error instanceof Error ? error.message : String(error)) + entry.runtimeError = message + entry.lastError = message entry.reconnectAttempt += 1 refreshPowerSaveBlocker(deps) scheduleReconnect(deps, serverId) From 7bb5e74875e39cf8b6db085df33a9679adbbaca8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 15 Jul 2026 16:26:54 -0600 Subject: [PATCH 102/105] chore: complete combined branch changesets --- .changeset/upgrade-pi-agent-core-openai.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/upgrade-pi-agent-core-openai.md b/.changeset/upgrade-pi-agent-core-openai.md index 00b41a6316..c7ce8c54fa 100644 --- a/.changeset/upgrade-pi-agent-core-openai.md +++ b/.changeset/upgrade-pi-agent-core-openai.md @@ -3,7 +3,9 @@ '@electric-ax/agents-runtime': patch '@electric-ax/agents-server': patch '@electric-ax/agents-server-conformance-tests': patch +'@electric-ax/agents-server-ui': patch '@electric-ax/agents-desktop': patch +'electric-ax': patch --- Update the agents runtime to the renamed Earendil Works pi packages and latest OpenAI/Codex model catalog. Preserve custom provider id compatibility, move built-in Codex defaults to GPT-5.6 models, and remove deprecated direct pi-agent-core dependencies from agents examples. From 19a9a6db84989b9b8ef7e9897735ade608fdfa91 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 15 Jul 2026 16:32:14 -0600 Subject: [PATCH 103/105] chore: sync lockfile for agents dependencies --- pnpm-lock.yaml | 50 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50e1054643..7e83607442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1820,8 +1820,8 @@ importers: specifier: ^0.78.0 version: 0.78.0(zod@4.3.6) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -1926,8 +1926,8 @@ importers: specifier: ^0.78.0 version: 0.78.0(zod@4.4.3) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/server': specifier: ^0.3.7 version: 0.3.7(@tanstack/db@0.6.7(typescript@5.8.3)) @@ -1949,6 +1949,12 @@ importers: '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.49 + '@tanstack/db': + specifier: ^0.6.7 + version: 0.6.7(typescript@5.8.3) + '@tanstack/electric-db-collection': + specifier: ^0.3.5 + version: 0.3.5(typescript@5.8.3) '@whatwg-node/server': specifier: ^0.10.18 version: 0.10.18 @@ -2020,10 +2026,10 @@ importers: packages/agents-server-conformance-tests: dependencies: '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@electric-sql/client': - specifier: ^1.5.22 + specifier: ^1.5.21 version: link:../typescript-client fast-check: specifier: ^4.6.0 @@ -2051,8 +2057,8 @@ importers: specifier: ^1.4.1 version: 1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -2169,8 +2175,8 @@ importers: packages/electric-ax: dependencies: '@durable-streams/client': - specifier: ^0.2.6 - version: 0.2.6 + specifier: https://pkg.pr.new/@durable-streams/client@293 + version: https://pkg.pr.new/@durable-streams/client@293 '@durable-streams/state': specifier: ^0.3.1 version: 0.3.1(@tanstack/db@0.6.7(typescript@5.9.3)) @@ -4712,6 +4718,12 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + '@durable-streams/client@https://pkg.pr.new/@durable-streams/client@293': + resolution: {tarball: https://pkg.pr.new/@durable-streams/client@293} + version: 0.2.6 + engines: {node: '>=18.0.0'} + hasBin: true + '@durable-streams/server-conformance-tests@0.3.5': resolution: {integrity: sha512-sXl2S562yVa+gO9ypZMJ3rC9lMpsUoc7XIBw24iY/k8g0RczgKdK3jYpgc10bttZLQLLGQMo3QxZ+pbGgIO5dw==} engines: {node: '>=18.0.0'} @@ -24957,6 +24969,11 @@ snapshots: '@microsoft/fetch-event-source': 2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188) fastq: 1.20.1 + '@durable-streams/client@https://pkg.pr.new/@durable-streams/client@293': + dependencies: + '@microsoft/fetch-event-source': 2.0.1(patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188) + fastq: 1.20.1 + '@durable-streams/server-conformance-tests@0.3.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@durable-streams/client': 0.2.6 @@ -30805,6 +30822,17 @@ snapshots: - supports-color - typescript + '@tanstack/electric-db-collection@0.3.5(typescript@5.8.3)': + dependencies: + '@electric-sql/client': 1.5.20 + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.6.7(typescript@5.8.3) + '@tanstack/store': 0.9.3 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + - typescript + '@tanstack/electric-db-collection@0.3.5(typescript@5.9.3)': dependencies: '@electric-sql/client': 1.5.20 From e492e931f99b40bfe152789f038e69830b0a3503 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 15 Jul 2026 16:38:50 -0600 Subject: [PATCH 104/105] chore: align Expo SDK patch version --- packages/agents-mobile/package.json | 2 +- pnpm-lock.yaml | 1414 +++++++++++++++------------ 2 files changed, 774 insertions(+), 642 deletions(-) diff --git a/packages/agents-mobile/package.json b/packages/agents-mobile/package.json index fd543c25d1..a54d9ca997 100644 --- a/packages/agents-mobile/package.json +++ b/packages/agents-mobile/package.json @@ -25,7 +25,7 @@ "@tanstack/db": "^0.6.5", "@tanstack/electric-db-collection": "^0.3.5", "@tanstack/react-db": "^0.1.85", - "expo": "54.0.35", + "expo": "54.0.36", "expo-build-properties": "~1.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e83607442..4c2ff827b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,7 +787,7 @@ importers: version: link:../../packages/react-hooks next: specifier: ^14.2.5 - version: 14.2.17(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.17(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) pg: specifier: ^8.12.0 version: 8.13.1 @@ -1590,7 +1590,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) packages/agents-desktop: dependencies: @@ -1703,13 +1703,13 @@ importers: version: link:../agents-server-ui '@expo/metro-runtime': specifier: ~6.1.2 - version: 6.1.2(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 6.1.2(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-native-async-storage/async-storage': specifier: ^2.2.0 - version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + version: 2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) '@sentry/react-native': specifier: ~7.2.0 - version: 7.2.0(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 7.2.0(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@tanstack/db': specifier: ^0.6.5 version: 0.6.7(typescript@5.9.3) @@ -1720,41 +1720,41 @@ importers: specifier: ^0.1.85 version: 0.1.85(react@19.1.0)(typescript@5.9.3) expo: - specifier: 54.0.35 - version: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + specifier: 54.0.36 + version: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) expo-build-properties: specifier: ~1.0.10 - version: 1.0.10(expo@54.0.35) + version: 1.0.10(expo@54.0.36) expo-clipboard: specifier: ~8.0.8 - version: 8.0.8(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 8.0.8(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.13 - version: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + version: 18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) expo-image-manipulator: specifier: ~14.0.8 - version: 14.0.8(expo@54.0.35) + version: 14.0.8(expo@54.0.36) expo-image-picker: specifier: ~17.0.11 - version: 17.0.11(expo@54.0.35) + version: 17.0.11(expo@54.0.36) expo-linking: specifier: ~8.0.12 - version: 8.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 8.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.24 - version: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-splash-screen: specifier: ~31.0.13 - version: 31.0.13(expo@54.0.35)(typescript@5.9.3) + version: 31.0.13(expo@54.0.36)(typescript@5.9.3) expo-status-bar: specifier: ~3.0.9 - version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 3.0.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-system-ui: specifier: ~6.0.9 - version: 6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + version: 6.0.9(expo@54.0.36)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) expo-web-browser: specifier: ~15.0.11 - version: 15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + version: 15.0.11(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) react: specifier: 19.1.0 version: 19.1.0 @@ -1763,28 +1763,28 @@ importers: version: 19.1.0(react@19.1.0) react-native: specifier: 0.81.5 - version: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + version: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-gesture-handler: specifier: 2.28.0 - version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-random-uuid: specifier: ^0.1.4 - version: 0.1.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + version: 0.1.4(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) react-native-safe-area-context: specifier: ~5.6.2 - version: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-screens: specifier: 4.16.0 - version: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-svg: specifier: 15.12.1 - version: 15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 15.12.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-web: specifier: ^0.21.2 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-native-webview: specifier: 13.15.0 - version: 13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1803,7 +1803,7 @@ importers: version: 4.1.5(vitest@4.1.5) babel-preset-expo: specifier: ~54.0.10 - version: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.35)(react-refresh@0.14.2) + version: 54.0.10(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@54.0.36)(react-refresh@0.14.2) expo-doctor: specifier: ^1.19.8 version: 1.19.8 @@ -6041,8 +6041,8 @@ packages: resolution: {integrity: sha512-uF1pOVcd+xizNtVTuZqNGzy7I6IJon5YMmQidsURds1Ww96AFDxrR/NEACqeATNAmY60m8wy1VZZpSg5zLNkpw==} hasBin: true - '@expo/cli@54.0.25': - resolution: {integrity: sha512-WnUqIb8oMBhtwSfIqdCHCzcaDIpLNXItRVd5miuvWi4GO0SGo89PSsAkbVJ+LJgcaY+v5rbgMELJS9I/CqOulA==} + '@expo/cli@54.0.26': + resolution: {integrity: sha512-BjsAoKINLEo3LRE+sDC6FCgjxuOWsyfOFOKz0txrbEcxSatzIjJDVuX8XaTdmeicZdcoN524yl1sfwCWfxhYMw==} hasBin: true peerDependencies: expo: '*' @@ -6066,6 +6066,9 @@ packages: '@expo/config-plugins@54.0.4': resolution: {integrity: sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==} + '@expo/config-plugins@54.0.5': + resolution: {integrity: sha512-aWQ3sViNRoQWw6So4A2qhWCt24CuGBK6MrRHI1AG+V6/NQAjIZCHaSvcXK2gXKpmisRhTSUWaKPIgLJQFB+AeQ==} + '@expo/config-types@53.0.5': resolution: {integrity: sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==} @@ -6078,6 +6081,9 @@ packages: '@expo/config@12.0.13': resolution: {integrity: sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==} + '@expo/config@12.0.14': + resolution: {integrity: sha512-3dfbBd9LnPDgyylhCgkOsaG8Adg52uOVOTQYH5lf23a/t8M5eQpXKCvzUarrf62B78057n2NnkiofK9TfPgvzw==} + '@expo/devcert@1.2.0': resolution: {integrity: sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA==} @@ -6101,6 +6107,9 @@ packages: '@expo/env@2.0.11': resolution: {integrity: sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==} + '@expo/env@2.0.12': + resolution: {integrity: sha512-wVfzeBGlUohZG5kS8QCqXurpuWZFJEkBB1wXCifai3EZ/Llcg/VMTiUCpAgHImD3lI7GIU3V1uI64c04XIo98Q==} + '@expo/fingerprint@0.13.4': resolution: {integrity: sha512-MYfPYBTMfrrNr07DALuLhG6EaLVNVrY/PXjEzsjWdWE4ZFn0yqI0IdHNkJG7t1gePT8iztHc7qnsx+oo/rDo6w==} hasBin: true @@ -6130,8 +6139,8 @@ packages: '@expo/metro-config@0.20.17': resolution: {integrity: sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA==} - '@expo/metro-config@54.0.16': - resolution: {integrity: sha512-3LLb9ZQl0VlqSlsalJ7+CYjfz60PBoSDHvpE1UF71aTM1Nx0Vb4LhXo7bCCC+PYP9q/GPB58LLbIROQ8PjKX2w==} + '@expo/metro-config@54.0.17': + resolution: {integrity: sha512-PQFgQCZGY0DffZUvBzJttDPreZfHrQakaBlKjnvOUMNXbDna+TYmg1IFZuIDUYJezLcdp+TvVFTLjNi1+mqaVw==} peerDependencies: expo: '*' peerDependenciesMeta: @@ -6185,6 +6194,11 @@ packages: peerDependencies: expo: '*' + '@expo/prebuild-config@54.0.9': + resolution: {integrity: sha512-3/Rmyzt8vduPjnSVHbnc0wYFrlhwLWn2g596rDyKcLGeqN2WTLJbzVeznsrUwyzhBNXgnTomZWO5HDzbZ/4E7g==} + peerDependencies: + expo: '*' + '@expo/prebuild-config@9.0.11': resolution: {integrity: sha512-0DsxhhixRbCCvmYskBTq8czsU0YOBsntYURhWPNpkl0IPVpeP9haE5W4OwtHGzXEbmHdzaoDwNmVcWjS/mqbDw==} @@ -6199,6 +6213,9 @@ packages: '@expo/schema-utils@0.1.8': resolution: {integrity: sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==} + '@expo/schema-utils@0.1.9': + resolution: {integrity: sha512-t9bYwG4Z0yCVzHYJoDMci1OFq2FkBkhStlfUGSkspKYTwB/84+x6sY+CXCgdhkQNQtvWaugW5KUs9YZfAXq9Sg==} + '@expo/sdk-runtime-versions@1.0.0': resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} @@ -11782,8 +11799,8 @@ packages: expo: optional: true - babel-preset-expo@54.0.11: - resolution: {integrity: sha512-dEpeFDtYEFzmWtWVwvt7sUCZH0fxXPfbJlgXd7XNZSQDa/Ki/hTOj9exMTzqR2oyPHDNcE9VxYCJ4oS6xw4Pjg==} + babel-preset-expo@54.0.12: + resolution: {integrity: sha512-6xeSkdaixmQhWSYL7tfLu0pOS0BY+8ftwmdNSHtpEFSizrXYZkCjk/B6Dxr+6nwNRihixMcS0aBlWS1wlDl3pw==} peerDependencies: '@babel/runtime': ^7.20.0 expo: '*' @@ -14312,8 +14329,8 @@ packages: react-native-webview: optional: true - expo@54.0.35: - resolution: {integrity: sha512-E+tXpQwjGm5fK/uwa55p0Xx/kuo5dXDKfVJ95IargTNa5KiFt26lSTXXa9KnHbI4EDLwFD38/xTKZvzPTlGTdg==} + expo@54.0.36: + resolution: {integrity: sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==} hasBin: true peerDependencies: '@expo/dom-webview': '*' @@ -19974,11 +19991,6 @@ packages: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} - terser@5.46.2: - resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} - engines: {node: '>=10'} - hasBin: true - terser@5.48.0: resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} @@ -22395,12 +22407,12 @@ snapshots: '@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-member-expression-to-functions': 7.27.1 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.0) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -22431,6 +22443,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -22447,13 +22485,13 @@ snapshots: '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.2.0 semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 @@ -22476,17 +22514,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -22511,7 +22538,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -22572,7 +22599,7 @@ snapshots: '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.28.6 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -22581,7 +22608,7 @@ snapshots: '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.28.6 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -22605,6 +22632,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -22637,15 +22682,15 @@ snapshots: '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-wrap-function': 7.25.9 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 '@babel/traverse': 7.29.0 @@ -22675,7 +22720,7 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -22697,6 +22742,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -22748,7 +22811,7 @@ snapshots: '@babel/helper-wrap-function@7.25.9': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -22756,7 +22819,7 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -22877,12 +22940,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.29.0)': + '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -22891,9 +22954,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.26.0)': @@ -22911,51 +22974,51 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.28.0)': @@ -22963,9 +23026,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-export-default-from@7.27.1(@babel/core@7.28.0)': @@ -22973,9 +23036,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-export-default-from@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-syntax-export-default-from@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.0)': @@ -22983,9 +23046,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': @@ -22996,12 +23059,12 @@ snapshots: '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': dependencies: @@ -23011,22 +23074,22 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)': dependencies: @@ -23043,85 +23106,90 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)': dependencies: @@ -23138,6 +23206,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -23147,11 +23220,11 @@ snapshots: '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': @@ -23162,17 +23235,17 @@ snapshots: '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.28.0) - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -23189,18 +23262,18 @@ snapshots: '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.28.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -23221,11 +23294,11 @@ snapshots: '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': @@ -23236,15 +23309,15 @@ snapshots: '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -23257,10 +23330,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -23276,23 +23349,23 @@ snapshots: '@babel/plugin-transform-classes@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.0) - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -23312,12 +23385,12 @@ snapshots: '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.29.7 @@ -23330,21 +23403,21 @@ snapshots: '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.28.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.28.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -23396,9 +23469,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': @@ -23412,23 +23485,23 @@ snapshots: '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.28.0) - '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.7) '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -23445,15 +23518,15 @@ snapshots: '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 @@ -23477,11 +23550,11 @@ snapshots: '@babel/plugin-transform-literals@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': @@ -23492,11 +23565,11 @@ snapshots: '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': @@ -23541,6 +23614,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -23571,12 +23660,12 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.28.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': @@ -23593,11 +23682,11 @@ snapshots: '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': @@ -23608,11 +23697,11 @@ snapshots: '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': @@ -23625,27 +23714,27 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.28.0) - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.28.0)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.28.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 + '@babel/core': 7.28.0 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.28.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -23671,11 +23760,11 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': @@ -23686,14 +23775,14 @@ snapshots: '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -23712,15 +23801,15 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.0)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.28.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': dependencies: @@ -23735,19 +23824,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.28.0)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.28.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.28.0 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -23768,21 +23857,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.28.0)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.28.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -23805,9 +23894,9 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.0)': @@ -23817,10 +23906,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -23839,6 +23928,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -23854,6 +23948,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -23865,13 +23964,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -23882,21 +23981,21 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 regenerator-transform: 0.15.2 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': @@ -23927,14 +24026,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-runtime@7.28.0(@babel/core@7.29.0)': + '@babel/plugin-transform-runtime@7.28.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -23942,11 +24041,11 @@ snapshots: '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': @@ -23957,14 +24056,14 @@ snapshots: '@babel/plugin-transform-spread@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -23981,11 +24080,11 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': @@ -24025,6 +24124,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -24040,12 +24150,12 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.28.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': @@ -24156,15 +24266,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-react@7.27.1(@babel/core@7.29.0)': + '@babel/preset-react@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -24190,6 +24300,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-typescript@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.26.0': dependencies: regenerator-runtime: 0.14.1 @@ -24260,8 +24381,8 @@ snapshots: '@babel/traverse@7.28.5': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 @@ -26172,23 +26293,23 @@ snapshots: - supports-color - utf-8-validate - '@expo/cli@54.0.25(expo-router@6.0.24)(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3)': + '@expo/cli@54.0.26(expo-router@6.0.24)(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3)': dependencies: '@0no-co/graphql.web': 1.1.2 '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 12.0.13 - '@expo/config-plugins': 54.0.4 + '@expo/config': 12.0.14 + '@expo/config-plugins': 54.0.5 '@expo/devcert': 1.2.1 - '@expo/env': 2.0.11 + '@expo/env': 2.0.12 '@expo/image-utils': 0.8.14(typescript@5.9.3) '@expo/json-file': 10.2.0 '@expo/metro': 54.2.0 - '@expo/metro-config': 54.0.16(expo@54.0.35) + '@expo/metro-config': 54.0.17(expo@54.0.36) '@expo/osascript': 2.4.4 '@expo/package-manager': 1.10.5 '@expo/plist': 0.4.9 - '@expo/prebuild-config': 54.0.8(expo@54.0.35)(typescript@5.9.3) - '@expo/schema-utils': 0.1.8 + '@expo/prebuild-config': 54.0.9(expo@54.0.36)(typescript@5.9.3) + '@expo/schema-utils': 0.1.9 '@expo/spawn-async': 1.7.2 '@expo/ws-tunnel': 1.0.6 '@expo/xcpretty': 4.3.2 @@ -26206,7 +26327,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 env-editor: 0.4.2 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) expo-server: 1.0.7 freeport-async: 2.0.0 getenv: 2.0.0 @@ -26239,8 +26360,8 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.20.0 optionalDependencies: - expo-router: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-router: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - bufferutil - graphql @@ -26250,7 +26371,7 @@ snapshots: '@expo/code-signing-certificates@0.0.5': dependencies: - node-forge: 1.3.1 + node-forge: 1.4.0 nullthrows: 1.1.1 '@expo/code-signing-certificates@0.0.6': @@ -26295,6 +26416,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/config-plugins@54.0.5': + dependencies: + '@expo/config-types': 54.0.10 + '@expo/json-file': 10.0.16 + '@expo/plist': 0.4.9 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slash: 3.0.0 + slugify: 1.6.6 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + '@expo/config-types@53.0.5': {} '@expo/config-types@54.0.10': {} @@ -26335,6 +26475,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/config@12.0.14': + dependencies: + '@babel/code-frame': 7.10.4 + '@expo/config-plugins': 54.0.5 + '@expo/config-types': 54.0.10 + '@expo/json-file': 10.2.0 + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + require-from-string: 2.0.2 + resolve-from: 5.0.0 + resolve-workspace-root: 2.0.0 + semver: 7.7.4 + slugify: 1.6.6 + sucrase: 3.35.1 + transitivePeerDependencies: + - supports-color + '@expo/devcert@1.2.0': dependencies: '@expo/sudo-prompt': 9.3.2 @@ -26350,12 +26508,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@0.1.8(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@expo/devtools@0.1.8(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) '@expo/env@1.0.7': dependencies: @@ -26377,6 +26535,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/env@2.0.12': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.4.5 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + '@expo/fingerprint@0.13.4': dependencies: '@expo/spawn-async': 1.7.2 @@ -26479,17 +26647,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-config@54.0.16(expo@54.0.35)': + '@expo/metro-config@54.0.17(expo@54.0.36)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@expo/config': 12.0.13 - '@expo/env': 2.0.11 + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@expo/config': 12.0.14 + '@expo/env': 2.0.12 '@expo/json-file': 10.0.16 '@expo/metro': 54.2.0 '@expo/spawn-async': 1.7.2 - browserslist: 4.28.2 + browserslist: 4.28.4 chalk: 4.1.2 debug: 4.4.3 dotenv: 16.4.5 @@ -26503,7 +26671,7 @@ snapshots: postcss: 8.4.47 resolve-from: 5.0.0 optionalDependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) transitivePeerDependencies: - bufferutil - supports-color @@ -26513,13 +26681,13 @@ snapshots: dependencies: react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - '@expo/metro-runtime@6.1.2(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@expo/metro-runtime@6.1.2(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: anser: 1.4.10 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) pretty-format: 29.7.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -26591,7 +26759,7 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@54.0.8(expo@54.0.35)(typescript@5.9.3)': + '@expo/prebuild-config@54.0.8(expo@54.0.36)(typescript@5.9.3)': dependencies: '@expo/config': 12.0.13 '@expo/config-plugins': 54.0.4 @@ -26600,7 +26768,24 @@ snapshots: '@expo/json-file': 10.2.0 '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/prebuild-config@54.0.9(expo@54.0.36)(typescript@5.9.3)': + dependencies: + '@expo/config': 12.0.14 + '@expo/config-plugins': 54.0.5 + '@expo/config-types': 54.0.10 + '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/json-file': 10.2.0 + '@react-native/normalize-colors': 0.81.5 + debug: 4.4.3 + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.4 xml2js: 0.6.0 @@ -26626,8 +26811,8 @@ snapshots: '@expo/require-utils@55.0.5(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.0 - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -26635,6 +26820,8 @@ snapshots: '@expo/schema-utils@0.1.8': {} + '@expo/schema-utils@0.1.9': {} + '@expo/sdk-runtime-versions@1.0.0': {} '@expo/server@0.6.3': @@ -26658,11 +26845,11 @@ snapshots: react: 19.1.0 react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - '@expo/vector-icons@15.1.1(expo-font@14.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@expo/vector-icons@15.1.1(expo-font@14.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - expo-font: 14.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-font: 14.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) '@expo/ws-tunnel@1.0.6': {} @@ -29437,10 +29624,10 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))': dependencies: merge-options: 3.0.4 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) '@react-native/assets-registry@0.80.1': {} @@ -29448,16 +29635,16 @@ snapshots: '@react-native/babel-plugin-codegen@0.79.5(@babel/core@7.28.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@react-native/codegen': 0.79.5(@babel/core@7.28.0) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': + '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.7)': dependencies: '@babel/traverse': 7.29.0 - '@react-native/codegen': 0.81.5(@babel/core@7.29.0) + '@react-native/codegen': 0.81.5(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' - supports-color @@ -29483,16 +29670,16 @@ snapshots: '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.28.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.28.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.28.0) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.28.0) '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.28.0) '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.28.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.28.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.28.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.28.0) '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.0) '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -29512,52 +29699,52 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/babel-preset@0.81.5(@babel/core@7.29.0)': + '@react-native/babel-preset@0.81.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) '@babel/template': 7.28.6 - '@react-native/babel-plugin-codegen': 0.81.5(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.81.5(@babel/core@7.29.7) babel-plugin-syntax-hermes-parser: 0.29.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color @@ -29580,9 +29767,9 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 - '@react-native/codegen@0.81.5(@babel/core@7.29.0)': + '@react-native/codegen@0.81.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.2 glob: 7.2.3 hermes-parser: 0.29.1 @@ -29704,12 +29891,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@react-native/virtualized-lists@0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-native/virtualized-lists@0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 @@ -29725,15 +29912,15 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/bottom-tabs@7.4.4(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/bottom-tabs@7.4.4(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - '@react-navigation/elements': 2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/elements': 2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) color: 4.2.3 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -29758,13 +29945,13 @@ snapshots: use-latest-callback: 0.2.4(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) - '@react-navigation/elements@2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/elements@2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) color: 4.2.3 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) use-latest-callback: 0.2.4(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) @@ -29780,14 +29967,14 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native-stack@7.3.23(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/native-stack@7.3.23(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - '@react-navigation/elements': 2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/elements': 2.6.1(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -29802,14 +29989,14 @@ snapshots: react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) use-latest-callback: 0.2.4(react@19.1.0) - '@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/core': 7.12.3(react@19.1.0) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.11 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) use-latest-callback: 0.2.4(react@19.1.0) '@react-navigation/routers@7.5.1': @@ -30199,7 +30386,7 @@ snapshots: '@sentry/core@10.12.0': {} - '@sentry/react-native@7.2.0(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@sentry/react-native@7.2.0(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@sentry/babel-plugin-component-annotate': 4.3.0 '@sentry/browser': 10.12.0 @@ -30208,9 +30395,9 @@ snapshots: '@sentry/react': 10.12.0(react@19.1.0) '@sentry/types': 10.12.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) optionalDependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) transitivePeerDependencies: - encoding - supports-color @@ -30789,8 +30976,8 @@ snapshots: '@tanstack/directive-functions-plugin@1.139.0(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-utils': 1.139.0 babel-dead-code-elimination: 1.0.10 @@ -31127,9 +31314,9 @@ snapshots: '@tanstack/server-functions-plugin@1.139.0(vite@7.1.7(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7) '@babel/template': 7.29.7 '@babel/traverse': 7.29.0 '@babel/types': 7.29.7 @@ -32836,7 +33023,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/expect@3.2.4': dependencies: @@ -33624,13 +33811,13 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@29.7.0(@babel/core@7.29.0): + babel-jest@29.7.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) + babel-preset-jest: 29.6.3(@babel/core@7.29.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -33663,15 +33850,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.0 @@ -33689,10 +33867,10 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color @@ -33712,13 +33890,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -33752,9 +33923,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): dependencies: - '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' @@ -33777,24 +33948,24 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.0) - babel-preset-current-node-syntax@1.1.1(@babel/core@7.29.0): + babel-preset-current-node-syntax@1.1.1(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) babel-preset-expo@13.2.3(@babel/core@7.28.0): dependencies: @@ -33823,66 +33994,66 @@ snapshots: - '@babel/core' - supports-color - babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.35)(react-refresh@0.14.2): + babel-preset-expo@54.0.10(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@54.0.36)(react-refresh@0.14.2): dependencies: '@babel/helper-module-imports': 7.28.6 - '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.0) - '@babel/preset-react': 7.27.1(@babel/core@7.29.0) - '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) - '@react-native/babel-preset': 0.81.5(@babel/core@7.29.0) + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.7) + '@babel/preset-react': 7.27.1(@babel/core@7.29.7) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.7) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.7) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.29.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) debug: 4.4.3 react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: - '@babel/runtime': 7.29.2 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + '@babel/runtime': 7.29.7 + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) transitivePeerDependencies: - '@babel/core' - supports-color - babel-preset-expo@54.0.11(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.35)(react-refresh@0.14.2): + babel-preset-expo@54.0.12(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@54.0.36)(react-refresh@0.14.2): dependencies: - '@babel/helper-module-imports': 7.28.6 - '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.0) - '@babel/preset-react': 7.27.1(@babel/core@7.29.0) - '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) - '@react-native/babel-preset': 0.81.5(@babel/core@7.29.0) + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.29.7) + '@babel/preset-react': 7.27.1(@babel/core@7.29.7) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.7) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.7) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.29.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) debug: 4.4.3 react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: - '@babel/runtime': 7.29.2 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + '@babel/runtime': 7.29.7 + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -33893,11 +34064,11 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.1(@babel/core@7.28.0) - babel-preset-jest@29.6.3(@babel/core@7.29.0): + babel-preset-jest@29.6.3(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.1.1(@babel/core@7.29.0) + babel-preset-current-node-syntax: 1.1.1(@babel/core@7.29.7) bail@2.0.2: {} @@ -34682,7 +34853,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.4 core-js@3.39.0: {} @@ -36591,28 +36762,28 @@ snapshots: transitivePeerDependencies: - supports-color - expo-asset@12.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3): + expo-asset@12.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3): dependencies: '@expo/image-utils': 0.8.14(typescript@5.9.3) - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-constants: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-constants: 18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - supports-color - typescript - expo-build-properties@1.0.10(expo@54.0.35): + expo-build-properties@1.0.10(expo@54.0.36): dependencies: ajv: 8.20.0 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) semver: 7.7.4 - expo-clipboard@8.0.8(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-clipboard@8.0.8(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) expo-constants@17.1.7(expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: @@ -36641,12 +36812,12 @@ snapshots: transitivePeerDependencies: - supports-color - expo-constants@18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + expo-constants@18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 '@expo/env': 2.0.11 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - supports-color @@ -36657,10 +36828,10 @@ snapshots: expo: 53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - expo-file-system@19.0.23(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + expo-file-system@19.0.23(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) expo-font@13.3.2(expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: @@ -36668,35 +36839,35 @@ snapshots: fontfaceobserver: 2.3.0 react: 19.1.0 - expo-font@14.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-font@14.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) fontfaceobserver: 2.3.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) - expo-image-loader@6.0.0(expo@54.0.35): + expo-image-loader@6.0.0(expo@54.0.36): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-image-manipulator@14.0.8(expo@54.0.35): + expo-image-manipulator@14.0.8(expo@54.0.36): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-image-loader: 6.0.0(expo@54.0.35) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-image-loader: 6.0.0(expo@54.0.36) - expo-image-picker@17.0.11(expo@54.0.35): + expo-image-picker@17.0.11(expo@54.0.36): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-image-loader: 6.0.0(expo@54.0.35) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-image-loader: 6.0.0(expo@54.0.36) expo-keep-awake@14.1.4(expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: expo: 53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 - expo-keep-awake@15.0.8(expo@54.0.35)(react@19.1.0): + expo-keep-awake@15.0.8(expo@54.0.36)(react@19.1.0): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) react: 19.1.0 expo-linking@8.0.12(expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): @@ -36709,12 +36880,12 @@ snapshots: - expo - supports-color - expo-linking@8.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-linking@8.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: - expo-constants: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + expo-constants: 18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) invariant: 2.2.4 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - expo - supports-color @@ -36741,11 +36912,11 @@ snapshots: dependencies: invariant: 2.2.4 - expo-modules-core@3.0.30(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-modules-core@3.0.30(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: invariant: 2.2.4 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) expo-router@5.1.4(716a7a11045ffabd7fab4726238d0981): dependencies: @@ -36775,21 +36946,21 @@ snapshots: - react-native - supports-color - expo-router@6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-router@6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: - '@expo/metro-runtime': 6.1.2(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/metro-runtime': 6.1.2(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@react-navigation/bottom-tabs': 7.4.4(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@react-navigation/native-stack': 7.3.23(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/bottom-tabs': 7.4.4(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native-stack': 7.3.23(@react-navigation/native@7.1.16(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) client-only: 0.0.1 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-constants: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) - expo-linking: 8.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-constants: 18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) + expo-linking: 8.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-server: 1.0.7 fast-deep-equal: 3.1.3 invariant: 2.2.4 @@ -36797,10 +36968,10 @@ snapshots: query-string: 7.1.3 react: 19.1.0 react-fast-compare: 3.2.2 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) semver: 7.6.3 server-only: 0.0.1 sf-symbols-typescript: 2.2.0 @@ -36809,7 +36980,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: react-dom: 19.1.0(react@19.1.0) - react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -36819,10 +36990,10 @@ snapshots: expo-server@1.0.7: {} - expo-splash-screen@31.0.13(expo@54.0.35)(typescript@5.9.3): + expo-splash-screen@31.0.13(expo@54.0.36)(typescript@5.9.3): dependencies: - '@expo/prebuild-config': 54.0.8(expo@54.0.35)(typescript@5.9.3) - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + '@expo/prebuild-config': 54.0.8(expo@54.0.36)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) transitivePeerDependencies: - supports-color - typescript @@ -36834,27 +37005,27 @@ snapshots: react-native-edge-to-edge: 1.6.0(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-is-edge-to-edge: 1.2.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-system-ui@6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + expo-system-ui@6.0.9(expo@54.0.36)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) optionalDependencies: react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color - expo-web-browser@15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + expo-web-browser@15.0.11(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): dependencies: - expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo: 54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -36888,34 +37059,34 @@ snapshots: - supports-color - utf-8-validate - expo@54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3): + expo@54.0.36(@babel/core@7.29.7)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.29.2 - '@expo/cli': 54.0.25(expo-router@6.0.24)(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3) - '@expo/config': 12.0.13 - '@expo/config-plugins': 54.0.4 - '@expo/devtools': 0.1.8(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@babel/runtime': 7.29.7 + '@expo/cli': 54.0.26(expo-router@6.0.24)(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3) + '@expo/config': 12.0.14 + '@expo/config-plugins': 54.0.5 + '@expo/devtools': 0.1.8(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/fingerprint': 0.15.5 '@expo/metro': 54.2.0 - '@expo/metro-config': 54.0.16(expo@54.0.35) - '@expo/vector-icons': 15.1.1(expo-font@14.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/metro-config': 54.0.17(expo@54.0.36) + '@expo/vector-icons': 15.1.1(expo-font@14.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 54.0.11(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.35)(react-refresh@0.14.2) - expo-asset: 12.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) - expo-constants: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) - expo-file-system: 19.0.23(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) - expo-font: 14.0.12(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-keep-awake: 15.0.8(expo@54.0.35)(react@19.1.0) + babel-preset-expo: 54.0.12(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@54.0.36)(react-refresh@0.14.2) + expo-asset: 12.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-constants: 18.0.13(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) + expo-file-system: 19.0.23(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)) + expo-font: 14.0.12(expo@54.0.36)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-keep-awake: 15.0.8(expo@54.0.36)(react@19.1.0) expo-modules-autolinking: 3.0.26 - expo-modules-core: 3.0.30(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-modules-core: 3.0.30(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) pretty-format: 29.7.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-refresh: 0.14.2 whatwg-url-without-unicode: 8.0.0-3 optionalDependencies: - '@expo/metro-runtime': 6.1.2(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-webview: 13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/metro-runtime': 6.1.2(expo@54.0.36)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-webview: 13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -38138,7 +38309,7 @@ snapshots: internal-slot@1.0.7: dependencies: es-errors: 1.3.0 - hasown: 2.0.3 + hasown: 2.0.4 side-channel: 1.1.0 internal-slot@1.1.0: @@ -38232,7 +38403,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -38456,7 +38627,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -39578,7 +39749,7 @@ snapshots: metro-babel-transformer@0.82.5: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.29.1 nullthrows: 1.1.1 @@ -39587,7 +39758,7 @@ snapshots: metro-babel-transformer@0.83.3: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.32.0 nullthrows: 1.1.1 @@ -39596,7 +39767,7 @@ snapshots: metro-babel-transformer@0.83.7: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.83.7 @@ -39751,17 +39922,17 @@ snapshots: metro-minify-terser@0.82.5: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.46.2 + terser: 5.48.0 metro-minify-terser@0.83.3: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.46.2 + terser: 5.48.0 metro-minify-terser@0.83.7: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.46.2 + terser: 5.48.0 metro-resolver@0.82.5: dependencies: @@ -39807,7 +39978,7 @@ snapshots: metro-source-map@0.83.3: dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@babel/traverse--for-generate-function-map': '@babel/traverse@7.29.7' '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 @@ -39869,10 +40040,10 @@ snapshots: metro-transform-plugins@0.82.5: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -39880,10 +40051,10 @@ snapshots: metro-transform-plugins@0.83.3: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -39891,7 +40062,7 @@ snapshots: metro-transform-plugins@0.83.7: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/template': 7.29.7 '@babel/traverse': 7.29.0 @@ -39902,7 +40073,7 @@ snapshots: metro-transform-worker@0.82.5: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -39922,7 +40093,7 @@ snapshots: metro-transform-worker@0.83.3: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -39942,7 +40113,7 @@ snapshots: metro-transform-worker@0.83.7: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -39962,9 +40133,9 @@ snapshots: metro@0.82.5: dependencies: - '@babel/code-frame': 7.29.0 - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 @@ -40010,11 +40181,11 @@ snapshots: metro@0.83.3: dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 accepts: 1.3.8 chalk: 4.1.2 @@ -40525,7 +40696,7 @@ snapshots: - babel-plugin-macros optional: true - next@14.2.17(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.17(@opentelemetry/api@1.9.1)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.17 '@swc/helpers': 0.5.5 @@ -40535,7 +40706,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.29.7)(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 14.2.17 '@next/swc-darwin-x64': 14.2.17 @@ -42103,13 +42274,13 @@ snapshots: react: 19.1.0 react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@egjs/hammerjs': 2.0.17 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-is-edge-to-edge@1.2.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -42121,30 +42292,30 @@ snapshots: react: 19.1.0 react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-random-uuid@0.1.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: fast-base64-decode: 1.0.0 react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - react-native-random-uuid@0.1.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + react-native-random-uuid@0.1.4(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0)): dependencies: fast-base64-decode: 1.0.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-safe-area-context@5.6.2(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-native: 0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0) - react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-screens@4.16.0(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -42154,20 +42325,20 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 - react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-freeze: 1.0.4(react@19.1.0) - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 - react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: css-select: 5.1.0 css-tree: 1.1.3 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) warn-once: 0.1.1 react-native-web@0.20.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -42200,12 +42371,12 @@ snapshots: transitivePeerDependencies: - encoding - react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0) react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -42262,20 +42433,20 @@ snapshots: - supports-color - utf-8-validate - react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0): + react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.81.5 - '@react-native/codegen': 0.81.5(@babel/core@7.29.0) + '@react-native/codegen': 0.81.5(@babel/core@7.29.7) '@react-native/community-cli-plugin': 0.81.5 '@react-native/gradle-plugin': 0.81.5 '@react-native/js-polyfills': 0.81.5 '@react-native/normalize-colors': 0.81.5 - '@react-native/virtualized-lists': 0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-native/virtualized-lists': 0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.7)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 29.7.0(@babel/core@7.29.7) babel-plugin-syntax-hermes-parser: 0.29.1 base64-js: 1.5.1 commander: 12.1.0 @@ -44025,12 +44196,10 @@ snapshots: '@babel/core': 7.28.4 optional: true - styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1): + styled-jsx@5.1.1(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 - optionalDependencies: - '@babel/core': 7.29.7 styleq@0.1.3: {} @@ -44259,13 +44428,6 @@ snapshots: terminal-size@4.0.1: {} - terser@5.46.2: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 - commander: 2.20.3 - source-map-support: 0.5.21 - terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -45850,36 +46012,6 @@ snapshots: - tsx - yaml - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.19.17 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) - jsdom: 29.1.0(@noble/hashes@2.0.1) - transitivePeerDependencies: - - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 From 947d91fa8ca8f1d395287cfffd265e557517049e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 15 Jul 2026 16:41:02 -0600 Subject: [PATCH 105/105] chore: include mobile Expo update in changeset --- .changeset/upgrade-pi-agent-core-openai.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/upgrade-pi-agent-core-openai.md b/.changeset/upgrade-pi-agent-core-openai.md index c7ce8c54fa..f5c4048a98 100644 --- a/.changeset/upgrade-pi-agent-core-openai.md +++ b/.changeset/upgrade-pi-agent-core-openai.md @@ -5,6 +5,7 @@ '@electric-ax/agents-server-conformance-tests': patch '@electric-ax/agents-server-ui': patch '@electric-ax/agents-desktop': patch +'@electric-ax/agents-mobile': patch 'electric-ax': patch ---