@longshot-harness/longshot 0.0.0-stage → 0.1.0-beta.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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The sidecar wire protocol: envelope, NDJSON framing and correlation.
3
+ *
4
+ * JavaScript port of protocol.py; the frozen contract lives in
5
+ * internal/backend/claude/protocol.go and is documented in
6
+ * docs/claude-sidecar.md. No SDK imports — only Node built-ins.
7
+ */
8
+
9
+ // 2 adds one event, message.completed: the doc's bump rule is that adding a
10
+ // message bumps, because an unknown event kind is refused rather than ignored
11
+ // (docs/claude-sidecar.md, "Versioning and the handshake").
12
+ export const PROTOCOL_VERSION = 2;
13
+ export const SIDECAR_VERSION = '0.2.0';
14
+
15
+ /** Refusal codes, verbatim from protocol.go's Code registry. */
16
+ export const Code = Object.freeze({
17
+ HELLO_REQUIRED: 'helloRequired',
18
+ UNSUPPORTED_VERSION: 'unsupportedVersion',
19
+ MALFORMED_REQUEST: 'malformedRequest',
20
+ UNKNOWN_OPERATION: 'unknownOperation',
21
+ UNKNOWN_SESSION: 'unknownSession',
22
+ NO_ACTIVE_TURN: 'noActiveTurn',
23
+ UNSUPPORTED_MODE: 'unsupportedMode',
24
+ FORBIDDEN_VALUE: 'forbiddenValue',
25
+ INTERNAL: 'internal',
26
+ });
27
+
28
+ /**
29
+ * Operations Longshot may send, verbatim from protocol.go's HostOps.
30
+ *
31
+ * tool.call and permission.request are the sidecar's own two
32
+ * sidecar-to-Longshot requests (protocol.go's SidecarOps); they are not
33
+ * listed here because the dispatcher only classifies frames arriving from
34
+ * Longshot.
35
+ */
36
+ export const Op = Object.freeze({
37
+ HELLO: 'hello',
38
+ SESSION_START: 'session.start',
39
+ SESSION_RESUME: 'session.resume',
40
+ SESSION_READ: 'session.read',
41
+ SESSION_HISTORY: 'session.history',
42
+ INPUT_SUBMIT: 'input.submit',
43
+ TURN_INTERRUPT: 'turn.interrupt',
44
+ MODEL_SET: 'model.set',
45
+ PERMISSION_SET: 'permission.set',
46
+ SESSION_CLOSE: 'session.close',
47
+ });
48
+
49
+ /** All host operations as a Set, for O(1) membership tests. */
50
+ export const KNOWN_OPS = new Set(Object.values(Op));
51
+
52
+ /**
53
+ * Raised by parseFrame for a line that is not a well-formed frame.
54
+ *
55
+ * Never raised for EOF — that is parseFrame returning null, the one
56
+ * unconditional exit signal the loop acts on.
57
+ */
58
+ export class FrameError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = 'FrameError';
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Thrown by Session methods to indicate the sidecar refuses a Longshot
67
+ * request. The dispatcher catches it and writes a refusal frame.
68
+ * .code is one of the Code values; .message is a human-readable sentence.
69
+ */
70
+ export class Refusal extends Error {
71
+ constructor(code, message) {
72
+ super(message);
73
+ this.name = 'Refusal';
74
+ this.code = code;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse one NDJSON line into an object, or return null on EOF ('').
80
+ *
81
+ * A line that is not valid JSON, or that decodes to something other than a
82
+ * JSON object, throws FrameError rather than being skipped: skipping an
83
+ * unrecognised line would let the second half of a split frame pass as
84
+ * valid (docs/claude-sidecar.md, "Framing").
85
+ *
86
+ * @param {string} line
87
+ * @returns {object}
88
+ */
89
+ export function parseFrame(line) {
90
+ let frame;
91
+ try {
92
+ frame = JSON.parse(line);
93
+ } catch (err) {
94
+ throw new FrameError(`the line did not parse as JSON: ${err.message}`);
95
+ }
96
+ if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
97
+ throw new FrameError('the line did not decode to a JSON object');
98
+ }
99
+ return frame;
100
+ }
101
+
102
+ /**
103
+ * Encode one frame as a single compact, newline-terminated JSON line.
104
+ *
105
+ * JSON.stringify does not escape non-ASCII by default, matching Python's
106
+ * ensure_ascii=False and the Go encoder in codec.go: payloads carry
107
+ * prompts and diffs whose non-ASCII characters must round-trip verbatim,
108
+ * and every frame is exactly one line.
109
+ *
110
+ * @param {object} frame
111
+ * @returns {string}
112
+ */
113
+ export function encodeFrame(frame) {
114
+ return JSON.stringify(frame) + '\n';
115
+ }
116
+
117
+ /**
118
+ * Build a success response. result is never omitted: an empty result is {},
119
+ * matching Go's NewResponse rule.
120
+ *
121
+ * @param {number} id
122
+ * @param {object|null} result
123
+ * @returns {object}
124
+ */
125
+ export function response(id, result) {
126
+ return { kind: 'response', id, result: result ?? {} };
127
+ }
128
+
129
+ /**
130
+ * Build a refusal response. message is a whole sentence.
131
+ *
132
+ * @param {number} id
133
+ * @param {string} code
134
+ * @param {string} message
135
+ * @returns {object}
136
+ */
137
+ export function refusal(id, code, message) {
138
+ return { kind: 'response', id, error: { code, message } };
139
+ }
140
+
141
+ // ---- Sidecar to Longshot: request/response correlation.
142
+ //
143
+ // Both requests this side initiates — permission.request and tool.call —
144
+ // share one id counter, one pending registry and one resolve(), and that
145
+ // sharing is the whole reason the primitive sits here instead of in either
146
+ // caller: a second correlation mechanism is refused (T20-S02). The dispatcher
147
+ // calls resolve() once for both.
148
+ //
149
+ // The counter is this side's own: each side of the channel numbers its own
150
+ // requests, unique as (initiator, id) (docs/claude-sidecar.md, "Correlation
151
+ // and identifiers"), so nothing here ever reads or reuses a Longshot request id.
152
+
153
+ /**
154
+ * Create one correlation registry for the lifetime of the process.
155
+ *
156
+ * @param {function(object): void} emit - called with the outgoing request frame
157
+ * @returns {{ request(op: string, params: object): Promise<object>,
158
+ * resolve(frame: object): boolean,
159
+ * failPending(reason: string): void,
160
+ * readonly pending: number }}
161
+ */
162
+ export function createCorrelation(emit) {
163
+ let nextId = 1;
164
+ const pending = new Map(); // id → { resolve, reject }
165
+
166
+ /**
167
+ * Send one sidecar-initiated request and await its correlated result.
168
+ *
169
+ * There is no deadline: the protocol freezes "there is no protocol-level
170
+ * timeout on either" of the two sidecar requests
171
+ * (docs/claude-sidecar.md, "Sidecar to Longshot: requests"); the bound
172
+ * is channel life via failPending.
173
+ */
174
+ function request(op, params) {
175
+ const id = nextId++;
176
+ return new Promise((resolve, reject) => {
177
+ pending.set(id, { resolve, reject });
178
+ emit({ kind: 'request', id, op, params });
179
+ }).finally(() => {
180
+ pending.delete(id);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Route one incoming kind:response frame to the waiting request.
186
+ *
187
+ * Returns false when nobody is waiting: the id belongs to something else,
188
+ * or the waiter already gave up (the pending entry is removed when
189
+ * request() settles).
190
+ */
191
+ function resolve(frame) {
192
+ const waiter = pending.get(frame.id);
193
+ if (!waiter) return false;
194
+ const error = frame.error;
195
+ if (error != null) {
196
+ waiter.reject(new Error(error.message ?? ''));
197
+ } else {
198
+ waiter.resolve(frame.result ?? {});
199
+ }
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Fail every request still outstanding with reason as the message.
205
+ *
206
+ * Called when the channel goes away. A waiter that outlived its channel
207
+ * would wait forever; raising instead reaches the model as a readable
208
+ * tool result (protocol.py:204-217).
209
+ */
210
+ function failPending(reason) {
211
+ for (const { reject } of pending.values()) {
212
+ reject(new Error(reason));
213
+ }
214
+ }
215
+
216
+ return {
217
+ request,
218
+ resolve,
219
+ failPending,
220
+ get pending() { return pending.size; },
221
+ };
222
+ }