@longshot-harness/longshot 0.1.0-beta.1 → 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,207 @@
1
+ /**
2
+ * The sidecar dispatch loop: hello gate, op dispatcher, EOF shutdown.
3
+ *
4
+ * JavaScript port of sidecars/claude/main.py:197-363. The injected sdk,
5
+ * input and output seams (D08) let tests run the whole loop without a real
6
+ * SDK or a real CLI. Requests from Longshot are serialised through a promise
7
+ * chain (one at a time, in arrival order); kind:"response" frames bypass the
8
+ * chain immediately so that sidecar-initiated requests can be resolved while a
9
+ * Longshot request is in flight (D07).
10
+ */
11
+
12
+ import { createInterface } from 'node:readline';
13
+
14
+ import {
15
+ PROTOCOL_VERSION,
16
+ SIDECAR_VERSION,
17
+ Code,
18
+ Op,
19
+ KNOWN_OPS,
20
+ parseFrame,
21
+ encodeFrame,
22
+ response,
23
+ refusal,
24
+ createCorrelation,
25
+ Refusal,
26
+ } from './protocol.mjs';
27
+ import { createUnknownLog } from './events.mjs';
28
+ import { Session } from './session.mjs';
29
+
30
+ // The two ops that emit session.ready immediately after their own response
31
+ // frame (main.py:108-113). The event is written here, after the result frame,
32
+ // never from inside Session: the caller reads the session id from that result,
33
+ // so an event naming a session Longshot has not been told about yet would be
34
+ // a frame out of order.
35
+ const READY_AFTER = new Set([Op.SESSION_START, Op.SESSION_RESUME]);
36
+
37
+ /**
38
+ * Route one host request to the appropriate Session method.
39
+ *
40
+ * @param {Session} session
41
+ * @param {string} op
42
+ * @param {object} params
43
+ * @returns {Promise<object>}
44
+ */
45
+ async function dispatch(session, op, params) {
46
+ switch (op) {
47
+ case Op.SESSION_START: return session.start(params);
48
+ case Op.SESSION_RESUME: return session.resume(params);
49
+ case Op.SESSION_READ: return session.read(params.session_id || '');
50
+ case Op.SESSION_HISTORY: return session.history(params);
51
+ case Op.INPUT_SUBMIT: return session.submit(params);
52
+ case Op.TURN_INTERRUPT: return session.interrupt(params.session_id || '');
53
+ case Op.MODEL_SET: return session.setModel(params);
54
+ case Op.PERMISSION_SET: return session.setPermissionMode(
55
+ params.session_id || '', params.permission_mode || '');
56
+ case Op.SESSION_CLOSE: return session.close(params.session_id || '');
57
+ default:
58
+ // Unreachable: KNOWN_OPS is checked before dispatch.
59
+ throw new Error(`unrouted op: ${op}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Run the sidecar protocol loop over the given I/O streams.
65
+ *
66
+ * Returns 0 on EOF, 2 on a protocol error that ends the channel, 1 on an
67
+ * unhandled internal error; always calls session.disconnect() before returning
68
+ * (main.py:362-363).
69
+ *
70
+ * @param {{ sdk: { query: Function, getSessionMessages: Function,
71
+ * version?: string, claudeCodeVersion?: string },
72
+ * input: import('node:stream').Readable,
73
+ * output: { write(s: string): void },
74
+ * errput: { write(s: string): void } }} opts
75
+ * @returns {Promise<0|1|2>}
76
+ */
77
+ export async function run({ sdk, input, output, errput }) {
78
+ let chainError = null;
79
+
80
+ function writeFrame(frame) {
81
+ output.write(encodeFrame(frame));
82
+ }
83
+
84
+ function diag(line) {
85
+ errput.write(line + '\n');
86
+ }
87
+
88
+ const correlation = createCorrelation(writeFrame);
89
+ const session = new Session({
90
+ sdk,
91
+ emit: writeFrame,
92
+ correlation,
93
+ logUnknown: createUnknownLog((s) => errput.write(s)),
94
+ logProblem: (s) => errput.write(s),
95
+ });
96
+
97
+ // One promise chain: each Longshot request starts after the previous one
98
+ // has written its response frame. kind:"response" frames bypass the chain
99
+ // (handled synchronously in the readline loop below).
100
+ let chain = Promise.resolve();
101
+
102
+ function enqueue(op, id, params) {
103
+ chain = chain.then(async () => {
104
+ if (chainError) return; // stop processing after an internal error
105
+ try {
106
+ const result = await dispatch(session, op, params);
107
+ writeFrame(response(id, result));
108
+ if (READY_AFTER.has(op)) writeFrame(session.readyEvent());
109
+ } catch (err) {
110
+ if (err instanceof Refusal) {
111
+ writeFrame(refusal(id, err.code, err.message));
112
+ } else {
113
+ errput.write(`claude sidecar: internal error: ${err?.stack ?? String(err)}\n`);
114
+ chainError = err;
115
+ }
116
+ }
117
+ });
118
+ }
119
+
120
+ let greeted = false;
121
+ let exitCode = 0;
122
+ const rl = createInterface({ input, crlfDelay: Infinity });
123
+
124
+ try {
125
+ for await (const line of rl) {
126
+ let frame;
127
+ try {
128
+ frame = parseFrame(line);
129
+ } catch (err) {
130
+ diag(`malformed frame: ${err.message}`);
131
+ exitCode = 2;
132
+ break;
133
+ }
134
+
135
+ const kind = frame.kind;
136
+ const op = frame.op;
137
+ const id = typeof frame.id === 'number' ? frame.id : 0;
138
+ const params = frame.params || {};
139
+
140
+ if (!greeted) {
141
+ if (kind !== 'request' || op !== Op.HELLO) {
142
+ writeFrame(refusal(id, Code.HELLO_REQUIRED,
143
+ `the sidecar channel received ${JSON.stringify(op || '')} before completing the ` +
144
+ 'hello handshake; a sidecar channel must complete hello first'));
145
+ exitCode = 2;
146
+ break;
147
+ }
148
+ if (params.protocol !== PROTOCOL_VERSION) {
149
+ writeFrame(refusal(id, Code.UNSUPPORTED_VERSION,
150
+ `sidecar protocol mismatch: this sidecar speaks protocol ${PROTOCOL_VERSION}, ` +
151
+ `longshot speaks protocol ${JSON.stringify(params.protocol)}. Reinstall the sidecar ` +
152
+ 'that ships with this longshot build, or run the longshot build that matches it.'));
153
+ exitCode = 2;
154
+ break;
155
+ }
156
+ writeFrame(response(id, {
157
+ protocol: PROTOCOL_VERSION,
158
+ sidecar: { name: 'claude-sidecar', version: SIDECAR_VERSION, pid: process.pid },
159
+ cli_version: sdk.claudeCodeVersion || 'unknown',
160
+ sdk_version: sdk.version || 'unknown',
161
+ }));
162
+ greeted = true;
163
+ continue;
164
+ }
165
+
166
+ // A second hello is refused but does not end the channel (main.py:264-276).
167
+ if (kind === 'request' && op === Op.HELLO) {
168
+ writeFrame(refusal(id, Code.MALFORMED_REQUEST,
169
+ 'the sidecar received hello twice; hello is the first frame of a channel and is sent once'));
170
+ continue;
171
+ }
172
+
173
+ // Longshot responses to sidecar-initiated requests bypass the chain so
174
+ // that a sidecar request (permission.request, tool.call) can be resolved
175
+ // while a Longshot request is in flight (D07, main.py:278-294).
176
+ if (kind === 'response') {
177
+ if (!correlation.resolve(frame)) {
178
+ diag(`claude sidecar: response id=${frame.id} matched no pending sidecar-initiated request`);
179
+ }
180
+ continue;
181
+ }
182
+
183
+ // Non-request, non-response kind (e.g. "event").
184
+ if (kind !== 'request') {
185
+ writeFrame(refusal(id, Code.MALFORMED_REQUEST,
186
+ `the sidecar channel received a frame of kind ${JSON.stringify(kind)}, which Longshot may not send`));
187
+ continue;
188
+ }
189
+
190
+ // Unknown op.
191
+ if (!KNOWN_OPS.has(op)) {
192
+ writeFrame(refusal(id, Code.UNKNOWN_OPERATION,
193
+ `${JSON.stringify(op)} is not an operation either side of the sidecar channel knows`));
194
+ continue;
195
+ }
196
+
197
+ enqueue(op, id, params);
198
+ }
199
+ } finally {
200
+ rl.close();
201
+ }
202
+
203
+ // Drain any queued requests before disconnecting, on every exit path.
204
+ await chain;
205
+ await session.disconnect();
206
+ return chainError ? 1 : exitCode;
207
+ }