diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index 3012ceb7d..4404cba18 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -5,10 +5,10 @@ import { defineFacet, type FacetHost, type FacetLoader, + type JsonValue, type LoadedFacets, } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import type { JsonValue } from "@earendil-works/pi-protocol"; import { CombinedAutocompleteProvider, type Component, @@ -331,7 +331,7 @@ export class ExperimentalClientTui implements Component { this.#selectedServerId = feature.serverId; this.#sessionId = prepared.summary.sessionId; this.#updateAutocomplete(); - await this.#openLane(feature, prepared.summary.sessionId); + await this.#openLane(feature); this.#screen = "chat"; this.#status = ""; this.#rebuild(); @@ -464,9 +464,8 @@ export class ExperimentalClientTui implements Component { #handleAttachmentState(feature: SessionFeature, state: SessionAttachmentState): void { if (this.#closed || this.#selectedServerId !== feature.serverId || this.#sessionId === undefined) return; if (state.status === "attached" && state.sessionId === this.#sessionId) { - const sessionId = this.#sessionId; this.#queueRecovery(async () => { - if (this.#laneReplica === undefined) await this.#openLane(feature, sessionId); + if (this.#laneReplica === undefined) await this.#openLane(feature); this.#busy = false; this.#status = ""; this.#rebuild(); @@ -493,9 +492,9 @@ export class ExperimentalClientTui implements Component { }); } - async #openLane(feature: SessionFeature, sessionId: string): Promise { + async #openLane(feature: SessionFeature): Promise { await this.#closeLane(); - const replica = await openLaneReplica(feature.transcript, sessionId); + const replica = await openLaneReplica(feature.transcript); const view = new ExperimentalChatView(this.#ui, process.cwd()); view.apply(replica.state()); this.#laneReplica = replica; diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index 994f6ac83..34f45ce46 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -82,7 +82,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption const agent = match.agent; const completedText = new Map(); - const replica = await openLaneReplica(match.transcript, sessionId, async (event) => { + const replica = await openLaneReplica(match.transcript, async (event) => { if (event.type === "message_end" && event.runId !== undefined && event.message.role === "assistant") { completedText.set(event.runId, messageText(event.message)); } diff --git a/packages/coding-agent/src/experimental/lane-replica.ts b/packages/coding-agent/src/experimental/lane-replica.ts index 44bdb8edd..303b13a98 100644 --- a/packages/coding-agent/src/experimental/lane-replica.ts +++ b/packages/coding-agent/src/experimental/lane-replica.ts @@ -2,7 +2,6 @@ import type { LaneSnapshot, LaneWatchEvent } from "@earendil-works/pi-agent-core import type { Transcript } from "./services/transcript.ts"; export interface LaneReplica { - readonly sessionId: string; state(): LaneSnapshot; subscribe(listener: () => void): () => void; close(): Promise; @@ -11,7 +10,6 @@ export interface LaneReplica { /** Open a presentation-local replica from the Session's Transcript state. */ export async function openLaneReplica( transcript: Transcript, - sessionId: string, onEvent?: (event: LaneWatchEvent) => void | Promise, ): Promise { const listeners = new Set<() => void>(); @@ -51,7 +49,6 @@ export async function openLaneReplica( } return { - sessionId, state() { if (snapshot === undefined) throw new Error("Transcript replica has no snapshot"); return snapshot; diff --git a/packages/coding-agent/src/experimental/plugins/bundled.ts b/packages/coding-agent/src/experimental/plugins/bundled.ts index 248cd201b..9b167aff4 100644 --- a/packages/coding-agent/src/experimental/plugins/bundled.ts +++ b/packages/coding-agent/src/experimental/plugins/bundled.ts @@ -1,11 +1,10 @@ -import { combineFacetLoaders, type FacetLoader } from "@earendil-works/chord"; +import { combineFacetLoaders, type FacetLoader, type JsonValue } from "@earendil-works/chord"; import { createFacetBundleArtifactLoader, createFacetBundleLoader, type FacetBundleArtifact, readFacetBundleManifest, } from "@earendil-works/chord/node"; -import type { JsonValue } from "@earendil-works/pi-protocol"; const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles"; const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin"; diff --git a/packages/coding-agent/src/experimental/services/plugins.ts b/packages/coding-agent/src/experimental/services/plugins.ts index 3986c2b36..55adf7dc5 100644 --- a/packages/coding-agent/src/experimental/services/plugins.ts +++ b/packages/coding-agent/src/experimental/services/plugins.ts @@ -1,5 +1,4 @@ -import { type Context, defineService } from "@earendil-works/chord"; -import type { JsonValue } from "@earendil-works/pi-protocol"; +import { type Context, defineService, type JsonValue } from "@earendil-works/chord"; /** Server-built plugin generations available to presentations. */ export interface PresentationPlugins { diff --git a/packages/coding-agent/src/experimental/services/server.ts b/packages/coding-agent/src/experimental/services/server.ts index 031411df5..6da5a9daa 100644 --- a/packages/coding-agent/src/experimental/services/server.ts +++ b/packages/coding-agent/src/experimental/services/server.ts @@ -1,6 +1,12 @@ -import { type Context, RemoteServiceProvider, replicatedState, type ServiceSubscription } from "@earendil-works/chord"; +import { + type Context, + type JsonValue, + RemoteServiceProvider, + replicatedState, + type ServiceSubscription, +} from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { decodeServiceControlCall, type JsonValue } from "@earendil-works/pi-protocol"; +import { decodeServiceControlCall } from "@earendil-works/pi-protocol"; import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server"; import { PresentationPlugins } from "./plugins.ts"; import { diff --git a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts index beb9aebf9..e2ae7189f 100644 --- a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts +++ b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts @@ -1,6 +1,5 @@ -import { defineFacet, type Facet } from "@earendil-works/chord"; +import { defineFacet, type Facet, type JsonValue } from "@earendil-works/chord"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { JsonValue } from "@earendil-works/pi-protocol"; import { AgentController } from "./agent-controller.ts"; import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts"; import { PresentationPlugins, SessionPlugins } from "./plugins.ts"; diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts index 01d94226c..7adeb9a13 100644 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ b/packages/coding-agent/src/experimental/services/worker.ts @@ -5,19 +5,14 @@ import { defineFacet, type FacetHost, type FacetLoader, - type ServiceCatalogueEntry, + isJsonValue, + type JsonValue, type ServiceProviderUpdate, type ServiceSubscription, } from "@earendil-works/chord"; import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core"; -import { - decodeServiceControlCall, - type JsonValue, - JsonValueSchema, - type ProtocolRpcCall, -} from "@earendil-works/pi-protocol"; +import { decodeServiceControlCall, type ProtocolRpcCall } from "@earendil-works/pi-protocol"; import Type, { type Static } from "typebox"; -import { Check } from "typebox/value"; import type { ModelRuntime } from "../../core/model-runtime.ts"; import type { SettingsManager } from "../../core/settings-manager.ts"; import { AgentController } from "./agent-controller.ts"; @@ -26,8 +21,10 @@ import { createModelsServiceFacet } from "./models-provider.ts"; import { SessionPlugins } from "./plugins.ts"; import { createTranscriptServiceFacet } from "./transcript-provider.ts"; +const OpaqueJsonValueSchema = Type.Unsafe(Type.Unknown()); + export const ServiceOperationResultSchema = Type.Object( - { result: Type.Optional(JsonValueSchema) }, + { result: Type.Optional(OpaqueJsonValueSchema) }, { additionalProperties: false }, ); export type ServiceOperationResult = Static; @@ -51,10 +48,8 @@ interface WorkerServiceSubscription { } export interface SessionWorkerServices { - readonly catalogue: readonly ServiceCatalogueEntry[]; invoke(call: ProtocolRpcCall, scope: WorkerServiceScope, context: Context): Promise; removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void; - reload(): Promise; dispose(): Promise; } @@ -130,7 +125,6 @@ export async function createSessionWorkerServices(options: { }; return { - catalogue: provider.catalogue, async invoke(call, scope, context) { const controlCall = decodeServiceControlCall(call); if (controlCall?.type === "catalogue") return toProtocolJsonValue(provider.catalogue); @@ -155,7 +149,6 @@ export async function createSessionWorkerServices(options: { return provider.invoke(call, context); }, removeSubscriptions, - reload: () => reloadPlugins(), async dispose() { removeSubscriptions(() => true); await reloadTail; @@ -178,6 +171,6 @@ function scopedSubscriptionKey(scope: WorkerServiceScope, subscriptionId: string } function toProtocolJsonValue(value: unknown): JsonValue { - if (!Check(JsonValueSchema, value)) throw new Error("Service produced invalid JSON"); + if (!isJsonValue(value)) throw new Error("Service produced invalid JSON"); return value; } diff --git a/packages/coding-agent/test/experimental-lane-replica.test.ts b/packages/coding-agent/test/experimental-lane-replica.test.ts index cebff18b5..a011e47ea 100644 --- a/packages/coding-agent/test/experimental-lane-replica.test.ts +++ b/packages/coding-agent/test/experimental-lane-replica.test.ts @@ -37,7 +37,7 @@ describe("experimental lane replica", () => { const staleEvent = { type: "run_start", lane: "main", runId: "stale", startedAt: 1 } as const; const state = replicatedState({ snapshot: snapshot(), event: staleEvent }); const onEvent = vi.fn(); - const replica = await openLaneReplica({ state }, "session-1", onEvent); + const replica = await openLaneReplica({ state }, onEvent); const changed = vi.fn(); replica.subscribe(changed); expect(onEvent).not.toHaveBeenCalled(); @@ -57,7 +57,7 @@ describe("experimental lane replica", () => { test("waits for the first initialized transcript value", async () => { const state = replicatedState({ snapshot: null, event: null }); - const opening = openLaneReplica({ state }, "session-1"); + const opening = openLaneReplica({ state }); state.state.snapshot = snapshot("ready"); state.publish(BACKGROUND_CONTEXT); const replica = await opening; diff --git a/packages/coding-agent/test/experimental-plugin-reload.test.ts b/packages/coding-agent/test/experimental-plugin-reload.test.ts index 4a7ee15d8..26eaa2cfd 100644 --- a/packages/coding-agent/test/experimental-plugin-reload.test.ts +++ b/packages/coding-agent/test/experimental-plugin-reload.test.ts @@ -1,6 +1,7 @@ import { defineFacet, type FacetLoader } from "@earendil-works/chord"; -import type { AgentLane, LaneSnapshot } from "@earendil-works/pi-agent-core"; +import { type AgentLane, BACKGROUND_CONTEXT, type LaneSnapshot } from "@earendil-works/pi-agent-core"; import { describe, expect, test, vi } from "vitest"; +import { SessionPlugins } from "../src/experimental/services/plugins.ts"; import { createSessionWorkerServices } from "../src/experimental/services/worker.ts"; describe("experimental plugin reload", () => { @@ -78,7 +79,11 @@ describe("experimental plugin reload", () => { }); try { expect(activations).toEqual([1]); - await services.reload(); + await services.invoke( + { serviceId: SessionPlugins.id, member: "reload", args: [] }, + { serverConnectionId: "server-1", attachmentId: "attachment-1" }, + BACKGROUND_CONTEXT, + ); expect(activations).toEqual([1, 2]); expect(disposals).toEqual([1]); } finally { diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 7c51e4db7..c98da1c02 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -1,13 +1,7 @@ import { isJsonValue } from "@earendil-works/chord"; import { Check } from "typebox/value"; import { decodeCbor, encodeCbor } from "./cbor/index.ts"; -import { - assertCompleteFrame, - DEFAULT_MAX_FRAME_LENGTH, - encodeFrame, - FrameDecoder, - type FrameDecoderOptions, -} from "./framing.ts"; +import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, type FrameDecoderOptions } from "./framing.ts"; import { type ClientMessage, ClientMessageSchema, @@ -51,9 +45,7 @@ function encodeProtocolMessage( const validated = parse(value); try { const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - const frame = encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength })); - assertCompleteFrame(frame, { maxFrameLength }); - return frame; + return encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength })); } catch (error) { if (error instanceof ProtocolValidationError) throw error; throw new ProtocolValidationError(`Unable to encode ${kind} protocol message: ${boundedErrorMessage(error)}`); diff --git a/packages/protocol/src/framing.ts b/packages/protocol/src/framing.ts index 6751e286d..1c6ad286a 100644 --- a/packages/protocol/src/framing.ts +++ b/packages/protocol/src/framing.ts @@ -38,20 +38,6 @@ export function encodeFrame(payload: Uint8Array): Uint8Array { return frame; } -/** Validates that bytes contain exactly one complete frame within the configured limit. */ -export function assertCompleteFrame(frame: Uint8Array, options?: FrameDecoderOptions): void { - if (!(frame instanceof Uint8Array)) throw new TypeError("Frame must be a Uint8Array"); - if (frame.byteLength < FRAME_HEADER_LENGTH) throw new FrameError("Frame does not contain a complete length prefix"); - const length = frame[0]! * 0x1_000_000 + frame[1]! * 0x1_0000 + frame[2]! * 0x100 + frame[3]!; - const maxFrameLength = resolveMaxFrameLength(options); - if (length > maxFrameLength) { - throw new FrameError(`Frame length ${length} exceeds configured limit of ${maxFrameLength}`); - } - if (frame.byteLength !== FRAME_HEADER_LENGTH + length) { - throw new FrameError("Frame must contain exactly one complete payload"); - } -} - type DecoderState = "open" | "ended" | "failed"; /** Incrementally splits arbitrary byte chunks into length-prefixed payloads. */ diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 2e92aaeef..d56b26a2c 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,7 +1,6 @@ export * from "./cbor/index.ts"; export * from "./codec.ts"; export * from "./framing.ts"; -export * from "./json-value.ts"; export { type AttachmentEnvelope, type CancelEnvelope, diff --git a/packages/protocol/src/json-value.ts b/packages/protocol/src/json-value.ts deleted file mode 100644 index b2dd39f04..000000000 --- a/packages/protocol/src/json-value.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { JsonValue } from "@earendil-works/chord"; -import Type from "typebox"; - -/** Static JSON contract. Protocol codecs perform recursive validation at the wire boundary. */ -export const JsonValueSchema = Type.Unsafe(Type.Unknown()); - -export type { JsonValue } from "@earendil-works/chord"; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index c52621051..fb6ec52d9 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -1,11 +1,12 @@ -import { isJsonValue } from "@earendil-works/chord"; +import { isJsonValue, type JsonValue } from "@earendil-works/chord"; import type { WireOp } from "@earendil-works/chord/delta"; import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; -import { type JsonValue, JsonValueSchema } from "./json-value.ts"; export const PROTOCOL_VERSION = 8 as const; +const OpaqueJsonValueSchema = Type.Unsafe(Type.Unknown()); + const IdSchema = Type.String({ minLength: 1 }); const StrictObject = [0]>(properties: T) => Type.Object(properties, { additionalProperties: false }); @@ -45,7 +46,7 @@ export const ProtocolRpcCallSchema = StrictObject({ serviceId: Type.String({ minLength: 1 }), instance: Type.Optional(ServiceInstanceAddressSchema), member: Type.String({ minLength: 1 }), - args: Type.Array(JsonValueSchema), + args: Type.Array(OpaqueJsonValueSchema), }); export type ProtocolRpcCall = Static; export type ProtocolRpcResult = JsonValue | undefined; @@ -58,10 +59,10 @@ const DeltaNonEmptyPathRefSchema = Type.Union([DeltaNonEmptyPathSchema, Type.Int const DeltaOpsSchema = Type.Array( Type.Unsafe( Type.Union([ - Type.Tuple([Type.Literal("r"), JsonValueSchema]), + Type.Tuple([Type.Literal("r"), OpaqueJsonValueSchema]), Type.Tuple([Type.Literal("#"), Type.Integer({ minimum: 0 }), DeltaPathSchema]), - Type.Tuple([Type.Literal("s"), DeltaNonEmptyPathRefSchema, JsonValueSchema]), - Type.Tuple([Type.Literal("s"), JsonValueSchema]), + Type.Tuple([Type.Literal("s"), DeltaNonEmptyPathRefSchema, OpaqueJsonValueSchema]), + Type.Tuple([Type.Literal("s"), OpaqueJsonValueSchema]), Type.Tuple([Type.Literal("d"), DeltaNonEmptyPathRefSchema]), Type.Tuple([Type.Literal("d")]), Type.Tuple([Type.Literal("a"), DeltaNonEmptyPathRefSchema, Type.String()]), @@ -73,13 +74,13 @@ const DeltaOpsSchema = Type.Array( DeltaPathRefSchema, Type.Integer({ minimum: 0 }), Type.Integer({ minimum: 0 }), - Type.Array(JsonValueSchema), + Type.Array(OpaqueJsonValueSchema), ]), Type.Tuple([ Type.Literal("p"), Type.Integer({ minimum: 0 }), Type.Integer({ minimum: 0 }), - Type.Array(JsonValueSchema), + Type.Array(OpaqueJsonValueSchema), ]), ]), ), @@ -279,7 +280,7 @@ const ResponseEnvelopeSchema = Type.Union([ type: Type.Literal("response"), id: IdSchema, ok: Type.Literal(true), - result: Type.Optional(JsonValueSchema), + result: Type.Optional(OpaqueJsonValueSchema), }), StrictObject({ type: Type.Literal("response"), diff --git a/packages/protocol/test/framing.test.ts b/packages/protocol/test/framing.test.ts index 1cb893658..7dd1ec258 100644 --- a/packages/protocol/test/framing.test.ts +++ b/packages/protocol/test/framing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { assertCompleteFrame, DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, FrameError } from "../src/index.ts"; +import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, FrameError } from "../src/index.ts"; function concatenate(...chunks: Uint8Array[]): Uint8Array { const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0)); @@ -19,13 +19,6 @@ describe("binary framing", () => { expect(encodeFrame(new Uint8Array())).toEqual(new Uint8Array([0, 0, 0, 0])); }); - test("validates one complete bounded frame without accepting trailing or partial bytes", () => { - expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 2, 1, 2]), { maxFrameLength: 2 })).not.toThrow(); - expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 2, 1]))).toThrow(/complete/i); - expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 1, 1, 2]))).toThrow(/exactly/i); - expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 3, 1, 2, 3]), { maxFrameLength: 2 })).toThrow(/limit/i); - }); - test("decodes fragmented, coalesced, and empty frames in order", () => { const wire = concatenate( encodeFrame(new Uint8Array([1, 2, 3])),