@constructive-db/machine-protocol 0.1.0 → 0.2.2

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @constructive-db/machine-protocol
2
2
 
3
- The wire protocol shared by the machine relay (`compute/services/machine-relay`)
4
- and the machine runner (`compute/lib/machine-runner`): the JSON frames a remote
3
+ The wire protocol shared by the machine relay (`compute/services/machine-relay` in constructive-db)
4
+ and the machine runner (`agentic/machine-runner`): the JSON frames a remote
5
5
  session is made of — `open`, `input`, `resize`, `close` from the client side;
6
6
  `output`, `exit`, `error` from the runner side; `attached` from the relay — and
7
7
  the codec that encodes them and refuses malformed ones.
@@ -11,3 +11,95 @@ The relay never interprets terminal bytes: `input` flows client → runner and
11
11
  session lifecycle. The package has no dependencies so the relay can carry it
12
12
  into the functions image without dragging the runner's native pty bindings
13
13
  along.
14
+
15
+ ## Two legs, two vocabularies
16
+
17
+ The runner leg — what a runner sends and receives — is the generic subset:
18
+ `open` (`command`, `args`, `interactive`, `cols`, `rows`, `cwd`), `input`,
19
+ `resize`, `signal`, `detach`, `reattach`, `close` in; `output` (with an
20
+ optional `stream: 'stdout' | 'stderr'` for a process on pipes), `exit`,
21
+ `error`, `scrollback` out; plus enrollment and credential exchange. A runner
22
+ never sees anything else, and never parses `output`.
23
+
24
+ The agent vocabulary is the client leg's, spoken between a client and the
25
+ relay: an `open` may carry a binding (`runId`, `agentMode`, `cliSessionId`,
26
+ resolved by `agentBinding`), and the relay turns a bound program's stdout
27
+ lines into `agent_event` frames (`assertAgentEvent`). An approval decision is
28
+ not a frame at all on the runner side: the relay writes it to the program's
29
+ stdin as one JSON line (`encodeApprovalDecisionLine` /
30
+ `parseApprovalDecisionLine`), which `constructive-agent-cli` reads. The runner
31
+ carried bytes.
32
+
33
+ ---
34
+
35
+ ## Education and Tutorials
36
+
37
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
38
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
39
+
40
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
41
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
42
+
43
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
44
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
45
+
46
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
47
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
48
+
49
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
50
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
51
+
52
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
53
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
54
+
55
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
56
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
57
+
58
+ ## Related Constructive Tooling
59
+
60
+ ### 📦 Package Management
61
+
62
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
63
+
64
+ ### 🧪 Testing
65
+
66
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
67
+ * [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
68
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
69
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
70
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
71
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
72
+
73
+ ### 🧠 Parsing & AST
74
+
75
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
76
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
77
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
78
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
79
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
80
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
81
+
82
+ ### 📚 Documentation & Skills
83
+
84
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
85
+
86
+ Install skills for AI coding agents:
87
+
88
+ ```bash
89
+ # All platform skills (security, blueprints, codegen, billing, etc.)
90
+ npx skills add constructive-io/constructive-skills
91
+
92
+ # Individual repo skills (pgpm, testing, CLI, search, etc.)
93
+ npx skills add https://github.com/constructive-io/constructive --skill pgpm
94
+ npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
95
+ ```
96
+
97
+ ## Credits
98
+
99
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
100
+
101
+ ## Disclaimer
102
+
103
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
104
+
105
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.js CHANGED
@@ -14,6 +14,19 @@
14
14
  // a decision. That is what makes a reattach from a different client safe —
15
15
  // nothing about the attach that opened the session carries over.
16
16
  //
17
+ // Two vocabularies live here, and the line between them is the line between the
18
+ // runner and everything above it:
19
+ //
20
+ // - The *machine* frames (`open` … `exit`, plus enrollment) are the remote
21
+ // control. A runner speaks only these: it runs an allow-listed command in a
22
+ // pty or on pipes, moves bytes, reports the exit. It has no notion of what the
23
+ // command is.
24
+ // - The *agent* vocabulary (`AgentBinding` on an `open`, `AgentEvent`, the
25
+ // `agent_event` frame, the stdio contract below) is between a client, the
26
+ // relay and an agent program running *under* the runner. The runner never
27
+ // reads or writes any of it; to the runner an agent program is a command on
28
+ // pipes whose stdout happens to be JSON lines.
29
+ //
17
30
  // This package is vocabulary, never policy. Everything a socket puts on the wire
18
31
  // — its database, its machine id — is a *coordinate*: it says which row the
19
32
  // socket is asking about, and the relay answers by asking that database's SQL.
@@ -86,6 +99,17 @@ export function parseClientAttachPath(path) {
86
99
  machineId: decodeURIComponent(match[2])
87
100
  };
88
101
  }
