@personaxis/protocol 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Personaxis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Front-end side: connect to an engine endpoint, submit Ops, receive events.
3
+ * Deliberately tiny — a TUI, a headless script, or a test all use the same
4
+ * three calls: `connect`, `submit`, `onEvent`.
5
+ */
6
+ import { type EventMsg, type Op, type OpResult } from "./types.js";
7
+ export declare class ProtocolClient {
8
+ private socket;
9
+ private conn;
10
+ private readonly listeners;
11
+ /** The engine's protocol version, known after connect() resolves. */
12
+ serverProtocolVersion: number | null;
13
+ connect(pipePath: string, timeoutMs?: number): Promise<void>;
14
+ /** Submit an operation; resolves with the engine's result. */
15
+ submit(op: Op): Promise<OpResult>;
16
+ /** Subscribe to engine events; returns the unsubscribe function.
17
+ * Subscribe BEFORE connect() — greeting events arrive during the handshake. */
18
+ onEvent(listener: (e: EventMsg) => void): () => void;
19
+ close(): void;
20
+ }
package/dist/client.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Front-end side: connect to an engine endpoint, submit Ops, receive events.
3
+ * Deliberately tiny — a TUI, a headless script, or a test all use the same
4
+ * three calls: `connect`, `submit`, `onEvent`.
5
+ */
6
+ import { createConnection } from "node:net";
7
+ import { RPC_EVENT, RPC_HELLO, RPC_SUBMIT, } from "./types.js";
8
+ import { connectionFor } from "./wire.js";
9
+ export class ProtocolClient {
10
+ socket = null;
11
+ conn = null;
12
+ listeners = new Set();
13
+ /** The engine's protocol version, known after connect() resolves. */
14
+ serverProtocolVersion = null;
15
+ async connect(pipePath, timeoutMs = 5_000) {
16
+ await new Promise((resolve, reject) => {
17
+ const socket = createConnection(pipePath);
18
+ const timer = setTimeout(() => {
19
+ socket.destroy();
20
+ reject(new Error(`timed out connecting to engine at ${pipePath}`));
21
+ }, timeoutMs);
22
+ socket.once("error", (err) => {
23
+ clearTimeout(timer);
24
+ reject(err);
25
+ });
26
+ socket.once("connect", () => {
27
+ clearTimeout(timer);
28
+ this.socket = socket;
29
+ this.conn = connectionFor(socket);
30
+ this.conn.onNotification(RPC_EVENT, (e) => {
31
+ for (const l of this.listeners)
32
+ l(e);
33
+ });
34
+ this.conn.listen();
35
+ resolve();
36
+ });
37
+ });
38
+ // Handshake: version exchange AND registration barrier — once this request
39
+ // roundtrips, the server has registered us and broadcasts will arrive.
40
+ const hello = (await this.conn.sendRequest(RPC_HELLO));
41
+ this.serverProtocolVersion = hello.protocolVersion;
42
+ }
43
+ /** Submit an operation; resolves with the engine's result. */
44
+ submit(op) {
45
+ if (!this.conn)
46
+ return Promise.reject(new Error("not connected"));
47
+ return this.conn.sendRequest(RPC_SUBMIT, op);
48
+ }
49
+ /** Subscribe to engine events; returns the unsubscribe function.
50
+ * Subscribe BEFORE connect() — greeting events arrive during the handshake. */
51
+ onEvent(listener) {
52
+ this.listeners.add(listener);
53
+ return () => this.listeners.delete(listener);
54
+ }
55
+ close() {
56
+ this.conn?.dispose();
57
+ this.socket?.destroy();
58
+ this.conn = null;
59
+ this.socket = null;
60
+ this.listeners.clear();
61
+ }
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @personaxis/protocol — the UI↔engine seam (SQ/EQ over JSON-RPC 2.0).
3
+ *
4
+ * Codex's protocol pattern adapted to TypeScript: front-ends submit typed
5
+ * operations and consume typed events; the engine never renders and a
6
+ * front-end never mutates persona state directly. One transport API on both
7
+ * OSes (UDS / Windows named pipes via node:net + vscode-jsonrpc).
8
+ */
9
+ export { PROTOCOL_VERSION, RPC_SUBMIT, RPC_EVENT, RPC_HELLO, type HelloResult, type Op, type OpName, type OpResult, type EventMsg, type EventName, } from "./types.js";
10
+ export { pipePathFor, connectionFor, type MessageConnection } from "./wire.js";
11
+ export { ProtocolServer, type OpHandler } from "./server.js";
12
+ export { ProtocolClient } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @personaxis/protocol — the UI↔engine seam (SQ/EQ over JSON-RPC 2.0).
3
+ *
4
+ * Codex's protocol pattern adapted to TypeScript: front-ends submit typed
5
+ * operations and consume typed events; the engine never renders and a
6
+ * front-end never mutates persona state directly. One transport API on both
7
+ * OSes (UDS / Windows named pipes via node:net + vscode-jsonrpc).
8
+ */
9
+ export { PROTOCOL_VERSION, RPC_SUBMIT, RPC_EVENT, RPC_HELLO, } from "./types.js";
10
+ export { pipePathFor, connectionFor } from "./wire.js";
11
+ export { ProtocolServer } from "./server.js";
12
+ export { ProtocolClient } from "./client.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Engine-side endpoint: accepts front-end connections, dispatches Ops to a
3
+ * single handler (the engine), broadcasts EventMsg to every connected front.
4
+ *
5
+ * The server holds NO business logic — governance, clamping and memory live in
6
+ * @personaxis/core behind the handler. This file is only the seam.
7
+ */
8
+ import { type EventMsg, type Op, type OpResult } from "./types.js";
9
+ import { type MessageConnection } from "./wire.js";
10
+ export type OpHandler = (op: Op) => Promise<OpResult> | OpResult;
11
+ export declare class ProtocolServer {
12
+ private readonly handler;
13
+ private server;
14
+ private readonly connections;
15
+ private readonly sockets;
16
+ constructor(handler: OpHandler);
17
+ /** Bind the endpoint. On POSIX a stale socket file from a crash is removed. */
18
+ listen(pipePath: string, onConfigured?: (conn: MessageConnection) => void): Promise<void>;
19
+ /** Broadcast an event to every connected front-end (JSON-RPC notification). */
20
+ broadcast(event: EventMsg): void;
21
+ /** Send to ONE connection (e.g. the session.configured greeting). */
22
+ send(conn: MessageConnection, event: EventMsg): void;
23
+ get connectionCount(): number;
24
+ close(): Promise<void>;
25
+ }
package/dist/server.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Engine-side endpoint: accepts front-end connections, dispatches Ops to a
3
+ * single handler (the engine), broadcasts EventMsg to every connected front.
4
+ *
5
+ * The server holds NO business logic — governance, clamping and memory live in
6
+ * @personaxis/core behind the handler. This file is only the seam.
7
+ */
8
+ import { createServer } from "node:net";
9
+ import { existsSync, unlinkSync } from "node:fs";
10
+ import { PROTOCOL_VERSION, RPC_EVENT, RPC_HELLO, RPC_SUBMIT, } from "./types.js";
11
+ import { connectionFor } from "./wire.js";
12
+ export class ProtocolServer {
13
+ handler;
14
+ server = null;
15
+ connections = new Set();
16
+ sockets = new Set();
17
+ constructor(handler) {
18
+ this.handler = handler;
19
+ }
20
+ /** Bind the endpoint. On POSIX a stale socket file from a crash is removed. */
21
+ listen(pipePath, onConfigured) {
22
+ if (process.platform !== "win32" && existsSync(pipePath)) {
23
+ // A previous engine that crashed leaves the UDS file behind; net.listen
24
+ // would EADDRINUSE forever. Named pipes on Windows self-clean.
25
+ try {
26
+ unlinkSync(pipePath);
27
+ }
28
+ catch {
29
+ /* raced with another cleanup — listen() will surface a real conflict */
30
+ }
31
+ }
32
+ this.server = createServer((socket) => {
33
+ const conn = connectionFor(socket);
34
+ // Handshake barrier: answered by the transport AFTER registration, so a
35
+ // client whose hello resolved is guaranteed to receive broadcasts.
36
+ conn.onRequest(RPC_HELLO, () => ({ protocolVersion: PROTOCOL_VERSION }));
37
+ conn.onRequest(RPC_SUBMIT, async (op) => {
38
+ try {
39
+ return await this.handler(op);
40
+ }
41
+ catch (e) {
42
+ // A handler bug must not tear down the transport for every front-end.
43
+ return { ok: false, error: e.message };
44
+ }
45
+ });
46
+ conn.onClose(() => this.connections.delete(conn));
47
+ socket.on("close", () => this.sockets.delete(socket));
48
+ conn.listen();
49
+ this.connections.add(conn);
50
+ this.sockets.add(socket);
51
+ onConfigured?.(conn);
52
+ });
53
+ return new Promise((resolve, reject) => {
54
+ this.server.once("error", reject);
55
+ this.server.listen(pipePath, () => resolve());
56
+ });
57
+ }
58
+ /** Broadcast an event to every connected front-end (JSON-RPC notification). */
59
+ broadcast(event) {
60
+ for (const conn of this.connections) {
61
+ // Best-effort per connection: one dead front must not stop the others.
62
+ try {
63
+ void conn.sendNotification(RPC_EVENT, event);
64
+ }
65
+ catch {
66
+ this.connections.delete(conn);
67
+ }
68
+ }
69
+ }
70
+ /** Send to ONE connection (e.g. the session.configured greeting). */
71
+ send(conn, event) {
72
+ void conn.sendNotification(RPC_EVENT, event);
73
+ }
74
+ get connectionCount() {
75
+ return this.connections.size;
76
+ }
77
+ close() {
78
+ for (const conn of this.connections)
79
+ conn.dispose();
80
+ this.connections.clear();
81
+ // net.Server.close waits for every socket to END — destroy them so close()
82
+ // resolves even when a front-end never disconnected (crash, test teardown).
83
+ for (const socket of this.sockets)
84
+ socket.destroy();
85
+ this.sockets.clear();
86
+ return new Promise((resolve) => {
87
+ if (!this.server)
88
+ return resolve();
89
+ this.server.close(() => resolve());
90
+ this.server = null;
91
+ });
92
+ }
93
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The SQ/EQ vocabulary — the ONLY types a front-end and the engine share.
3
+ *
4
+ * Pattern from Codex (codex-rs/core/src/protocol.rs), adapted to TypeScript
5
+ * discriminated unions:
6
+ * - Op (submission queue): everything a front-end may ASK the engine.
7
+ * - EventMsg (event queue): everything the engine TELLS its front-ends.
8
+ *
9
+ * The engine's internal LoopEvent (core EventBus) is carried verbatim inside
10
+ * `engine.event` — the protocol does not re-model the living loop, it
11
+ * transports it. Type-only import: nothing from core lands in the wire bundle.
12
+ */
13
+ import type { LoopEvent } from "@personaxis/core";
14
+ export declare const PROTOCOL_VERSION = 1;
15
+ export type Op =
16
+ /** Free-form user input for the living session (NL or /command line). */
17
+ {
18
+ op: "user_input";
19
+ text: string;
20
+ }
21
+ /** One governed Living-Loop tick on an observation. */
22
+ | {
23
+ op: "observe";
24
+ observation: string;
25
+ source: "user" | "tool" | "internal" | "synthesis";
26
+ }
27
+ /** Clamped, audited state mutation (adjust_persona_state). */
28
+ | {
29
+ op: "adjust";
30
+ field: string;
31
+ delta: number;
32
+ reason: string;
33
+ }
34
+ /** Answer to a pending `approval.requested` event. */
35
+ | {
36
+ op: "approval";
37
+ requestId: string;
38
+ decision: "allow" | "deny";
39
+ }
40
+ /** Cancel the in-flight turn/agent run (best-effort). */
41
+ | {
42
+ op: "interrupt";
43
+ }
44
+ /** Current envelope values + recent mutations. */
45
+ | {
46
+ op: "state_get";
47
+ }
48
+ /** Mutation log + memory-chain integrity + anomalies. */
49
+ | {
50
+ op: "audit_get";
51
+ }
52
+ /** Change the self-improvement posture (governed; min-wins vs policy.yaml). */
53
+ | {
54
+ op: "improve";
55
+ mode: "locked" | "suggesting" | "autonomous";
56
+ }
57
+ /** Graceful engine shutdown (flushes writers, acks, then exits). */
58
+ | {
59
+ op: "shutdown";
60
+ };
61
+ export type OpName = Op["op"];
62
+ /** Every op resolves to a result (JSON-RPC request/response). */
63
+ export interface OpResult {
64
+ ok: boolean;
65
+ /** Op-specific payload (state snapshot, audit view, applied mutation, …). */
66
+ data?: unknown;
67
+ error?: string;
68
+ }
69
+ export type EventMsg =
70
+ /** First event on connect: who is being served, under which posture. */
71
+ {
72
+ event: "session.configured";
73
+ sessionId: string;
74
+ persona: {
75
+ name: string;
76
+ path: string;
77
+ };
78
+ mode: "locked" | "suggesting" | "autonomous";
79
+ protocolVersion: number;
80
+ }
81
+ /** A turn began processing (user input or observation accepted). */
82
+ | {
83
+ event: "turn.started";
84
+ turnId: string;
85
+ }
86
+ /** Streaming text for the live region (frame-batched by the UI, not here). */
87
+ | {
88
+ event: "token.delta";
89
+ turnId: string;
90
+ text: string;
91
+ }
92
+ /** The turn finished; the transcript line can be committed to <Static>. */
93
+ | {
94
+ event: "turn.completed";
95
+ turnId: string;
96
+ }
97
+ /** A tool call needs a human decision — answer with op `approval`. */
98
+ | {
99
+ event: "approval.requested";
100
+ requestId: string;
101
+ tool: string;
102
+ args: Record<string, unknown>;
103
+ reason: string;
104
+ }
105
+ /** An engine LoopEvent, verbatim (observe/appraise/govern/mutate/memory/…). */
106
+ | {
107
+ event: "engine.event";
108
+ payload: LoopEvent;
109
+ }
110
+ /** Push snapshot after a mutation batch (dashboards re-render from this). */
111
+ | {
112
+ event: "state.snapshot";
113
+ values: Record<string, number>;
114
+ mutationCount: number;
115
+ }
116
+ /** Engine-side failure that did not kill the session. */
117
+ | {
118
+ event: "error";
119
+ message: string;
120
+ };
121
+ export type EventName = EventMsg["event"];
122
+ /** All ops travel as ONE request method; the union discriminates. */
123
+ export declare const RPC_SUBMIT = "personaxis/submit";
124
+ /** All events travel as ONE notification method; the union discriminates. */
125
+ export declare const RPC_EVENT = "personaxis/event";
126
+ /**
127
+ * Connection handshake, answered by the transport itself (not the app handler).
128
+ * Doubles as the registration barrier: when it resolves, the server side has
129
+ * fully registered the connection — a subsequent broadcast WILL reach it.
130
+ */
131
+ export declare const RPC_HELLO = "personaxis/hello";
132
+ export interface HelloResult {
133
+ protocolVersion: number;
134
+ }
package/dist/types.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The SQ/EQ vocabulary — the ONLY types a front-end and the engine share.
3
+ *
4
+ * Pattern from Codex (codex-rs/core/src/protocol.rs), adapted to TypeScript
5
+ * discriminated unions:
6
+ * - Op (submission queue): everything a front-end may ASK the engine.
7
+ * - EventMsg (event queue): everything the engine TELLS its front-ends.
8
+ *
9
+ * The engine's internal LoopEvent (core EventBus) is carried verbatim inside
10
+ * `engine.event` — the protocol does not re-model the living loop, it
11
+ * transports it. Type-only import: nothing from core lands in the wire bundle.
12
+ */
13
+ export const PROTOCOL_VERSION = 1;
14
+ // ─── JSON-RPC method names (the wire contract) ───────────────────────────────
15
+ /** All ops travel as ONE request method; the union discriminates. */
16
+ export const RPC_SUBMIT = "personaxis/submit";
17
+ /** All events travel as ONE notification method; the union discriminates. */
18
+ export const RPC_EVENT = "personaxis/event";
19
+ /**
20
+ * Connection handshake, answered by the transport itself (not the app handler).
21
+ * Doubles as the registration barrier: when it resolves, the server side has
22
+ * fully registered the connection — a subsequent broadcast WILL reach it.
23
+ */
24
+ export const RPC_HELLO = "personaxis/hello";
package/dist/wire.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wire transport: JSON-RPC 2.0 over node:net.
3
+ *
4
+ * One API for both OSes (the vscode-jsonrpc + node:net combination VS Code
5
+ * battle-tested): Unix domain sockets on POSIX, named pipes on Windows.
6
+ * The path derives from the persona path so several personas can each run
7
+ * their own engine concurrently on one machine.
8
+ */
9
+ import type { Socket } from "node:net";
10
+ import { type MessageConnection } from "vscode-jsonrpc/node.js";
11
+ /** Deterministic per-persona endpoint (named pipe on win32, UDS elsewhere). */
12
+ export declare function pipePathFor(personaPath: string): string;
13
+ /** JSON-RPC connection over an established socket (either side). */
14
+ export declare function connectionFor(socket: Socket): MessageConnection;
15
+ export type { MessageConnection };
package/dist/wire.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Wire transport: JSON-RPC 2.0 over node:net.
3
+ *
4
+ * One API for both OSes (the vscode-jsonrpc + node:net combination VS Code
5
+ * battle-tested): Unix domain sockets on POSIX, named pipes on Windows.
6
+ * The path derives from the persona path so several personas can each run
7
+ * their own engine concurrently on one machine.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js";
13
+ /** Deterministic per-persona endpoint (named pipe on win32, UDS elsewhere). */
14
+ export function pipePathFor(personaPath) {
15
+ const h = createHash("sha256").update(personaPath).digest("hex").slice(0, 12);
16
+ if (process.platform === "win32")
17
+ return `\\\\.\\pipe\\personaxis-${h}`;
18
+ const runtimeDir = process.env.XDG_RUNTIME_DIR ?? tmpdir();
19
+ return join(runtimeDir, `personaxis-${h}.sock`);
20
+ }
21
+ /** JSON-RPC connection over an established socket (either side). */
22
+ export function connectionFor(socket) {
23
+ return createMessageConnection(new StreamMessageReader(socket), new StreamMessageWriter(socket));
24
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@personaxis/protocol",
3
+ "version": "0.11.0",
4
+ "description": "The UI↔engine seam for Personaxis: typed operations (SQ) and events (EQ) as discriminated unions, carried over JSON-RPC 2.0 on node:net (Unix domain sockets + Windows named pipes, one API). The engine never renders; a front-end never mutates state directly — everything crosses this boundary.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "dependencies": {
13
+ "vscode-jsonrpc": "^8.2.1",
14
+ "@personaxis/core": "0.11.0"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.3",
18
+ "vitest": "^3.0.5"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/personaxis/cli.git",
26
+ "directory": "packages/protocol"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "lint": "tsc --noEmit",
31
+ "test": "vitest run"
32
+ }
33
+ }