@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.
- package/LICENSE.md +201 -0
- package/README.md +44 -2
- package/bin/longshot.js +82 -0
- package/package.json +34 -6
- package/sidecars/claude/events.mjs +331 -0
- package/sidecars/claude/history.mjs +70 -0
- package/sidecars/claude/main.mjs +84 -0
- package/sidecars/claude/mcpserver.mjs +88 -0
- package/sidecars/claude/protocol.mjs +222 -0
- package/sidecars/claude/session.mjs +561 -0
- package/sidecars/claude/sidecar.mjs +207 -0
|
@@ -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
|
+
}
|