@nanobpm/agentic 0.5.0 → 0.7.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/README.md CHANGED
@@ -13,7 +13,7 @@ The **Nano agentic protocol** (ADR 0056): one app-tier channel carrying agent pr
13
13
  | `@nanobpm/agentic/vocab` | Vocab resolver + core vocabulary (S3) |
14
14
  | `@nanobpm/agentic/demand` | Demand×supply model (S4) |
15
15
  | `@nanobpm/agentic/relay` | Relay ring + QoS scheduler (S5) |
16
- | `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) + turn-structured view (Camunda `AgentHistoryRecordValue` parity) |
16
+ | `@nanobpm/agentic/transcript` | Transcript store, retention-by-lifecycle (S6) + turn-structured view (Camunda `AgentHistoryRecordValue` parity) + the typed transcript-event **vocabulary** & single `parseTranscriptEvent` fold |
17
17
  | `@nanobpm/agentic/blackboard` | Blackboard channel family (S7) |
18
18
  | `@nanobpm/agentic/cockpit` | Operator visibility page — the cockpit (S8) |
19
19
  | `@nanobpm/agentic/session` | Canonical `SessionEvent` + authoritative session log for durable agent-session resume (ADR 0062) |
@@ -21,3 +21,60 @@ The **Nano agentic protocol** (ADR 0056): one app-tier channel carrying agent pr
21
21
  The barrel `@nanobpm/agentic` re-exports each family as a namespace (`protocol`, `channel`, …). The worker-side client ships separately as `@nanobpm/urban-agent-client`.
22
22
 
23
23
  The wire contract is the single source of truth; nothing here rides the Camunda-8 engine or its transport.
24
+
25
+ ## Transcript event vocabulary (`@nanobpm/agentic/transcript`)
26
+
27
+ The transcript subpath now ships the canonical typed transcript-event **vocabulary** alongside the S6
28
+ store, so every Urban app derives ACP-rich transcripts from **one** parser instead of forking its own:
29
+
30
+ ```ts
31
+ import {
32
+ parseTranscriptEvent, // THE ONE PARSER: stored chunk → typed TranscriptEvent
33
+ deriveView, // THE ONE FOLD: typed events → per-turn structured view
34
+ deriveViewFromChunks, // parse + fold in one call
35
+ mergeTranscriptVocab, // additive EXTENSION POINT (register a new kind, never fork)
36
+ CORE_TRANSCRIPT_VOCAB,
37
+ encodeTranscriptEvent,
38
+ utf8ByteLength, // browser-safe (TextEncoder, no Buffer)
39
+ TRANSCRIPT_EVENT_MARKER, // "nwfTranscriptEvent" — canonical single source of truth
40
+ TRANSCRIPT_EVENT_VERSION, // 1
41
+ } from "@nanobpm/agentic/transcript";
42
+ ```
43
+
44
+ The vocab/parser modules are **browser-safe** (no `Buffer`, no Node-only imports), so the cockpit derive
45
+ runs in-browser. Kinds: `message`, `tool-call`, `tool-result`, `turn`, `step`, `lifecycle`, plus the
46
+ raw `stream-chunk` fallback that preserves byte-level replay fidelity. A stored chunk is decoded as a
47
+ structured event **only** when it is a JSON object carrying `TRANSCRIPT_EVENT_MARKER` at the current
48
+ version — otherwise it is retained verbatim, so a raw ANSI frame that happens to be JSON is never
49
+ mis-classified. `TRANSCRIPT_EVENT_MARKER` + `parseTranscriptEvent` are the canonical detection surface
50
+ the whole package family (e.g. the cockpit's structured-stream drill-in) imports from here — never a
51
+ private copy.
52
+
53
+ ### Extending the vocabulary (merge, don't fork)
54
+
55
+ A downstream app adds its own kind + parse handler **without editing this package**:
56
+
57
+ ```ts
58
+ const appVocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
59
+ permission: (body, offset) =>
60
+ typeof body.requestId === "string"
61
+ ? { kind: "message", offset, role: "system", text: `permission(${body.requestId})` }
62
+ : undefined, // reject malformed → raw fallback
63
+ });
64
+ parseTranscriptEvent({ offset, chunk }, appVocab); // the one parser, now aware of `permission`
65
+ ```
66
+
67
+ ### Migration for nano-workforce
68
+
69
+ `nano-workforce` currently forks this vocabulary at `app/agentic/transcript-events.ts`. To consume the
70
+ shared copy:
71
+
72
+ 1. Replace imports of the local `./transcript-events.ts` with `@nanobpm/agentic/transcript`
73
+ (`parseTranscriptEvent`, `deriveView`, `mergeTranscriptVocab`, `TRANSCRIPT_EVENT_MARKER`,
74
+ `TRANSCRIPT_EVENT_VERSION`, `utf8ByteLength`, the event types, …). The public API — symbol names,
75
+ the `"nwfTranscriptEvent"` marker string, and version `1` — is preserved verbatim, so this is a
76
+ drop-in.
77
+ 2. Register nano-workforce's app-specific `permission` kind (nano-workforce#559) via
78
+ `mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, { permission: … })` instead of editing a forked parser.
79
+ 3. **Delete** `app/agentic/transcript-events.ts` (and fold its `transcript-events.drift.test.ts` marker
80
+ guard into the shared package's guard, already ported here as `events.drift.test.ts`).
@@ -0,0 +1,32 @@
1
+ import { type InboundControlErrorCode, type InboundControlFrame } from "../control.ts";
2
+ /**
3
+ * Shared corpus for the INBOUND control vocabulary (steer-in). These vectors are
4
+ * exported so cross-repo consumers (the c8ctl harness) decode the SAME frames.
5
+ *
6
+ * `chunk` is the raw inbound steer string a peer sends; `frame` is the typed
7
+ * {@link InboundControlFrame} it must decode to; `structured` records whether it
8
+ * is a recognised control envelope (`true`) or the legacy bare-string-as-prompt
9
+ * fall-back (`false`). A `roundTrips: false` vector decodes to `frame` but does
10
+ * NOT re-encode to the same `chunk` (a legacy bare string, or an envelope with
11
+ * extra tolerated fields), so the round-trip test skips re-encoding it.
12
+ */
13
+ export interface ValidControlFrame {
14
+ readonly name: string;
15
+ readonly chunk: string;
16
+ readonly frame: InboundControlFrame;
17
+ readonly structured: boolean;
18
+ readonly roundTrips: boolean;
19
+ }
20
+ export declare const VALID_CONTROL_FRAMES: readonly ValidControlFrame[];
21
+ /**
22
+ * Adversarial inbound control vectors: a chunk TAGGED as a control envelope but
23
+ * malformed. Each MUST be rejected with the exact {@link InboundControlErrorCode}.
24
+ * A missing tag is NOT here — an untagged chunk is a valid legacy prompt (see
25
+ * {@link VALID_CONTROL_FRAMES}), never an error.
26
+ */
27
+ export interface MalformedControlFrame {
28
+ readonly name: string;
29
+ readonly chunk: string;
30
+ readonly expected: InboundControlErrorCode;
31
+ }
32
+ export declare const MALFORMED_CONTROL_FRAMES: readonly MalformedControlFrame[];
@@ -0,0 +1,113 @@
1
+ import { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, } from "../control.js";
2
+ const MARKER = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
3
+ export const VALID_CONTROL_FRAMES = [
4
+ {
5
+ name: "prompt-structured",
6
+ chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "run the tests" }),
7
+ frame: { kind: "prompt", text: "run the tests" },
8
+ structured: true,
9
+ roundTrips: true,
10
+ },
11
+ {
12
+ name: "prompt-structured-empty-text",
13
+ chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "" }),
14
+ frame: { kind: "prompt", text: "" },
15
+ structured: true,
16
+ roundTrips: true,
17
+ },
18
+ {
19
+ name: "cancel-structured",
20
+ chunk: JSON.stringify({ ...MARKER, kind: "cancel" }),
21
+ frame: { kind: "cancel" },
22
+ structured: true,
23
+ roundTrips: true,
24
+ },
25
+ {
26
+ name: "cancel-structured-with-reason",
27
+ chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: "operator interrupt" }),
28
+ frame: { kind: "cancel", reason: "operator interrupt" },
29
+ structured: true,
30
+ roundTrips: true,
31
+ },
32
+ {
33
+ name: "permission-granted",
34
+ chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-7", outcome: "granted" }),
35
+ frame: { kind: "permission", requestId: "req-7", outcome: "granted" },
36
+ structured: true,
37
+ roundTrips: true,
38
+ },
39
+ {
40
+ name: "permission-denied",
41
+ chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-8", outcome: "denied" }),
42
+ frame: { kind: "permission", requestId: "req-8", outcome: "denied" },
43
+ structured: true,
44
+ roundTrips: true,
45
+ },
46
+ // Legacy raw-byte steer: a bare keystroke/line is NOT a control envelope and
47
+ // must decode as a prompt carrying the chunk verbatim — the no-regression path.
48
+ {
49
+ name: "legacy-keystroke-line",
50
+ chunk: "ls -la\n",
51
+ frame: { kind: "prompt", text: "ls -la\n" },
52
+ structured: false,
53
+ roundTrips: false,
54
+ },
55
+ {
56
+ name: "legacy-control-c",
57
+ chunk: "\u0003",
58
+ frame: { kind: "prompt", text: "\u0003" },
59
+ structured: false,
60
+ roundTrips: false,
61
+ },
62
+ {
63
+ name: "legacy-json-number-is-not-a-frame",
64
+ chunk: "42",
65
+ frame: { kind: "prompt", text: "42" },
66
+ structured: false,
67
+ roundTrips: false,
68
+ },
69
+ {
70
+ name: "legacy-untagged-json-object",
71
+ chunk: JSON.stringify({ kind: "prompt", text: "not tagged" }),
72
+ frame: { kind: "prompt", text: JSON.stringify({ kind: "prompt", text: "not tagged" }) },
73
+ structured: false,
74
+ roundTrips: false,
75
+ },
76
+ ];
77
+ export const MALFORMED_CONTROL_FRAMES = [
78
+ {
79
+ name: "tagged-missing-kind",
80
+ chunk: JSON.stringify({ ...MARKER, text: "no kind" }),
81
+ expected: "bad-kind",
82
+ },
83
+ {
84
+ name: "tagged-unknown-kind",
85
+ chunk: JSON.stringify({ ...MARKER, kind: "explode" }),
86
+ expected: "bad-kind",
87
+ },
88
+ {
89
+ name: "prompt-missing-text",
90
+ chunk: JSON.stringify({ ...MARKER, kind: "prompt" }),
91
+ expected: "bad-prompt-text",
92
+ },
93
+ {
94
+ name: "prompt-non-string-text",
95
+ chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: 123 }),
96
+ expected: "bad-prompt-text",
97
+ },
98
+ {
99
+ name: "cancel-non-string-reason",
100
+ chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: 5 }),
101
+ expected: "bad-cancel-reason",
102
+ },
103
+ {
104
+ name: "permission-missing-request-id",
105
+ chunk: JSON.stringify({ ...MARKER, kind: "permission", outcome: "granted" }),
106
+ expected: "bad-permission-request-id",
107
+ },
108
+ {
109
+ name: "permission-bad-outcome",
110
+ chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-9", outcome: "maybe" }),
111
+ expected: "bad-permission-outcome",
112
+ },
113
+ ];
@@ -11,3 +11,4 @@ export { GOLDEN_FRAMES, type GoldenFrame, } from "./frames.ts";
11
11
  export { MALFORMED_FRAMES, type MalformedFrame, } from "./malformed.ts";
12
12
  export { VALID_VOCABS, INVALID_VOCABS, type ValidVocab, type InvalidVocab, } from "./vocab.ts";
13
13
  export { VALID_TOKENS, INVALID_TOKENS, type ValidToken, type InvalidToken, } from "./tokens.ts";
14
+ export { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES, type ValidControlFrame, type MalformedControlFrame, } from "./control.ts";
@@ -11,3 +11,4 @@ export { GOLDEN_FRAMES, } from "./frames.js";
11
11
  export { MALFORMED_FRAMES, } from "./malformed.js";
12
12
  export { VALID_VOCABS, INVALID_VOCABS, } from "./vocab.js";
13
13
  export { VALID_TOKENS, INVALID_TOKENS, } from "./tokens.js";
14
+ export { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES, } from "./control.js";
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Typed INBOUND control vocabulary for the `relay` family (ADR 0056, ACP steer).
3
+ *
4
+ * The relay data frame ({@link RelayPayload} `{ stream, offset, chunk }`) is the
5
+ * OUTBOUND (hub → consumer) byte lane and is left untouched. This module adds an
6
+ * ADDITIVE vocabulary for the INBOUND (consumer → agent) steer lane, where a
7
+ * peer historically sent a bare `chunk` string that a PTY role fed straight to
8
+ * the terminal as keystrokes. ACP steering must now distinguish three intents:
9
+ *
10
+ * - `prompt` — start a new turn (carries the prompt text);
11
+ * - `cancel` — interrupt the current turn (optionally with a reason);
12
+ * - `permission` — answer/release a blocked `session/request_permission`
13
+ * (carries the request id being answered + the outcome).
14
+ *
15
+ * ## Backward compatibility (raw-byte steer)
16
+ *
17
+ * The vocabulary is a STRICT superset of the legacy raw-byte path. A structured
18
+ * frame is a JSON envelope TAGGED with {@link CONTROL_FRAME_MARKER}; a bare
19
+ * inbound string that is NOT such a tagged envelope (a keystroke, a shell line,
20
+ * unrelated JSON) continues to decode as a `prompt` whose `text` is the original
21
+ * chunk verbatim. Existing raw-byte producers and consumers need not change.
22
+ *
23
+ * ## Decode order (see {@link parseInboundRelayChunk})
24
+ *
25
+ * 1. If the chunk parses as a control envelope (a JSON object tagged with the
26
+ * marker at the current version) → validate it and return the typed frame;
27
+ * a tagged-but-malformed envelope is an ERROR, never a silent prompt.
28
+ * 2. Otherwise → legacy fall-back: `{ kind: "prompt", text: <chunk> }`.
29
+ *
30
+ * This is purely the inbound control vocabulary at the protocol seam: it changes
31
+ * neither the relay transport (ring, QoS, offsets, stream routing) nor the
32
+ * outbound `{ stream, offset, chunk }` data-frame shape.
33
+ */
34
+ /**
35
+ * The marker key that tags a chunk as a structured inbound control envelope.
36
+ * Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
37
+ * legacy bare-string steer and decodes as a `prompt`.
38
+ */
39
+ export declare const CONTROL_FRAME_MARKER: "nanoControlFrame";
40
+ /** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
41
+ export declare const CONTROL_FRAME_VERSION: 1;
42
+ /** The three inbound steer intents. */
43
+ export type InboundControlKind = "prompt" | "cancel" | "permission";
44
+ /** How a blocked `session/request_permission` was answered. */
45
+ export type PermissionOutcome = "granted" | "denied";
46
+ /** Start a new turn with the given prompt text. */
47
+ export interface PromptControlFrame {
48
+ readonly kind: "prompt";
49
+ readonly text: string;
50
+ }
51
+ /** Interrupt the current turn. `reason` is advisory, for telemetry/audit. */
52
+ export interface CancelControlFrame {
53
+ readonly kind: "cancel";
54
+ readonly reason?: string;
55
+ }
56
+ /** Answer a blocked `session/request_permission` identified by `requestId`. */
57
+ export interface PermissionControlFrame {
58
+ readonly kind: "permission";
59
+ readonly requestId: string;
60
+ readonly outcome: PermissionOutcome;
61
+ }
62
+ /** The typed inbound control vocabulary — a discriminated union on `kind`. */
63
+ export type InboundControlFrame = PromptControlFrame | CancelControlFrame | PermissionControlFrame;
64
+ export interface InboundControlError {
65
+ readonly code: InboundControlErrorCode;
66
+ readonly message: string;
67
+ }
68
+ /**
69
+ * The closed set of validation-error codes a tagged-but-malformed control
70
+ * envelope can raise. The conformance corpus derives its coverage from this
71
+ * union, so a new code cannot be added without a covering malformed vector.
72
+ */
73
+ export type InboundControlErrorCode = "bad-kind" | "bad-prompt-text" | "bad-cancel-reason" | "bad-permission-request-id" | "bad-permission-outcome";
74
+ /**
75
+ * The result of decoding an inbound steer chunk. `structured` distinguishes a
76
+ * recognised control envelope from the legacy bare-string-as-prompt fall-back,
77
+ * so a consumer can tell an explicit `prompt` frame from a raw keystroke.
78
+ */
79
+ export type InboundControlDecodeResult = {
80
+ readonly ok: true;
81
+ readonly frame: InboundControlFrame;
82
+ readonly structured: boolean;
83
+ } | {
84
+ readonly ok: false;
85
+ readonly errors: readonly InboundControlError[];
86
+ };
87
+ /**
88
+ * Is `value` a structured inbound control envelope (a plain object tagged with
89
+ * the marker at the current version)? This is the discriminator the decoder uses
90
+ * to choose the structured path over the legacy bare-string fall-back — it does
91
+ * NOT assert the envelope's `kind`-specific fields are well-formed.
92
+ */
93
+ export declare function isInboundControlEnvelope(value: unknown): value is Record<string, unknown>;
94
+ /**
95
+ * Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
96
+ *
97
+ * A chunk tagged as a control envelope is validated and returned as its typed
98
+ * frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
99
+ * discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
100
+ * — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
101
+ * raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
102
+ * (`structured: false`). See the module header for the full decode order.
103
+ */
104
+ export declare function parseInboundRelayChunk(chunk: string): InboundControlDecodeResult;
105
+ /**
106
+ * Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
107
+ * JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
108
+ * back through {@link parseInboundRelayChunk} to an equal frame.
109
+ */
110
+ export declare function encodeInboundControlFrame(frame: InboundControlFrame): string;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Typed INBOUND control vocabulary for the `relay` family (ADR 0056, ACP steer).
3
+ *
4
+ * The relay data frame ({@link RelayPayload} `{ stream, offset, chunk }`) is the
5
+ * OUTBOUND (hub → consumer) byte lane and is left untouched. This module adds an
6
+ * ADDITIVE vocabulary for the INBOUND (consumer → agent) steer lane, where a
7
+ * peer historically sent a bare `chunk` string that a PTY role fed straight to
8
+ * the terminal as keystrokes. ACP steering must now distinguish three intents:
9
+ *
10
+ * - `prompt` — start a new turn (carries the prompt text);
11
+ * - `cancel` — interrupt the current turn (optionally with a reason);
12
+ * - `permission` — answer/release a blocked `session/request_permission`
13
+ * (carries the request id being answered + the outcome).
14
+ *
15
+ * ## Backward compatibility (raw-byte steer)
16
+ *
17
+ * The vocabulary is a STRICT superset of the legacy raw-byte path. A structured
18
+ * frame is a JSON envelope TAGGED with {@link CONTROL_FRAME_MARKER}; a bare
19
+ * inbound string that is NOT such a tagged envelope (a keystroke, a shell line,
20
+ * unrelated JSON) continues to decode as a `prompt` whose `text` is the original
21
+ * chunk verbatim. Existing raw-byte producers and consumers need not change.
22
+ *
23
+ * ## Decode order (see {@link parseInboundRelayChunk})
24
+ *
25
+ * 1. If the chunk parses as a control envelope (a JSON object tagged with the
26
+ * marker at the current version) → validate it and return the typed frame;
27
+ * a tagged-but-malformed envelope is an ERROR, never a silent prompt.
28
+ * 2. Otherwise → legacy fall-back: `{ kind: "prompt", text: <chunk> }`.
29
+ *
30
+ * This is purely the inbound control vocabulary at the protocol seam: it changes
31
+ * neither the relay transport (ring, QoS, offsets, stream routing) nor the
32
+ * outbound `{ stream, offset, chunk }` data-frame shape.
33
+ */
34
+ /**
35
+ * The marker key that tags a chunk as a structured inbound control envelope.
36
+ * Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
37
+ * legacy bare-string steer and decodes as a `prompt`.
38
+ */
39
+ export const CONTROL_FRAME_MARKER = "nanoControlFrame";
40
+ /** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
41
+ export const CONTROL_FRAME_VERSION = 1;
42
+ function isPlainObject(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+ /**
46
+ * Is `value` a structured inbound control envelope (a plain object tagged with
47
+ * the marker at the current version)? This is the discriminator the decoder uses
48
+ * to choose the structured path over the legacy bare-string fall-back — it does
49
+ * NOT assert the envelope's `kind`-specific fields are well-formed.
50
+ */
51
+ export function isInboundControlEnvelope(value) {
52
+ return isPlainObject(value) && value[CONTROL_FRAME_MARKER] === CONTROL_FRAME_VERSION;
53
+ }
54
+ function validateEnvelope(env) {
55
+ const errors = [];
56
+ const kind = env.kind;
57
+ switch (kind) {
58
+ case "prompt": {
59
+ const text = env.text;
60
+ if (typeof text !== "string") {
61
+ errors.push({ code: "bad-prompt-text", message: "control.prompt.text must be a string" });
62
+ return { ok: false, errors };
63
+ }
64
+ return { ok: true, structured: true, frame: { kind: "prompt", text } };
65
+ }
66
+ case "cancel": {
67
+ if ("reason" in env && typeof env.reason !== "string") {
68
+ errors.push({ code: "bad-cancel-reason", message: "control.cancel.reason must be a string when present" });
69
+ }
70
+ if (errors.length > 0)
71
+ return { ok: false, errors };
72
+ const frame = typeof env.reason === "string" ? { kind: "cancel", reason: env.reason } : { kind: "cancel" };
73
+ return { ok: true, structured: true, frame };
74
+ }
75
+ case "permission": {
76
+ const requestId = env.requestId;
77
+ const outcome = env.outcome;
78
+ const validRequestId = typeof requestId === "string" && requestId.length > 0 ? requestId : undefined;
79
+ if (validRequestId === undefined) {
80
+ errors.push({
81
+ code: "bad-permission-request-id",
82
+ message: "control.permission.requestId must be a non-empty string",
83
+ });
84
+ }
85
+ const validOutcome = outcome === "granted" || outcome === "denied" ? outcome : undefined;
86
+ if (validOutcome === undefined) {
87
+ errors.push({
88
+ code: "bad-permission-outcome",
89
+ message: "control.permission.outcome must be 'granted' or 'denied'",
90
+ });
91
+ }
92
+ if (errors.length > 0 || validRequestId === undefined || validOutcome === undefined) {
93
+ return { ok: false, errors };
94
+ }
95
+ return {
96
+ ok: true,
97
+ structured: true,
98
+ frame: {
99
+ kind: "permission",
100
+ requestId: validRequestId,
101
+ outcome: validOutcome,
102
+ },
103
+ };
104
+ }
105
+ default: {
106
+ const seen = kind === undefined ? "<missing>" : JSON.stringify(kind);
107
+ return {
108
+ ok: false,
109
+ errors: [
110
+ {
111
+ code: "bad-kind",
112
+ message: `control.kind must be one of prompt|cancel|permission, got ${seen}`,
113
+ },
114
+ ],
115
+ };
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Whether `chunk` begins with a JSON object — i.e. its first character that is
121
+ * not JSON-insignificant whitespace (space, tab, LF, CR) is `{`. Used as a
122
+ * cheap, allocation-free gate before attempting a full `JSON.parse`: only a
123
+ * chunk that starts a JSON object can possibly be a tagged control envelope.
124
+ */
125
+ function startsWithJsonObject(chunk) {
126
+ for (let i = 0; i < chunk.length; i++) {
127
+ const code = chunk.charCodeAt(i);
128
+ // JSON insignificant whitespace: space (0x20), tab (0x09), LF (0x0a), CR (0x0d).
129
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
130
+ continue;
131
+ }
132
+ return code === 0x7b; // '{'
133
+ }
134
+ return false;
135
+ }
136
+ /**
137
+ * Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
138
+ *
139
+ * A chunk tagged as a control envelope is validated and returned as its typed
140
+ * frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
141
+ * discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
142
+ * — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
143
+ * raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
144
+ * (`structured: false`). See the module header for the full decode order.
145
+ */
146
+ export function parseInboundRelayChunk(chunk) {
147
+ // Fast path for the common per-keystroke steer: a control envelope is always a
148
+ // JSON *object*, so it must begin with `{` after any insignificant leading
149
+ // whitespace. Any chunk that does not is (and always was) a legacy bare-string
150
+ // prompt carrying the chunk verbatim, so skip the JSON.parse attempt entirely.
151
+ if (!startsWithJsonObject(chunk)) {
152
+ return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
153
+ }
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(chunk);
157
+ }
158
+ catch {
159
+ // Started with `{` but is not valid JSON. Legacy bare-string prompt.
160
+ return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
161
+ }
162
+ if (!isInboundControlEnvelope(parsed)) {
163
+ // Valid JSON but not a control envelope (unrelated JSON, or a plain string
164
+ // like "ls\n"): still a legacy bare-string prompt carrying the chunk as-is.
165
+ return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
166
+ }
167
+ return validateEnvelope(parsed);
168
+ }
169
+ /**
170
+ * Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
171
+ * JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
172
+ * back through {@link parseInboundRelayChunk} to an equal frame.
173
+ */
174
+ export function encodeInboundControlFrame(frame) {
175
+ const marker = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
176
+ switch (frame.kind) {
177
+ case "prompt":
178
+ return JSON.stringify({ ...marker, kind: "prompt", text: frame.text });
179
+ case "cancel":
180
+ return JSON.stringify(frame.reason === undefined
181
+ ? { ...marker, kind: "cancel" }
182
+ : { ...marker, kind: "cancel", reason: frame.reason });
183
+ case "permission":
184
+ return JSON.stringify({
185
+ ...marker,
186
+ kind: "permission",
187
+ requestId: frame.requestId,
188
+ outcome: frame.outcome,
189
+ });
190
+ }
191
+ }
@@ -20,4 +20,5 @@ export { encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, FRAME_MAG
20
20
  export { parseToken, formatToken, isValidToken, isSegmentName, isSeatLabel, TokenParseError, type RoutingToken, type TokenParseErrorCode, } from "./token.ts";
21
21
  export { validateVocabDocument, type VocabDocument, type VocabNetwork, type VocabRole, type VocabError, type VocabValidationResult, } from "./vocab/schema.ts";
22
22
  export { validatePayload, type Capability, type RegisterPayload, type HeartbeatPayload, type DeregisterPayload, type ServePayload, type DemandPayload, type BlackboardPayload, type BlackboardOp, type RelayPayload, type RelayProducePayload, type RelaySubscribePayload, type RelayCreditPayload, type RelaySubscribedPayload, type PayloadError, type PayloadValidationResult, } from "./payloads.ts";
23
+ export { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, isInboundControlEnvelope, parseInboundRelayChunk, encodeInboundControlFrame, type InboundControlKind, type PermissionOutcome, type PromptControlFrame, type CancelControlFrame, type PermissionControlFrame, type InboundControlFrame, type InboundControlError, type InboundControlErrorCode, type InboundControlDecodeResult, } from "./control.ts";
23
24
  export { bytesToHex, hexToBytes } from "./hex.ts";
@@ -20,4 +20,5 @@ export { encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, FRAME_MAG
20
20
  export { parseToken, formatToken, isValidToken, isSegmentName, isSeatLabel, TokenParseError, } from "./token.js";
21
21
  export { validateVocabDocument, } from "./vocab/schema.js";
22
22
  export { validatePayload, } from "./payloads.js";
23
+ export { CONTROL_FRAME_MARKER, CONTROL_FRAME_VERSION, isInboundControlEnvelope, parseInboundRelayChunk, encodeInboundControlFrame, } from "./control.js";
23
24
  export { bytesToHex, hexToBytes } from "./hex.js";