102
+ export const AGENT_MODES = ['cli', 'embedded'];
103
+ /** The binding an `open` frame carries, or null for a plain session. */
104
+ export function agentBinding(frame) {
105
+ if (frame.runId === undefined || frame.agentMode === undefined)
106
+ return null;
107
+ return {
108
+ runId: frame.runId,
109
+ agentMode: frame.agentMode,
110
+ ...(frame.cliSessionId !== undefined ? { cliSessionId: frame.cliSessionId } : {})
111
+ };
112
+ }
89
113
  /**
90
114
  * Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
91
115
  * interrupt characters, so on a pty they are delivered the way a keyboard
@@ -94,6 +118,124 @@ export function parseClientAttachPath(path) {
94
118
  * session. The rest are delivered to the session leader and do end it.
95
119
  */
96
120
  export const SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
121
+ export const AGENT_EVENT_KINDS = [
122
+ 'session',
123
+ 'text',
124
+ 'tool_call',
125
+ 'tool_result',
126
+ 'approval_requested',
127
+ 'approval_resolved',
128
+ 'result'
129
+ ];
130
+ // ---------------------------------------------------------------------------
131
+ // The agent program contract. An agent runs under the runner as an ordinary
132
+ // non-interactive command; this is what it speaks on its pipes, and the relay
133
+ // is the peer that reads and writes it — not the runner, which sees bytes.
134
+ //
135
+ // stdin — one prompt per line, and {@link ApprovalDecisionLine}s as JSON
136
+ // lines where the relay answers a question the program asked;
137
+ // stdout — {@link AgentEvent}s as JSON lines; any other line is plain output;
138
+ // stderr — plain output;
139
+ // exit — the program's own.
140
+ //
141
+ // `constructive-agent-host` (`@constructive-db/agent-host`) speaks it natively.
142
+ // `constructive-agent-cli` (`@constructive-db/agent-cli`) speaks it on behalf
143
+ // of a coding-agent CLI (`claude`, `codex`) that does not.
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * The program the relay runs for a `cli` binding: it wraps the CLI the opener
147
+ * named (`claude`, `codex`) and translates its stream to this contract. The
148
+ * machine's policy must allow it by this exact name.
149
+ */
150
+ export const AGENT_CLI_COMMAND = 'constructive-agent-cli';
151
+ export function encodeApprovalDecisionLine(line) {
152
+ return `${JSON.stringify(line)}\n`;
153
+ }
154
+ /** The decision a stdin line carries, or null when the line is a prompt. */
155
+ export function parseApprovalDecisionLine(line) {
156
+ if (!line.startsWith('{'))
157
+ return null;
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(line);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
166
+ return null;
167
+ const value = parsed;
168
+ if (value.kind !== 'approval_decision')
169
+ return null;
170
+ if (typeof value.requestId !== 'string' || value.requestId.length === 0) {
171
+ throw new Error('machine-protocol: approval_decision line is missing requestId');
172
+ }
173
+ if (value.decision !== 'allow' && value.decision !== 'deny') {
174
+ throw new Error('machine-protocol: approval_decision line has invalid decision');
175
+ }
176
+ const reason = value.reason;
177
+ if (reason !== undefined && typeof reason !== 'string') {
178
+ throw new Error('machine-protocol: approval_decision line reason must be a string');
179
+ }
180
+ const decoded = {
181
+ kind: 'approval_decision',
182
+ requestId: value.requestId,
183
+ decision: value.decision
184
+ };
185
+ if (typeof reason === 'string')
186
+ decoded.reason = reason;
187
+ return decoded;
188
+ }
189
+ /** The longest line a {@link LineSplitter} buffers before giving up on it. */
190
+ export const MAX_LINE_LENGTH = 1024 * 1024;
191
+ /**
192
+ * Split a stream into the complete lines it has delivered so far. Keeps the
193
+ * unterminated tail for the next chunk; `flush` hands it over at the end. A
194
+ * tail that outgrows `maxLineLength` is dropped and reported, so a program
195
+ * that never writes a newline cannot grow the reader without bound.
196
+ */
197
+ export class LineSplitter {
198
+ maxLineLength;
199
+ tail = '';
200
+ constructor(maxLineLength = MAX_LINE_LENGTH) {
201
+ this.maxLineLength = maxLineLength;
202
+ }
203
+ /**
204
+ * Complete lines are delivered before an oversized tail in the same chunk is
205
+ * reported: the failure is raised on the next call instead of losing them.
206
+ */
207
+ overflowed = false;
208
+ push(chunk) {
209
+ this.raiseOverflow();
210
+ this.tail += chunk;
211
+ const lines = this.tail.split('\n');
212
+ this.tail = lines.pop() ?? '';
213
+ if (this.tail.length > this.maxLineLength) {
214
+ this.tail = '';
215
+ this.overflowed = true;
216
+ if (lines.length === 0)
217
+ this.raiseOverflow();
218
+ }
219
+ return lines.map(stripCarriageReturn);
220
+ }
221
+ raiseOverflow() {
222
+ if (!this.overflowed)
223
+ return;
224
+ this.overflowed = false;
225
+ throw new Error(`machine-protocol: line exceeds ${this.maxLineLength} characters without a newline`);
226
+ }
227
+ flush() {
228
+ this.raiseOverflow();
229
+ if (!this.tail)
230
+ return [];
231
+ const line = stripCarriageReturn(this.tail);
232
+ this.tail = '';
233
+ return [line];
234
+ }
235
+ }
236
+ function stripCarriageReturn(line) {
237
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
238
+ }
97
239
  const FRAME_TYPES = new Set([
98
240
  'open',
99
241
  'input',
@@ -103,6 +245,7 @@ const FRAME_TYPES = new Set([
103
245
  'reattach',
104
246
  'close',
105
247
  'output',
248
+ 'agent_event',
106
249
  'exit',
107
250
  'error',
108
251
  'scrollback',
@@ -121,6 +264,7 @@ const SESSION_FRAME_TYPES = new Set([
121
264
  'reattach',
122
265
  'close',
123
266
  'output',
267
+ 'agent_event',
124
268
  'exit',
125
269
  'scrollback',
126
270
  'detached',
@@ -140,6 +284,105 @@ function validDimension(value) {
140
284
  value > 0 &&
141
285
  value <= MAX_DIMENSION);
142
286
  }
287
+ function isPlainObject(value) {
288
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
289
+ }
290
+ /**
291
+ * Whether `value` looks like an {@link AgentEvent} — an object naming one of
292
+ * the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
293
+ * headless agent process speaks these on its stdout one per line, and this is
294
+ * how a relaying runner tells an event line from any other line it prints.
295
+ */
296
+ export function isAgentEventLike(value) {
297
+ return (isPlainObject(value) &&
298
+ typeof value.kind === 'string' &&
299
+ AGENT_EVENT_KINDS.includes(value.kind));
300
+ }
301
+ /**
302
+ * `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
303
+ * wrong with it. `where` names the carrier in the message (the frame, a
304
+ * process's stdout) so the fault is attributed.
305
+ */
306
+ export function assertAgentEvent(value, where = "'agent_event' frame") {
307
+ if (!isPlainObject(value)) {
308
+ throw new Error(`machine-protocol: ${where} is missing event`);
309
+ }
310
+ const event = value;
311
+ const kind = event.kind;
312
+ if (typeof kind !== 'string' || !AGENT_EVENT_KINDS.includes(kind)) {
313
+ throw new Error(`machine-protocol: ${where} has unknown event kind '${String(kind)}'`);
314
+ }
315
+ const requiredString = (field) => {
316
+ if (typeof event[field] !== 'string') {
317
+ throw new Error(`machine-protocol: ${where} ${kind} event is missing ${field}`);
318
+ }
319
+ };
320
+ switch (kind) {
321
+ case 'session':
322
+ requiredString('cliSessionId');
323
+ break;
324
+ case 'text':
325
+ requiredString('text');
326
+ break;
327
+ case 'tool_call':
328
+ requiredString('id');
329
+ requiredString('name');
330
+ break;
331
+ case 'tool_result':
332
+ requiredString('id');
333
+ break;
334
+ case 'approval_requested':
335
+ requiredString('requestId');
336
+ requiredString('tool');
337
+ break;
338
+ case 'approval_resolved':
339
+ requiredString('requestId');
340
+ if (event.decision !== 'allow' && event.decision !== 'deny') {
341
+ throw new Error(`machine-protocol: ${where} approval_resolved event has invalid decision`);
342
+ }
343
+ break;
344
+ case 'result':
345
+ if (typeof event.ok !== 'boolean') {
346
+ throw new Error(`machine-protocol: ${where} result event is missing ok`);
347
+ }
348
+ break;
349
+ }
350
+ return event;
351
+ }
352
+ /**
353
+ * The agent-run binding, checked the way `machine_session`'s own constraint
354
+ * checks it, so a frame the tenant would refuse is refused before it is ever
355
+ * asked: `runId` and `agentMode` come together or not at all, `cliSessionId`
356
+ * names a CLI session and so needs `agentMode: 'cli'`, and a bound session is
357
+ * headless — it is the run's process, not a terminal — so `interactive` is a
358
+ * contradiction rather than an option.
359
+ */
360
+ function validateAgentBinding(frame) {
361
+ const { runId, agentMode, cliSessionId } = frame;
362
+ if (runId !== undefined && (typeof runId !== 'string' || runId.length === 0)) {
363
+ throw new Error("machine-protocol: 'open' frame runId must be a non-empty string");
364
+ }
365
+ if (agentMode !== undefined && !AGENT_MODES.includes(agentMode)) {
366
+ throw new Error(`machine-protocol: 'open' frame agentMode must be one of ${AGENT_MODES.join(', ')}`);
367
+ }
368
+ if ((runId === undefined) !== (agentMode === undefined)) {
369
+ throw new Error("machine-protocol: 'open' frame needs both runId and agentMode, or neither");
370
+ }
371
+ if (cliSessionId !== undefined) {
372
+ if (typeof cliSessionId !== 'string' || cliSessionId.length === 0) {
373
+ throw new Error("machine-protocol: 'open' frame cliSessionId must be a non-empty string");
374
+ }
375
+ if (agentMode !== 'cli') {
376
+ throw new Error("machine-protocol: 'open' frame cliSessionId requires agentMode 'cli'");
377
+ }
378
+ }
379
+ if (runId !== undefined && frame.interactive === true) {
380
+ throw new Error("machine-protocol: 'open' frame bound to a run cannot be interactive");
381
+ }
382
+ if (frame.cwd !== undefined && (typeof frame.cwd !== 'string' || frame.cwd.length === 0)) {
383
+ throw new Error("machine-protocol: 'open' frame cwd must be a non-empty string");
384
+ }
385
+ }
143
386
  export function encodeFrame(frame) {
144
387
  return JSON.stringify(frame);
145
388
  }
@@ -177,12 +420,22 @@ export function decodeFrame(raw) {
177
420
  throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
178
421
  }
179
422
  }
423
+ validateAgentBinding(frame);
180
424
  break;
181
425
  case 'input':
182
426
  case 'output':
183
427
  if (typeof frame.data !== 'string') {
184
428
  throw new Error(`machine-protocol: '${type}' frame is missing data`);
185
429
  }
430
+ if (type === 'output' &&
431
+ frame.stream !== undefined &&
432
+ frame.stream !== 'stdout' &&
433
+ frame.stream !== 'stderr') {
434
+ throw new Error("machine-protocol: 'output' frame stream must be 'stdout' or 'stderr'");
435
+ }
436
+ break;
437
+ case 'agent_event':
438
+ assertAgentEvent(frame.event);
186
439
  break;
187
440
  case 'resize':
188
441
  if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
package/index.d.ts CHANGED
@@ -45,6 +45,28 @@ export declare function parseClientAttachPath(path: string): {
45
45
  databaseId: string;
46
46
  machineId: string;
47
47
  } | null;
48
+ /**
49
+ * How a session bound to an agent run executes the agent: `cli` drives the
50
+ * user's own installed CLI, `embedded` runs our loop. Mirrors the
51
+ * `machine_session.agent_mode` check constraint.
52
+ */
53
+ export type AgentMode = 'cli' | 'embedded';
54
+ export declare const AGENT_MODES: readonly AgentMode[];
55
+ /**
56
+ * The agent-run binding an `open` frame may carry, client → relay. A bound
57
+ * session is headless: its process is the agent's, driven by the run rather
58
+ * than by a keyboard, so it never gets a pty. Like every coordinate on this
59
+ * wire the run id is a request — the tenant's SQL decides whether the opener
60
+ * may bind to that run. The relay keeps the binding; what reaches the runner
61
+ * is a plain `open` for the agent program, with the binding folded into its
62
+ * arguments.
63
+ */
64
+ export interface AgentBinding {
65
+ runId: string;
66
+ agentMode: AgentMode;
67
+ /** The CLI's own resumable session identifier; `cli` mode only. */
68
+ cliSessionId?: string;
69
+ }
48
70
  /** Client → runner: start a process in a new session. */
49
71
  export interface OpenFrame {
50
72
  type: 'open';
@@ -54,13 +76,27 @@ export interface OpenFrame {
54
76
  cols?: number;
55
77
  rows?: number;
56
78
  /**
57
- * Ask for a terminal rather than a pipe. An interactive session gets a pty,
79
+ * Ask for a terminal rather than pipes. An interactive session gets a pty,
58
80
  * so its output is opaque terminal bytes (escape sequences and all) and its
59
81
  * geometry is part of the session — which is what makes `vim` render and
60
- * reflow. Command mode (phase 1) is the default and unchanged.
82
+ * reflow. The default is a command on pipes: stdin in, stdout and stderr out
83
+ * (each `output` frame says which), no echo, no geometry.
61
84
  */
62
85
  interactive?: boolean;
86
+ /** Agent run this session executes locally. Requires `agentMode`. */
87
+ runId?: string;
88
+ /** How a bound session runs the agent. Requires `runId`. */
89
+ agentMode?: AgentMode;
90
+ /** The CLI's own session identifier; only with `agentMode: 'cli'`. */
91
+ cliSessionId?: string;
92
+ /**
93
+ * Working directory the session runs in, as a request: resolved against the
94
+ * runner's policy root, and refused when it would leave it.
95
+ */
96
+ cwd?: string;
63
97
  }
98
+ /** The binding an `open` frame carries, or null for a plain session. */
99
+ export declare function agentBinding(frame: OpenFrame): AgentBinding | null;
64
100
  /** Client → runner: bytes for the session's stdin. */
65
101
  export interface InputFrame {
66
102
  type: 'input';
@@ -124,6 +160,95 @@ export interface OutputFrame {
124
160
  type: 'output';
125
161
  sessionId: string;
126
162
  data: string;
163
+ /**
164
+ * Which pipe the bytes came from, on a non-interactive session. A terminal
165
+ * has one stream, so a pty session's output carries none; a program on
166
+ * pipes has two, and a consumer parsing stdout line by line (an agent's
167
+ * events) must not have stderr spliced into it.
168
+ */
169
+ stream?: 'stdout' | 'stderr';
170
+ }
171
+ export type AgentEvent = {
172
+ kind: 'session';
173
+ cliSessionId: string;
174
+ } | {
175
+ kind: 'text';
176
+ text: string;
177
+ } | {
178
+ kind: 'tool_call';
179
+ id: string;
180
+ name: string;
181
+ input: unknown;
182
+ } | {
183
+ kind: 'tool_result';
184
+ id: string;
185
+ output: unknown;
186
+ isError?: boolean;
187
+ } | {
188
+ kind: 'approval_requested';
189
+ requestId: string;
190
+ tool: string;
191
+ input: unknown;
192
+ reason?: string;
193
+ } | {
194
+ kind: 'approval_resolved';
195
+ requestId: string;
196
+ decision: 'allow' | 'deny';
197
+ reason?: string;
198
+ } | {
199
+ kind: 'result';
200
+ ok: boolean;
201
+ summary?: string;
202
+ usage?: unknown;
203
+ costUsd?: number;
204
+ };
205
+ export declare const AGENT_EVENT_KINDS: readonly AgentEvent['kind'][];
206
+ export interface AgentEventFrame {
207
+ type: 'agent_event';
208
+ sessionId: string;
209
+ event: AgentEvent;
210
+ }
211
+ export type ApprovalDecision = 'allow' | 'deny';
212
+ /**
213
+ * The program the relay runs for a `cli` binding: it wraps the CLI the opener
214
+ * named (`claude`, `codex`) and translates its stream to this contract. The
215
+ * machine's policy must allow it by this exact name.
216
+ */
217
+ export declare const AGENT_CLI_COMMAND = "constructive-agent-cli";
218
+ /**
219
+ * Relay → agent program (a line on its stdin): the answer to an
220
+ * `approval_requested` event the program emitted. Carries no identity: who
221
+ * answered was the run's RLS, asked on the bound run's log.
222
+ */
223
+ export interface ApprovalDecisionLine {
224
+ kind: 'approval_decision';
225
+ requestId: string;
226
+ decision: ApprovalDecision;
227
+ reason?: string;
228
+ }
229
+ export declare function encodeApprovalDecisionLine(line: ApprovalDecisionLine): string;
230
+ /** The decision a stdin line carries, or null when the line is a prompt. */
231
+ export declare function parseApprovalDecisionLine(line: string): ApprovalDecisionLine | null;
232
+ /** The longest line a {@link LineSplitter} buffers before giving up on it. */
233
+ export declare const MAX_LINE_LENGTH: number;
234
+ /**
235
+ * Split a stream into the complete lines it has delivered so far. Keeps the
236
+ * unterminated tail for the next chunk; `flush` hands it over at the end. A
237
+ * tail that outgrows `maxLineLength` is dropped and reported, so a program
238
+ * that never writes a newline cannot grow the reader without bound.
239
+ */
240
+ export declare class LineSplitter {
241
+ private readonly maxLineLength;
242
+ private tail;
243
+ constructor(maxLineLength?: number);
244
+ /**
245
+ * Complete lines are delivered before an oversized tail in the same chunk is
246
+ * reported: the failure is raised on the next call instead of losing them.
247
+ */
248
+ private overflowed;
249
+ push(chunk: string): string[];
250
+ private raiseOverflow;
251
+ flush(): string[];
127
252
  }
128
253
  /** Runner → client: the process ended. */
129
254
  export interface ExitFrame {
@@ -141,6 +266,8 @@ export interface ErrorFrame {
141
266
  type: 'error';
142
267
  sessionId?: string;
143
268
  message: string;
269
+ /** A stable, machine-readable reason, where the sender has one. */
270
+ code?: string;
144
271
  }
145
272
  /**
146
273
  * Runner → relay: the scrollback a detaching session leaves behind, capped by
@@ -199,8 +326,23 @@ export interface EnrolledFrame {
199
326
  expiresAt: number;
200
327
  }
201
328
  export type ClientToRunnerFrame = OpenFrame | InputFrame | ResizeFrame | SignalFrame | DetachFrame | ReattachFrame | CloseFrame;
202
- export type RunnerToClientFrame = OutputFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
329
+ export type RunnerToClientFrame = OutputFrame | AgentEventFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
203
330
  export type Frame = ClientToRunnerFrame | RunnerToClientFrame | AttachedFrame | DetachedFrame | ReattachedFrame | EnrolledFrame;
331
+ /**
332
+ * Whether `value` looks like an {@link AgentEvent} — an object naming one of
333
+ * the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
334
+ * headless agent process speaks these on its stdout one per line, and this is
335
+ * how a relaying runner tells an event line from any other line it prints.
336
+ */
337
+ export declare function isAgentEventLike(value: unknown): value is Record<string, unknown> & {
338
+ kind: AgentEvent['kind'];
339
+ };
340
+ /**
341
+ * `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
342
+ * wrong with it. `where` names the carrier in the message (the frame, a
343
+ * process's stdout) so the fault is attributed.
344
+ */
345
+ export declare function assertAgentEvent(value: unknown, where?: string): AgentEvent;
204
346
  export declare function encodeFrame(frame: Frame): string;
205
347
  /**
206
348
  * Parse one wire message into a frame. Malformed input is a protocol fault on
package/index.js CHANGED
@@ -15,6 +15,19 @@
15
15
  // a decision. That is what makes a reattach from a different client safe —
16
16
  // nothing about the attach that opened the session carries over.
17
17
  //
18
+ // Two vocabularies live here, and the line between them is the line between the
19
+ // runner and everything above it:
20
+ //
21
+ // - The *machine* frames (`open` … `exit`, plus enrollment) are the remote
22
+ // control. A runner speaks only these: it runs an allow-listed command in a
23
+ // pty or on pipes, moves bytes, reports the exit. It has no notion of what the
24
+ // command is.
25
+ // - The *agent* vocabulary (`AgentBinding` on an `open`, `AgentEvent`, the
26
+ // `agent_event` frame, the stdio contract below) is between a client, the
27
+ // relay and an agent program running *under* the runner. The runner never
28
+ // reads or writes any of it; to the runner an agent program is a command on
29
+ // pipes whose stdout happens to be JSON lines.
30
+ //
18
31
  // This package is vocabulary, never policy. Everything a socket puts on the wire
19
32
  // — its database, its machine id — is a *coordinate*: it says which row the
20
33
  // socket is asking about, and the relay answers by asking that database's SQL.
@@ -23,9 +36,14 @@
23
36
  // the tenant that issued it, and RLS decides everything after that. Addressing a
24
37
  // database you have no credential for is simply a refusal.
25
38
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.SIGNAL_NAMES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
39
+ exports.LineSplitter = exports.MAX_LINE_LENGTH = exports.AGENT_CLI_COMMAND = exports.AGENT_EVENT_KINDS = exports.SIGNAL_NAMES = exports.AGENT_MODES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
27
40
  exports.credentialFromSubprotocols = credentialFromSubprotocols;
28
41
  exports.parseClientAttachPath = parseClientAttachPath;
42
+ exports.agentBinding = agentBinding;
43
+ exports.encodeApprovalDecisionLine = encodeApprovalDecisionLine;
44
+ exports.parseApprovalDecisionLine = parseApprovalDecisionLine;
45
+ exports.isAgentEventLike = isAgentEventLike;
46
+ exports.assertAgentEvent = assertAgentEvent;
29
47
  exports.encodeFrame = encodeFrame;
30
48
  exports.decodeFrame = decodeFrame;
31
49
  // Token hashing is deliberately NOT re-exported here: it needs node's `crypto`,
@@ -95,6 +113,17 @@ function parseClientAttachPath(path) {
95
113
  machineId: decodeURIComponent(match[2])
96
114
  };
97
115
  }
116
+ exports.AGENT_MODES = ['cli', 'embedded'];
117
+ /** The binding an `open` frame carries, or null for a plain session. */
118
+ function agentBinding(frame) {
119
+ if (frame.runId === undefined || frame.agentMode === undefined)
120
+ return null;
121
+ return {
122
+ runId: frame.runId,
123
+ agentMode: frame.agentMode,
124
+ ...(frame.cliSessionId !== undefined ? { cliSessionId: frame.cliSessionId } : {})
125
+ };
126
+ }
98
127
  /**
99
128
  * Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
100
129
  * interrupt characters, so on a pty they are delivered the way a keyboard
@@ -103,6 +132,125 @@ function parseClientAttachPath(path) {
103
132
  * session. The rest are delivered to the session leader and do end it.
104
133
  */
105
134
  exports.SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
135
+ exports.AGENT_EVENT_KINDS = [
136
+ 'session',
137
+ 'text',
138
+ 'tool_call',
139
+ 'tool_result',
140
+ 'approval_requested',
141
+ 'approval_resolved',
142
+ 'result'
143
+ ];
144
+ // ---------------------------------------------------------------------------
145
+ // The agent program contract. An agent runs under the runner as an ordinary
146
+ // non-interactive command; this is what it speaks on its pipes, and the relay
147
+ // is the peer that reads and writes it — not the runner, which sees bytes.
148
+ //
149
+ // stdin — one prompt per line, and {@link ApprovalDecisionLine}s as JSON
150
+ // lines where the relay answers a question the program asked;
151
+ // stdout — {@link AgentEvent}s as JSON lines; any other line is plain output;
152
+ // stderr — plain output;
153
+ // exit — the program's own.
154
+ //
155
+ // `constructive-agent-host` (`@constructive-db/agent-host`) speaks it natively.
156
+ // `constructive-agent-cli` (`@constructive-db/agent-cli`) speaks it on behalf
157
+ // of a coding-agent CLI (`claude`, `codex`) that does not.
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * The program the relay runs for a `cli` binding: it wraps the CLI the opener
161
+ * named (`claude`, `codex`) and translates its stream to this contract. The
162
+ * machine's policy must allow it by this exact name.
163
+ */
164
+ exports.AGENT_CLI_COMMAND = 'constructive-agent-cli';
165
+ function encodeApprovalDecisionLine(line) {
166
+ return `${JSON.stringify(line)}\n`;
167
+ }
168
+ /** The decision a stdin line carries, or null when the line is a prompt. */
169
+ function parseApprovalDecisionLine(line) {
170
+ if (!line.startsWith('{'))
171
+ return null;
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(line);
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
180
+ return null;
181
+ const value = parsed;
182
+ if (value.kind !== 'approval_decision')
183
+ return null;
184
+ if (typeof value.requestId !== 'string' || value.requestId.length === 0) {
185
+ throw new Error('machine-protocol: approval_decision line is missing requestId');
186
+ }
187
+ if (value.decision !== 'allow' && value.decision !== 'deny') {
188
+ throw new Error('machine-protocol: approval_decision line has invalid decision');
189
+ }
190
+ const reason = value.reason;
191
+ if (reason !== undefined && typeof reason !== 'string') {
192
+ throw new Error('machine-protocol: approval_decision line reason must be a string');
193
+ }
194
+ const decoded = {
195
+ kind: 'approval_decision',
196
+ requestId: value.requestId,
197
+ decision: value.decision
198
+ };
199
+ if (typeof reason === 'string')
200
+ decoded.reason = reason;
201
+ return decoded;
202
+ }
203
+ /** The longest line a {@link LineSplitter} buffers before giving up on it. */
204
+ exports.MAX_LINE_LENGTH = 1024 * 1024;
205
+ /**
206
+ * Split a stream into the complete lines it has delivered so far. Keeps the
207
+ * unterminated tail for the next chunk; `flush` hands it over at the end. A
208
+ * tail that outgrows `maxLineLength` is dropped and reported, so a program
209
+ * that never writes a newline cannot grow the reader without bound.
210
+ */
211
+ class LineSplitter {
212
+ maxLineLength;
213
+ tail = '';
214
+ constructor(maxLineLength = exports.MAX_LINE_LENGTH) {
215
+ this.maxLineLength = maxLineLength;
216
+ }
217
+ /**
218
+ * Complete lines are delivered before an oversized tail in the same chunk is
219
+ * reported: the failure is raised on the next call instead of losing them.
220
+ */
221
+ overflowed = false;
222
+ push(chunk) {
223
+ this.raiseOverflow();
224
+ this.tail += chunk;
225
+ const lines = this.tail.split('\n');
226
+ this.tail = lines.pop() ?? '';
227
+ if (this.tail.length > this.maxLineLength) {
228
+ this.tail = '';
229
+ this.overflowed = true;
230
+ if (lines.length === 0)
231
+ this.raiseOverflow();
232
+ }
233
+ return lines.map(stripCarriageReturn);
234
+ }
235
+ raiseOverflow() {
236
+ if (!this.overflowed)
237
+ return;
238
+ this.overflowed = false;
239
+ throw new Error(`machine-protocol: line exceeds ${this.maxLineLength} characters without a newline`);
240
+ }
241
+ flush() {
242
+ this.raiseOverflow();
243
+ if (!this.tail)
244
+ return [];
245
+ const line = stripCarriageReturn(this.tail);
246
+ this.tail = '';
247
+ return [line];
248
+ }
249
+ }
250
+ exports.LineSplitter = LineSplitter;
251
+ function stripCarriageReturn(line) {
252
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
253
+ }
106
254
  const FRAME_TYPES = new Set([
107
255
  'open',
108
256
  'input',
@@ -112,6 +260,7 @@ const FRAME_TYPES = new Set([
112
260
  'reattach',
113
261
  'close',
114
262
  'output',
263
+ 'agent_event',
115
264
  'exit',
116
265
  'error',
117
266
  'scrollback',
@@ -130,6 +279,7 @@ const SESSION_FRAME_TYPES = new Set([
130
279
  'reattach',
131
280
  'close',
132
281
  'output',
282
+ 'agent_event',
133
283
  'exit',
134
284
  'scrollback',
135
285
  'detached',
@@ -149,6 +299,105 @@ function validDimension(value) {
149
299
  value > 0 &&
150
300
  value <= MAX_DIMENSION);
151
301
  }
302
+ function isPlainObject(value) {
303
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
304
+ }
305
+ /**
306
+ * Whether `value` looks like an {@link AgentEvent} — an object naming one of
307
+ * the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
308
+ * headless agent process speaks these on its stdout one per line, and this is
309
+ * how a relaying runner tells an event line from any other line it prints.
310
+ */
311
+ function isAgentEventLike(value) {
312
+ return (isPlainObject(value) &&
313
+ typeof value.kind === 'string' &&
314
+ exports.AGENT_EVENT_KINDS.includes(value.kind));
315
+ }
316
+ /**
317
+ * `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
318
+ * wrong with it. `where` names the carrier in the message (the frame, a
319
+ * process's stdout) so the fault is attributed.
320
+ */
321
+ function assertAgentEvent(value, where = "'agent_event' frame") {
322
+ if (!isPlainObject(value)) {
323
+ throw new Error(`machine-protocol: ${where} is missing event`);
324
+ }
325
+ const event = value;
326
+ const kind = event.kind;
327
+ if (typeof kind !== 'string' || !exports.AGENT_EVENT_KINDS.includes(kind)) {
328
+ throw new Error(`machine-protocol: ${where} has unknown event kind '${String(kind)}'`);
329
+ }
330
+ const requiredString = (field) => {
331
+ if (typeof event[field] !== 'string') {
332
+ throw new Error(`machine-protocol: ${where} ${kind} event is missing ${field}`);
333
+ }
334
+ };
335
+ switch (kind) {
336
+ case 'session':
337
+ requiredString('cliSessionId');
338
+ break;
339
+ case 'text':
340
+ requiredString('text');
341
+ break;
342
+ case 'tool_call':
343
+ requiredString('id');
344
+ requiredString('name');
345
+ break;
346
+ case 'tool_result':
347
+ requiredString('id');
348
+ break;
349
+ case 'approval_requested':
350
+ requiredString('requestId');
351
+ requiredString('tool');
352
+ break;
353
+ case 'approval_resolved':
354
+ requiredString('requestId');
355
+ if (event.decision !== 'allow' && event.decision !== 'deny') {
356
+ throw new Error(`machine-protocol: ${where} approval_resolved event has invalid decision`);
357
+ }
358
+ break;
359
+ case 'result':
360
+ if (typeof event.ok !== 'boolean') {
361
+ throw new Error(`machine-protocol: ${where} result event is missing ok`);
362
+ }
363
+ break;
364
+ }
365
+ return event;
366
+ }
367
+ /**
368
+ * The agent-run binding, checked the way `machine_session`'s own constraint
369
+ * checks it, so a frame the tenant would refuse is refused before it is ever
370
+ * asked: `runId` and `agentMode` come together or not at all, `cliSessionId`
371
+ * names a CLI session and so needs `agentMode: 'cli'`, and a bound session is
372
+ * headless — it is the run's process, not a terminal — so `interactive` is a
373
+ * contradiction rather than an option.
374
+ */
375
+ function validateAgentBinding(frame) {
376
+ const { runId, agentMode, cliSessionId } = frame;
377
+ if (runId !== undefined && (typeof runId !== 'string' || runId.length === 0)) {
378
+ throw new Error("machine-protocol: 'open' frame runId must be a non-empty string");
379
+ }
380
+ if (agentMode !== undefined && !exports.AGENT_MODES.includes(agentMode)) {
381
+ throw new Error(`machine-protocol: 'open' frame agentMode must be one of ${exports.AGENT_MODES.join(', ')}`);
382
+ }
383
+ if ((runId === undefined) !== (agentMode === undefined)) {
384
+ throw new Error("machine-protocol: 'open' frame needs both runId and agentMode, or neither");
385
+ }
386
+ if (cliSessionId !== undefined) {
387
+ if (typeof cliSessionId !== 'string' || cliSessionId.length === 0) {
388
+ throw new Error("machine-protocol: 'open' frame cliSessionId must be a non-empty string");
389
+ }
390
+ if (agentMode !== 'cli') {
391
+ throw new Error("machine-protocol: 'open' frame cliSessionId requires agentMode 'cli'");
392
+ }
393
+ }
394
+ if (runId !== undefined && frame.interactive === true) {
395
+ throw new Error("machine-protocol: 'open' frame bound to a run cannot be interactive");
396
+ }
397
+ if (frame.cwd !== undefined && (typeof frame.cwd !== 'string' || frame.cwd.length === 0)) {
398
+ throw new Error("machine-protocol: 'open' frame cwd must be a non-empty string");
399
+ }
400
+ }
152
401
  function encodeFrame(frame) {
153
402
  return JSON.stringify(frame);
154
403
  }
@@ -186,12 +435,22 @@ function decodeFrame(raw) {
186
435
  throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
187
436
  }
188
437
  }
438
+ validateAgentBinding(frame);
189
439
  break;
190
440
  case 'input':
191
441
  case 'output':
192
442
  if (typeof frame.data !== 'string') {
193
443
  throw new Error(`machine-protocol: '${type}' frame is missing data`);
194
444
  }
445
+ if (type === 'output' &&
446
+ frame.stream !== undefined &&
447
+ frame.stream !== 'stdout' &&
448
+ frame.stream !== 'stderr') {
449
+ throw new Error("machine-protocol: 'output' frame stream must be 'stdout' or 'stderr'");
450
+ }
451
+ break;
452
+ case 'agent_event':
453
+ assertAgentEvent(frame.event);
195
454
  break;
196
455
  case 'resize':
197
456
  if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@constructive-db/machine-protocol",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Wire protocol shared by the machine relay and the machine runner: the JSON frames a session is made of, and the codec that refuses malformed ones.",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "license": "MIT",
7
+ "homepage": "https://github.com/constructive-io/constructive",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/constructive-io/constructive",
11
+ "directory": "agentic/machine-protocol"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/constructive-io/constructive/issues"
15
+ },
7
16
  "main": "index.js",
8
17
  "module": "esm/index.js",
9
18
  "types": "index.d.ts",
@@ -11,19 +20,13 @@
11
20
  "access": "public",
12
21
  "directory": "dist"
13
22
  },
14
- "devDependencies": {
15
- "@types/jest": "^29.5.14",
16
- "@types/node": "^22.10.4",
17
- "jest": "^29.7.0",
18
- "makage": "^0.1.12",
19
- "ts-jest": "^29.2.0",
20
- "typescript": "^5.1.6"
21
- },
22
23
  "scripts": {
24
+ "prepack": "npm run build",
23
25
  "build": "makage build && cp LICENSE dist/LICENSE",
24
26
  "build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
25
27
  "clean": "makage clean",
26
28
  "lint": "eslint . --fix",
27
29
  "test": "jest"
28
- }
29
- }
30
+ },
31
+ "gitHead": "81eed19332cc231de0fde02bc6e87002357f9536"
32
+ }