@anureo/adapter-hermes 0.0.0-stage → 0.3.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.
@@ -0,0 +1,206 @@
1
+ import { HarnessSdkError } from '@anureo/sdk';
2
+ import { array, identity, integer, object, protocolError, stableId, text } from "./native.js";
3
+ /**
4
+ * Internal interaction registry owned by HermesSession. Map replayed prompts and gateway requests
5
+ * to stable SDK interaction ids without blocking the serial reader on user answers. The two native
6
+ * mechanisms share this registry but retain their different acknowledgement semantics. The SDK
7
+ * Session owns handler cancellation and answer privacy. HermesSession owns channel cleanup and recovery.
8
+ */
9
+ export class HermesInteractions {
10
+ /** Capture callbacks supplied by HermesSession; resolve identity/channel lazily so pending answers use the recovered connection. */
11
+ constructor({ sessionId, rpc, sink, turnId, epoch, options }) {
12
+ this.sessionId = sessionId;
13
+ this.rpc = rpc;
14
+ this.sink = sink;
15
+ this.turnId = turnId;
16
+ this.epoch = epoch;
17
+ this.options = options;
18
+ }
19
+ /**
20
+ * Register an approval/clarification from HermesSession.apply or snapshot, ignoring replayed ids.
21
+ * Invalid prompts and a full registry reject registration. The sink can invoke the responder with
22
+ * an answer or null, which sends a denial or empty clarification. Delivery checks the current
23
+ * authority before sending once and requires a native ACK for the same prompt id. The sink
24
+ * observes delivery failures through the responder.
25
+ */
26
+ async prompt(value) {
27
+ const nativeId = identity(value.prompt_id), generation = integer(value.execution_generation), epoch = this.epoch();
28
+ const id = `hermes-prompt-${stableId(this.sessionId(), epoch, generation, nativeId)}`;
29
+ if (this.pending.has(id))
30
+ return;
31
+ const kind = value.kind;
32
+ if (kind !== 'approval' && kind !== 'clarify')
33
+ throw protocolError();
34
+ const request = kind === 'approval' ? approvalRequest(id, value)
35
+ : { interactionId: id, kind: 'userInput', questions: [this.question(nativeId, text(value.question), value.choices)] };
36
+ const scopedTurn = this.turnId(generation);
37
+ if (scopedTurn)
38
+ request.turnId = scopedTurn;
39
+ if (typeof value.timeout_s === 'number' && value.timeout_s > 0)
40
+ request.expiresAt = new Date(Date.now() + value.timeout_s * 1000).toISOString();
41
+ const pending = { id, nativeId, generation, wire: 'prompt', done: false, responding: false };
42
+ this.register(pending, request, async (response) => {
43
+ if (this.epoch() !== epoch)
44
+ throw new HarnessSdkError('INTERACTION_EXPIRED');
45
+ const params = { session_id: this.sessionId(), execution_generation: generation, prompt_id: nativeId };
46
+ const answer = kind === 'approval'
47
+ ? { ...params, choice: response?.kind === 'approval' ? response.optionId : 'deny' }
48
+ : { ...params, answer: response?.kind === 'userInput' ? this.answer(response.answers[nativeId]) : '' };
49
+ const result = object(await this.rpc().request(kind === 'approval' ? 'approval.respond' : 'clarify.respond', answer, true));
50
+ if (result.prompt_id !== nativeId || !['resolved', 'already_resolved'].includes(String(result.status)))
51
+ throw protocolError();
52
+ if (result.status === 'already_resolved')
53
+ throw new HarnessSdkError('INTERACTION_ALREADY_RESOLVED', 'Hermes prompt already settled', false, true);
54
+ });
55
+ }
56
+ /**
57
+ * Register session-scoped srq requests from HermesSession's live or held traffic, or from snapshot.
58
+ * Reply with a native error for unsupported requests and reject malformed supported payloads.
59
+ * The sink owns the wait for answers. The responder resolves after local send, so snapshot checks
60
+ * whether a finished delivery still appears open remotely.
61
+ */
62
+ async serverRequest(request) {
63
+ if (request.params.session_id !== this.sessionId()) {
64
+ await this.rpc().reject(request.id);
65
+ return;
66
+ }
67
+ if (typeof request.id !== 'string' || !request.id.startsWith('srq-')) {
68
+ await this.rpc().reject(request.id);
69
+ return;
70
+ }
71
+ const id = `hermes-request-${stableId(this.sessionId(), request.id)}`;
72
+ if (this.pending.has(id))
73
+ return;
74
+ let questions;
75
+ if (request.method === 'clarify') {
76
+ questions = this.batchQuestions(request.params);
77
+ }
78
+ else if (request.method === 'sudo' || request.method === 'secret') {
79
+ questions = [{ id: 'value', prompt: request.method === 'sudo' ? 'Hermes requests a sudo password' : text(request.params.prompt), secret: true }];
80
+ }
81
+ else {
82
+ await this.rpc().reject(request.id);
83
+ return;
84
+ }
85
+ const pending = { id, nativeId: request.id, wire: 'request', done: false, responding: false };
86
+ this.register(pending, { interactionId: id, kind: 'userInput', questions }, async (response) => {
87
+ const answers = response?.kind === 'userInput' ? response.answers : undefined;
88
+ const result = request.method === 'clarify'
89
+ ? { answers: Object.fromEntries(questions.map(question => [question.id, answers ? this.answer(answers[question.id]) : null])), outcome: answers ? 'submitted' : 'cancelled' }
90
+ : { value: answers ? this.answer(answers.value) : '' };
91
+ await this.rpc().reply(request.id, result);
92
+ });
93
+ }
94
+ /** Let deliveries already in flight settle before HermesSession.applyTurnEvent projects a terminal frame. The sink handles their outcomes. */
95
+ async waitResponders() {
96
+ // A terminal frame may beat the native response ACK. The reader still routes ACKs directly.
97
+ await Promise.all([...this.pending.values()].filter(value => value.responding && !value.done).map(value => value.completion));
98
+ }
99
+ /**
100
+ * Handle peer settlement from HermesSession.apply or withdrawal from snapshot. Match prompt
101
+ * generations to fence reused ids and leave in-flight delivery to its own settlement path.
102
+ * Publish an unknown outcome for a bare native resolved signal because it carries no answer.
103
+ * Sink publication failures reject to the caller.
104
+ */
105
+ async settled(target, reason) {
106
+ for (const pending of this.pending.values()) {
107
+ if (pending.done || pending.nativeId !== target.nativeId || pending.wire !== target.wire || target.wire === 'prompt' && pending.generation !== target.generation)
108
+ continue;
109
+ pending.cancelled = reason;
110
+ if (pending.responding)
111
+ continue; // The local native ACK arbitrates the response already in flight.
112
+ pending.done = true;
113
+ await this.sink.resolveInteraction({ interactionId: pending.id, outcome: reason === 'timeout' ? 'expired' : reason === 'resolved' ? 'unknown' : 'cancelled', reason: reason === 'resolved' ? 'native_settled' : reason });
114
+ }
115
+ }
116
+ /**
117
+ * HermesSession.snapshot reconciles open native questions before releasing held traffic. Withdraw
118
+ * missing questions and register new ones; reject RESULT_UNKNOWN if a locally finished delivery is
119
+ * still open remotely, preventing silent suppression or replay of a possibly delivered secret.
120
+ */
121
+ async snapshot(prompts, requests) {
122
+ const promptIds = new Set(prompts.map(value => identity(object(value).prompt_id)));
123
+ const requestIds = new Set(requests.map(value => identity(object(value).id)));
124
+ for (const value of this.pending.values())
125
+ if (value.done && !value.cancelled && (value.wire === 'prompt' ? promptIds : requestIds).has(value.nativeId)) {
126
+ // A response may have left this process without reaching the owner. Never replay a secret
127
+ // or silently suppress the still-open native question as though it had been answered.
128
+ throw new HarnessSdkError('RESULT_UNKNOWN', 'Hermes interaction delivery could not be confirmed', false, true);
129
+ }
130
+ for (const value of this.pending.values())
131
+ if (!value.done && !(value.wire === 'prompt' ? promptIds : requestIds).has(value.nativeId)) {
132
+ await this.settled(value, 'withdrawn');
133
+ }
134
+ for (const value of prompts)
135
+ await this.prompt(object(value));
136
+ for (const value of requests) {
137
+ const entry = object(value);
138
+ await this.serverRequest({ id: identity(entry.id), method: identity(entry.method), params: object(entry.params) });
139
+ }
140
+ }
141
+ pending = new Map();
142
+ /** Parse a nonempty clarify batch with distinct native question ids so one answer can be sent per id. */
143
+ batchQuestions(params) {
144
+ const questions = array(params.questions).map(value => {
145
+ const question = object(value);
146
+ return this.question(identity(question.qid), text(question.question), question.choices);
147
+ });
148
+ if (!questions.length || new Set(questions.map(value => value.id)).size !== questions.length)
149
+ throw protocolError();
150
+ return questions;
151
+ }
152
+ /** Normalize optional native choices; null, omission and an empty array all leave the question open-ended. */
153
+ question(id, prompt, choices) {
154
+ const values = choices == null ? [] : array(choices);
155
+ return { id, prompt, ...(values.length ? { options: values.map(value => ({ id: text(value), label: text(value) })) } : {}) };
156
+ }
157
+ /** Encode SDK answers for Hermes text fields; unanswered/null values become empty text and string selections are comma-separated. */
158
+ answer(value) {
159
+ if (Array.isArray(value) && value.every(entry => typeof entry === 'string'))
160
+ return value.join(', ');
161
+ if (value === undefined || value === null)
162
+ return '';
163
+ return text(value);
164
+ }
165
+ /**
166
+ * Admit within registry budgets and register the responder with the SDK sink. Session invokes it
167
+ * for unsupported or closed requests, and InteractionRegistry invokes it for answers and
168
+ * cancellations. Mark delivery in flight before writing so native settlement can defer to it.
169
+ * Observe fulfillment and rejection as local completion without blocking the reader on the user.
170
+ */
171
+ register(pending, request, respond) {
172
+ if ([...this.pending.values()].filter(value => !value.done).length >= (this.options.maxInteractions ?? 32))
173
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
174
+ if (this.pending.size >= (this.options.maxInteractions ?? 32) + 1024) {
175
+ const retired = [...this.pending].find(([, value]) => value.done);
176
+ if (retired)
177
+ this.pending.delete(retired[0]);
178
+ }
179
+ this.pending.set(pending.id, pending);
180
+ // Do not await the user from the receive loop. Core owns cancellation and response privacy.
181
+ pending.completion = this.sink.requestInteraction(request, async (response) => {
182
+ if (pending.done)
183
+ throw new HarnessSdkError('INTERACTION_EXPIRED');
184
+ pending.responding = true;
185
+ await respond(response);
186
+ if (pending.cancelled && pending.cancelled !== 'resolved')
187
+ throw new HarnessSdkError('RESULT_UNKNOWN', 'Hermes interaction ended while responding', false, true);
188
+ }).then(() => { pending.done = true; }, () => { pending.done = true; });
189
+ }
190
+ sessionId;
191
+ rpc;
192
+ sink;
193
+ turnId;
194
+ epoch;
195
+ options;
196
+ }
197
+ /** Offer only supported native approval choices and require an explicit denial path. */
198
+ function approvalRequest(id, value) {
199
+ const semantics = { once: 'allowOnce', session: 'allowSession', deny: 'deny' };
200
+ const options = array(value.choices).map(text).filter((choice) => Object.hasOwn(semantics, choice))
201
+ .map(choice => ({ id: choice, label: choice, semantic: semantics[choice] }));
202
+ if (!options.some(option => option.id === 'deny'))
203
+ throw protocolError();
204
+ return { interactionId: id, kind: 'approval', options, title: 'Hermes approval',
205
+ input: { ...(typeof value.command === 'string' ? { command: value.command } : {}), ...(typeof value.description === 'string' ? { description: value.description } : {}) } };
206
+ }
@@ -0,0 +1,97 @@
1
+ import { HarnessSdkError } from '@anureo/sdk';
2
+ import type { JsonObject, JsonValue } from '@anureo/protocol';
3
+ /** Internal gateway object after only a non-null, non-array shape check; fields remain untrusted. */
4
+ export type NativeObject = Record<string, unknown>;
5
+ /** Validated runtime identity and creation capabilities consumed by HermesSession.open and adapter initialization. */
6
+ export interface Description {
7
+ instanceId: string;
8
+ profileId: string;
9
+ /** Owner authority epoch; sessions bind executions and interaction answers to this authority. */
10
+ epoch: number;
11
+ /** Advertised session.create parameter names used to gate optional configuration. */
12
+ createParameters: string[];
13
+ /** True only when the ACP source, editor parameter and session MCP capability are all advertised. */
14
+ editorMcp: boolean;
15
+ }
16
+ /** Validated durable admission evidence; acceptance alone does not establish that execution has started or finished. */
17
+ export interface Receipt {
18
+ id: string;
19
+ /** Native admission sequence, distinct from the replay event sequence. */
20
+ sequence: number;
21
+ status: 'queued' | 'started' | 'unknown' | 'terminal';
22
+ /** Native outcome when supplied, passed to turn projection for terminal receipts. */
23
+ outcome: string | null;
24
+ epoch: number;
25
+ /** Required for started receipts; absent terminal generations can describe cancellation before execution. */
26
+ generation?: number;
27
+ /** Native submission identity used to match local inputs during snapshot reconciliation. */
28
+ inputId?: string;
29
+ /** A native transfer to steering rather than a locally queued execution. */
30
+ steering?: boolean;
31
+ /** Optional terminal evidence; HermesTurn.fromReceipt validates the fields it consumes. */
32
+ result?: NativeObject;
33
+ }
34
+ /** Gateway event envelope; HermesSession validates session/replay continuity and HermesTurn validates event-specific payloads. */
35
+ export interface NativeEvent {
36
+ type: string;
37
+ /** Absent for global notifications, including an empty native session id. */
38
+ session_id?: string;
39
+ payload: NativeObject;
40
+ /** Position within the replay history named by replay_epoch. */
41
+ seq?: number;
42
+ replay_epoch?: string;
43
+ authority_epoch?: number;
44
+ execution_generation?: number;
45
+ admission_id?: string;
46
+ }
47
+ /** Wire identifier required in the CLI grant and in the WebSocket handshake. */
48
+ export declare const GATEWAY_PROTOCOL = "hermes-gateway-v1";
49
+ /** Create a malformed-message error with unknown outcome. Attach and the adapter probe clear uncertainty for their read-only failures. */
50
+ export declare const protocolError: () => HarnessSdkError;
51
+ /** Require a non-null, non-array object without validating fields; throw PROTOCOL_ERROR on other shapes. */
52
+ export declare function object(value: unknown): NativeObject;
53
+ /** Require a string, allowing empty text for message content and answers; otherwise throw PROTOCOL_ERROR. */
54
+ export declare function text(value: unknown): string;
55
+ /** Require an identifier of 1..4096 UTF-16 code units; otherwise throw PROTOCOL_ERROR. */
56
+ export declare function identity(value: unknown): string;
57
+ /** Require a nonnegative safe integer for gateway counters and epochs; otherwise throw PROTOCOL_ERROR. */
58
+ export declare function integer(value: unknown): number;
59
+ /** Require an array without validating its elements; otherwise throw PROTOCOL_ERROR. */
60
+ export declare function array(value: unknown): unknown[];
61
+ /**
62
+ * Hash JSON-serializable identity parts in order for HermesTurn and HermesInteractions' replay-stable
63
+ * local ids. This is an identity hash, not credential protection; serialization failures propagate.
64
+ */
65
+ export declare function stableId(...parts: unknown[]): string;
66
+ /** Detach native tool input/output through a JSON round trip for HermesTurn.apply. Serialization or parse failures propagate. */
67
+ export declare function json(value: unknown): JsonValue;
68
+ /**
69
+ * Validate runtime.describe against the fresh attach identity and required gateway capabilities.
70
+ * HermesAdapter.probeRuntime publishes capabilities only after this succeeds; HermesSession.connect
71
+ * stores the result for session creation and epoch checks. Throw SESSION_BINDING_MISMATCH for a
72
+ * different owner/profile, HERMES_GATEWAY_UNSUPPORTED for missing capabilities, or PROTOCOL_ERROR
73
+ * for malformed fields. Extra capabilities are accepted and creation parameters are retained.
74
+ */
75
+ export declare function describe(value: unknown, expected: {
76
+ instanceId: string;
77
+ profileId: string;
78
+ }): Description;
79
+ /**
80
+ * Parse a durable admission for HermesSession.parseReceipt, rejecting a different profile/session
81
+ * with SESSION_BINDING_MISMATCH and malformed evidence with PROTOCOL_ERROR. A started admission
82
+ * requires an execution generation; terminal result payloads remain subject to HermesTurn validation.
83
+ */
84
+ export declare function receipt(value: unknown, profileId: string, sessionId: string): Receipt;
85
+ /** Select the admission metadata HermesSession.submit returns to SDK callers; omit native input, steering and result evidence. */
86
+ export declare function publicReceipt(value: Receipt): JsonObject;
87
+ /**
88
+ * Parse a gateway event envelope for live RPC delivery or HermesSession.replaySnapshot, throwing
89
+ * PROTOCOL_ERROR for malformed fields. Missing/null payload becomes {}; session continuity and
90
+ * event-specific payload validation remain with HermesSession.apply and HermesTurn.apply.
91
+ */
92
+ export declare function event(value: unknown): NativeEvent;
93
+ /** Map a gateway rejection for HermesRpc.receiveResponse without retaining native error messages or data. */
94
+ export declare class HermesRpcError extends HarnessSdkError {
95
+ /** Map an explicit rejection to a known-outcome failure; only storage_unavailable is retryable. Array-shaped error data throws PROTOCOL_ERROR. */
96
+ constructor(error: NativeObject);
97
+ }
package/src/native.js ADDED
@@ -0,0 +1,138 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { HarnessSdkError } from '@anureo/sdk';
3
+ /** Wire identifier required in the CLI grant and in the WebSocket handshake. */
4
+ export const GATEWAY_PROTOCOL = 'hermes-gateway-v1';
5
+ /** Create a malformed-message error with unknown outcome. Attach and the adapter probe clear uncertainty for their read-only failures. */
6
+ export const protocolError = () => new HarnessSdkError('PROTOCOL_ERROR', 'Invalid Hermes gateway message', false, true);
7
+ /** Require a non-null, non-array object without validating fields; throw PROTOCOL_ERROR on other shapes. */
8
+ export function object(value) {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value))
10
+ throw protocolError();
11
+ return value;
12
+ }
13
+ /** Require a string, allowing empty text for message content and answers; otherwise throw PROTOCOL_ERROR. */
14
+ export function text(value) {
15
+ if (typeof value !== 'string')
16
+ throw protocolError();
17
+ return value;
18
+ }
19
+ /** Require an identifier of 1..4096 UTF-16 code units; otherwise throw PROTOCOL_ERROR. */
20
+ export function identity(value) {
21
+ const result = text(value);
22
+ if (!result.length || result.length > 4096)
23
+ throw protocolError();
24
+ return result;
25
+ }
26
+ /** Require a nonnegative safe integer for gateway counters and epochs; otherwise throw PROTOCOL_ERROR. */
27
+ export function integer(value) {
28
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
29
+ throw protocolError();
30
+ return value;
31
+ }
32
+ /** Require an array without validating its elements; otherwise throw PROTOCOL_ERROR. */
33
+ export function array(value) {
34
+ if (!Array.isArray(value))
35
+ throw protocolError();
36
+ return value;
37
+ }
38
+ /**
39
+ * Hash JSON-serializable identity parts in order for HermesTurn and HermesInteractions' replay-stable
40
+ * local ids. This is an identity hash, not credential protection; serialization failures propagate.
41
+ */
42
+ export function stableId(...parts) {
43
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
44
+ }
45
+ /** Detach native tool input/output through a JSON round trip for HermesTurn.apply. Serialization or parse failures propagate. */
46
+ export function json(value) { return JSON.parse(JSON.stringify(value)); }
47
+ /**
48
+ * Validate runtime.describe against the fresh attach identity and required gateway capabilities.
49
+ * HermesAdapter.probeRuntime publishes capabilities only after this succeeds; HermesSession.connect
50
+ * stores the result for session creation and epoch checks. Throw SESSION_BINDING_MISMATCH for a
51
+ * different owner/profile, HERMES_GATEWAY_UNSUPPORTED for missing capabilities, or PROTOCOL_ERROR
52
+ * for malformed fields. Extra capabilities are accepted and creation parameters are retained.
53
+ */
54
+ export function describe(value, expected) {
55
+ const data = object(value);
56
+ const creation = object(data.session_create);
57
+ if (data.instance_id !== expected.instanceId || data.profile_id !== expected.profileId)
58
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
59
+ const capabilities = array(data.capabilities).map(identity);
60
+ const sources = array(creation.sources).map(identity);
61
+ const parameters = array(creation.parameters).map(identity);
62
+ assertCanonicalCreation({ capabilities, sources, parameters });
63
+ return {
64
+ instanceId: expected.instanceId,
65
+ profileId: expected.profileId,
66
+ epoch: integer(data.authority_epoch),
67
+ createParameters: parameters,
68
+ editorMcp: capabilities.includes('acp-session-mcp-v1') && sources.includes('acp') && parameters.includes('editor'),
69
+ };
70
+ }
71
+ /**
72
+ * Parse a durable admission for HermesSession.parseReceipt, rejecting a different profile/session
73
+ * with SESSION_BINDING_MISMATCH and malformed evidence with PROTOCOL_ERROR. A started admission
74
+ * requires an execution generation; terminal result payloads remain subject to HermesTurn validation.
75
+ */
76
+ export function receipt(value, profileId, sessionId) {
77
+ const data = object(value), ref = object(data.ref);
78
+ if (ref.profile_id !== profileId || ref.session_id !== sessionId)
79
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
80
+ if (!['queued', 'started', 'unknown', 'terminal'].includes(String(data.status)))
81
+ throw protocolError();
82
+ const generation = data.execution_generation == null ? undefined : integer(data.execution_generation);
83
+ if (data.status === 'started' && generation === undefined)
84
+ throw protocolError();
85
+ return {
86
+ id: identity(data.admission_id), sequence: integer(data.sequence), status: data.status,
87
+ outcome: data.outcome == null ? null : text(data.outcome), epoch: integer(data.authority_epoch), generation,
88
+ ...(data.input_id !== undefined ? { inputId: identity(data.input_id) } : {}),
89
+ ...(data.steering === true ? { steering: true } : {}),
90
+ ...(data.result != null ? { result: object(data.result) } : {}),
91
+ };
92
+ }
93
+ /** Select the admission metadata HermesSession.submit returns to SDK callers; omit native input, steering and result evidence. */
94
+ export function publicReceipt(value) {
95
+ return { admissionId: value.id, sequence: value.sequence, status: value.status, authorityEpoch: value.epoch,
96
+ ...(value.generation !== undefined ? { executionGeneration: value.generation } : {}) };
97
+ }
98
+ /**
99
+ * Parse a gateway event envelope for live RPC delivery or HermesSession.replaySnapshot, throwing
100
+ * PROTOCOL_ERROR for malformed fields. Missing/null payload becomes {}; session continuity and
101
+ * event-specific payload validation remain with HermesSession.apply and HermesTurn.apply.
102
+ */
103
+ export function event(value) {
104
+ const data = object(value);
105
+ return {
106
+ type: identity(data.type), payload: data.payload == null ? {} : object(data.payload),
107
+ // Global notifications such as sessions.changed use an empty session ID.
108
+ ...(data.session_id !== undefined && data.session_id !== '' ? { session_id: identity(data.session_id) } : {}),
109
+ ...(data.seq !== undefined ? { seq: integer(data.seq) } : {}),
110
+ ...(data.replay_epoch !== undefined ? { replay_epoch: identity(data.replay_epoch) } : {}),
111
+ ...(data.authority_epoch !== undefined ? { authority_epoch: integer(data.authority_epoch) } : {}),
112
+ ...(data.execution_generation !== undefined ? { execution_generation: integer(data.execution_generation) } : {}),
113
+ ...(data.admission_id !== undefined ? { admission_id: identity(data.admission_id) } : {}),
114
+ };
115
+ }
116
+ /** Map a gateway rejection for HermesRpc.receiveResponse without retaining native error messages or data. */
117
+ export class HermesRpcError extends HarnessSdkError {
118
+ /** Map an explicit rejection to a known-outcome failure; only storage_unavailable is retryable. Array-shaped error data throws PROTOCOL_ERROR. */
119
+ constructor(error) {
120
+ const reason = error.data && typeof error.data === 'object' ? object(error.data).reason : undefined;
121
+ const code = typeof reason === 'string' ? REASONS[reason] : undefined;
122
+ super(code ?? (error.code === -32601 ? 'METHOD_NOT_FOUND' : error.code === -32602 ? 'INVALID_PARAMS' : 'NATIVE_RPC_ERROR'), 'Hermes gateway refused the request', reason === 'storage_unavailable', false);
123
+ }
124
+ }
125
+ /** Reject owners that cannot create ordinary sessions and recover their durable admissions and event history. */
126
+ function assertCanonicalCreation({ capabilities, sources, parameters }) {
127
+ if (!['durable-admission-v1', 'event-replay-v1', 'local-cli-create-v1'].every(value => capabilities.includes(value))
128
+ || !sources.includes('tool')
129
+ || !['request_id', 'source', 'cwd', 'model'].every(value => parameters.includes(value))) {
130
+ throw new HarnessSdkError('HERMES_GATEWAY_UNSUPPORTED', 'Hermes canonical gateway capabilities are required');
131
+ }
132
+ }
133
+ const REASONS = {
134
+ invalid_params: 'INVALID_PARAMS', permission_denied: 'AUTHORIZATION_FAILED', profile_mismatch: 'SESSION_BINDING_MISMATCH',
135
+ stale_generation: 'TURN_NOT_ACTIVE', not_found: 'SESSION_NOT_FOUND', admission_conflict: 'SUBMISSION_ID_CONFLICT',
136
+ queue_full: 'BACKPRESSURE_EXCEEDED', session_busy: 'SESSION_BUSY', storage_unavailable: 'HARNESS_UNAVAILABLE',
137
+ unsupported_control: 'CAPABILITY_UNSUPPORTED', execution_not_ready: 'TURN_NOT_ACTIVE',
138
+ };
@@ -0,0 +1,60 @@
1
+ import type { HarnessProcess, HarnessProcessOptions } from '@anureo/sdk/process';
2
+ /**
3
+ * Configure createHermesAdapter's local CLI attachment, gateway channel and session budgets.
4
+ * Numeric values must be safe integers; limits and timeouts are positive except the explicitly
5
+ * zero-enabled recovery settings below. Millisecond values must fit a signed 32-bit timer.
6
+ */
7
+ export interface HermesAdapterOptions {
8
+ /** Hermes executable or wrapper; defaults to 'hermes'. Must be nonempty and contain no NUL. */
9
+ command?: string;
10
+ /** Arguments preceding Hermes CLI arguments, for a wrapper executable; entries must contain no NUL. */
11
+ args?: string[];
12
+ /** CLI profile selection; omit to use the CLI default. Must contain non-whitespace text and no NUL. */
13
+ profile?: string;
14
+ /** Native toolsets for new sessions; [] disables model tools. Omit to inherit the profile. Names must be nonblank and contain no NUL. */
15
+ toolsets?: string[];
16
+ /** Complete child environment; omitted means inherit. */
17
+ env?: Record<string, string | undefined>;
18
+ /** Defaults to start, which ensures a shared owner. attach uses an existing owner even during reconnects. */
19
+ startup?: 'start' | 'attach';
20
+ /** Separate deadline in ms for CLI response after spawn, WebSocket open and each RPC response. Defaults to 30,000. */
21
+ timeoutMs?: number;
22
+ /** Maximum UTF-8 frame/queued-entry bytes; defaults to 8 MiB. CLI attach stdout has its own 64 KiB cap. */
23
+ maxFrameBytes?: number;
24
+ /** Byte budget for socket backlog and, separately, incoming buffers, retained inputs and each turn's items; defaults to 16 MiB. */
25
+ maxBufferedBytes?: number;
26
+ /** Separate count limit for the incoming queue and traffic held during setup or recovery. Defaults to 2048 each. */
27
+ maxEvents?: number;
28
+ /** Maximum outstanding RPC requests per channel; defaults to 128. */
29
+ maxPending?: number;
30
+ /** Maximum unresolved interactions per session; defaults to 32. Settled identities are retained separately. */
31
+ maxInteractions?: number;
32
+ /** Maximum retained executions per session; defaults to 128. Eligible terminal executions are evicted first. */
33
+ maxExecutions?: number;
34
+ /** Maximum retained submissions per session; defaults to 1024. Eligible acknowledged submissions are evicted first. */
35
+ maxSubmissions?: number;
36
+ /** Attempts per session recovery cycle, including the first attach-only attempt. Defaults to 3. Zero disables automatic recovery. */
37
+ reconnectAttempts?: number;
38
+ /** Base retry delay in ms, doubled after each delayed attempt; defaults to 100. First attempt is immediate; zero selects a zero-ms delay. */
39
+ reconnectDelayMs?: number;
40
+ /** Owner recovery requests per session in a rolling minute; defaults to 2. Zero prohibits owner recovery. */
41
+ maxOwnerRecoveriesPerMinute?: number;
42
+ /**
43
+ * Replace CLI spawning for each ticket request. Resolve with usable pipes and exit observation
44
+ * and release partial resources on rejection. The adapter takes ownership of the returned
45
+ * handle and awaits its disposal, including when attachment fails.
46
+ */
47
+ processFactory?: (options: HarnessProcessOptions) => Promise<HarnessProcess>;
48
+ /**
49
+ * Replace WebSocket construction for each ticket. Return a socket that implements the standard
50
+ * WebSocket interface. Keep the credential subprotocol private. The adapter owns socket closure.
51
+ */
52
+ webSocketFactory?: (url: string, protocols: string[]) => WebSocket;
53
+ /**
54
+ * Receive fixed diagnostic codes synchronously when attach reports stderr or session detach
55
+ * cannot be confirmed. Return normally so detach cleanup can proceed.
56
+ */
57
+ diagnostic?: (code: string) => void;
58
+ }
59
+ /** Validate configuration synchronously; HermesAdapter's constructor converts failures to INVALID_PARAMS before starting resources. */
60
+ export declare function validateOptions(options: HermesAdapterOptions): void;
package/src/options.js ADDED
@@ -0,0 +1,29 @@
1
+ /** Validate configuration synchronously; HermesAdapter's constructor converts failures to INVALID_PARAMS before starting resources. */
2
+ export function validateOptions(options) {
3
+ for (const [key, value] of Object.entries(options)) {
4
+ if (value === undefined)
5
+ continue;
6
+ if (key.endsWith('Ms') || key.startsWith('max') || key === 'reconnectAttempts')
7
+ validateNumericOption(key, value);
8
+ }
9
+ if (options.toolsets !== undefined && (!Array.isArray(options.toolsets)
10
+ || options.toolsets.some(value => typeof value !== 'string' || !value.trim() || value.includes('\0'))))
11
+ throw new Error('Invalid Hermes toolsets');
12
+ if (options.startup !== undefined && !['start', 'attach'].includes(options.startup))
13
+ throw new Error('Invalid Hermes startup mode');
14
+ if (options.profile !== undefined && (!options.profile.trim() || options.profile.includes('\0')))
15
+ throw new Error('Invalid Hermes profile');
16
+ if (options.command !== undefined && (typeof options.command !== 'string' || !options.command || options.command.includes('\0')))
17
+ throw new Error('Invalid Hermes command');
18
+ if (options.args !== undefined && (!Array.isArray(options.args)
19
+ || options.args.some(value => typeof value !== 'string' || value.includes('\0'))))
20
+ throw new Error('Invalid Hermes arguments');
21
+ }
22
+ /** Allow zero only for recovery controls; keep configured millisecond values within the native timer range. */
23
+ function validateNumericOption(key, value) {
24
+ const minimum = ['reconnectAttempts', 'reconnectDelayMs', 'maxOwnerRecoveriesPerMinute'].includes(key) ? 0 : 1;
25
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum)
26
+ throw new Error(`Invalid Hermes option: ${key}`);
27
+ if (key.endsWith('Ms') && value > 2_147_483_647)
28
+ throw new Error(`Invalid Hermes option: ${key}`);
29
+ }
package/src/rpc.d.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { HarnessSdkError } from '@anureo/sdk';
2
+ import type { GatewayGrant } from "./attach.js";
3
+ import type { HermesAdapterOptions } from "./options.js";
4
+ import { type NativeEvent, type NativeObject } from "./native.js";
5
+ /** Internal gateway-to-client request; probe handlers and HermesInteractions reply or reject using the same id. */
6
+ export interface ServerRequest {
7
+ id: string | number;
8
+ method: string;
9
+ params: NativeObject;
10
+ }
11
+ /**
12
+ * Internal, bounded JSON-RPC channel for adapter probes and sessions. Responses settle requests
13
+ * immediately; events and server requests run serially so a handler can await an RPC acknowledgement.
14
+ * HermesAdapter.probeRuntime disposes its probe. HermesSession owns the channel during observation
15
+ * and disposes it on recovery or close.
16
+ */
17
+ export declare class HermesRpc {
18
+ private readonly socket;
19
+ private readonly handlers;
20
+ private readonly options;
21
+ /** Install transport listeners and start serial delivery before connect waits for the opening handshake. */
22
+ private constructor();
23
+ /**
24
+ * Open a fresh grant and require hermes-gateway-v1 negotiation. Reject factory/open failures,
25
+ * timeout, protocol mismatch or abort; after channel construction, failure disposes its socket.
26
+ * The signal only cancels opening. On success the caller must dispose the channel separately
27
+ * and must use a freshly minted grant for each connection attempt.
28
+ */
29
+ static connect(grant: GatewayGrant, handlers: Handlers, options: HermesAdapterOptions, signal?: AbortSignal): Promise<HermesRpc>;
30
+ /** Whether the local channel still permits writes. HermesSession.ready uses this to decide when to recover. */
31
+ get connected(): boolean;
32
+ /**
33
+ * Resolve with the correlated native result, without waiting for event delivery or execution
34
+ * completion. Reject native RPC errors, admission/write failures, timeout or channel closure.
35
+ * HermesSession and HermesInteractions set mutation for submissions, creation, interrupts and
36
+ * prompt answers so timeout, channel loss or an unclassified write failure reports an unknown
37
+ * outcome. A request never retries or cancels remote work.
38
+ */
39
+ request(method: string, params?: NativeObject, mutation?: boolean): Promise<unknown>;
40
+ /**
41
+ * Send HermesInteractions' answer to a gateway request. Resolve after local socket.send, without
42
+ * remote acknowledgement; any write failure rejects RESULT_UNKNOWN with an unknown outcome.
43
+ */
44
+ reply(id: string | number, result: unknown): Promise<void>;
45
+ /** Reject an unsupported gateway request; delivery and failure semantics match reply. */
46
+ reject(id: string | number): Promise<void>;
47
+ /**
48
+ * Idempotently close local observation and reject pending RPCs without stopping native execution.
49
+ * Suppress failure callbacks; callers needing handler completion must also await finished.
50
+ */
51
+ dispose(): void;
52
+ /**
53
+ * Settle when queued delivery ends and any failure callback returns. Disposal skips buffered
54
+ * entries and lets the active handler finish. HermesSession.retryRecovery awaits this before
55
+ * opening a replacement. Reader failures are reported to the callback, whose own throw rejects this promise.
56
+ */
57
+ readonly finished: Promise<void>;
58
+ private readonly queue;
59
+ private readonly pending;
60
+ private nextId;
61
+ private closed;
62
+ private disposed;
63
+ /**
64
+ * Deliver queued events/requests in order. The constructor's catch closes the channel on handler
65
+ * failure and reports it; disposed channels skip buffered entries while an active handler finishes.
66
+ */
67
+ private read;
68
+ /** The message listener validates/routes each frame; throwing closes the channel through that listener. */
69
+ private receive;
70
+ /**
71
+ * Settle matching requests without entering the handler queue. Ignore uncorrelated responses
72
+ * after checking the envelope, so late replies after timeout leave other requests pending.
73
+ */
74
+ private receiveResponse;
75
+ /** Release the pending slot and its deadline before settling a response or reporting a write failure. */
76
+ private forgetRequest;
77
+ /** Enforce per-frame and socket backlog limits before a synchronous write; callers decide how to report failures. */
78
+ private send;
79
+ /** Share response delivery/error mapping so answers and unsupported-request replies cannot drift. */
80
+ private respond;
81
+ /** End observation once. Reject pending RPCs with their mutation uncertainty before ending queued delivery and closing the socket. */
82
+ private stop;
83
+ }
84
+ /**
85
+ * Supplied by HermesAdapter.probeRuntime for probes and HermesSession.connect for session delivery.
86
+ * read awaits event/request callbacks serially; failure is invoked when read fails, unless disposed.
87
+ * Callbacks must not wait for a user answer: response RPCs bypass this queue, but later events do not.
88
+ */
89
+ interface Handlers {
90
+ event(value: NativeEvent): Promise<void>;
91
+ request(value: ServerRequest): Promise<void>;
92
+ failure(cause: HarnessSdkError): void;
93
+ }
94
+ export {};