@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,561 @@
1
+ /**
2
+ * The one live Claude session this sidecar serves (D07).
3
+ *
4
+ * JavaScript port of session.py + permissions.py over an injected SDK
5
+ * ({query, getSessionMessages}): one streaming query() per session, fed by an
6
+ * input queue and read by one open-ended loop that maps every SDK message to
7
+ * wire events (events.mjs). Nothing here writes a response frame: refusals are
8
+ * thrown as Refusal and the dispatcher (T04) answers them. Event frames go out
9
+ * through the injected emit; sidecar-to-Longshot requests through the
10
+ * injected correlation registry (protocol.mjs).
11
+ *
12
+ * Everything a Session holds arrived from Longshot and arrives again on
13
+ * session.resume (I12).
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ import { Code, Refusal } from './protocol.mjs';
19
+ import { StreamState, beginTurn, event, mapMessage, sessionReady } from './events.mjs';
20
+ import { serializeMessage } from './history.mjs';
21
+ import { buildLongshotServer } from './mcpserver.mjs';
22
+
23
+ // The one permission_mode both sides of the channel refuse: the mode of an
24
+ // unattended worker run, never the coordinator's own session (I08).
25
+ export const FORBIDDEN_PERMISSION_MODE = 'bypassPermissions';
26
+
27
+ // Verbatim from internal/backend/claude/testdata/error_forbiddenValue.json.
28
+ const FORBIDDEN_PERMISSION_MODE_MESSAGE = (mode) =>
29
+ `permission_mode "${mode}" is forbidden on the coordinator session: it is ` +
30
+ 'the mode of a worker in an isolated working directory and is not ' +
31
+ 'carried over to the coordinator';
32
+
33
+ // The permission_mode vocabulary of the vendor CLI, and the one place it is
34
+ // written down (session.py:47-71). The wire carries Claude's own spelling;
35
+ // anything else reaches the CLI verbatim and kills it, so it is refused here.
36
+ // `manual` is not in the TS PermissionMode type, but the SDK passes the value
37
+ // through unchecked and Longshot sends it for read-only (D07).
38
+ export const ALLOWED_PERMISSION_MODES = Object.freeze([
39
+ 'acceptEdits',
40
+ 'auto',
41
+ 'bypassPermissions',
42
+ 'manual',
43
+ 'dontAsk',
44
+ 'plan',
45
+ 'default',
46
+ ]);
47
+
48
+ // The two input.submit modes served; both make the same call (session.py:73-80).
49
+ export const SERVED_SUBMIT_MODES = Object.freeze(['turn', 'steer']);
50
+
51
+ // The two wire modes refused by design, each with its reason (session.py:82-97).
52
+ const UNSERVED_SUBMIT_MODES = {
53
+ 'next-turn':
54
+ "next-turn is served by Longshot's own mailbox, not by this sidecar; accepting it " +
55
+ 'here would mean holding a queue of pending input, which is state this side must ' +
56
+ 'not hold (I12)',
57
+ 'external-wake':
58
+ 'external-wake needs a channel notification, which this vendor accepts but never ' +
59
+ 'delivers into the session (F01, measured-constraints.md section 4), so there is ' +
60
+ 'nothing this side could serve honestly',
61
+ };
62
+
63
+ const OPEN_FAILED = 'the Claude session could not be opened in this environment: ';
64
+ const EARLY_END = 'the Claude CLI exited before the session opened';
65
+
66
+ const quote = (v) => JSON.stringify(v ?? '');
67
+ const why = (err) => `${err?.name ?? 'Error'}: ${err?.message ?? String(err)}`;
68
+
69
+ /**
70
+ * Enforces I08 for every frame that carries permission_mode: session.start,
71
+ * session.resume and permission.set. Three facts, three codes
72
+ * (docs/claude-sidecar.md, "Empty versus forbidden value"): empty is
73
+ * malformedRequest, bypassPermissions is forbiddenValue, a mode outside the
74
+ * CLI's vocabulary is unsupportedMode.
75
+ */
76
+ export function checkPermissionMode(mode) {
77
+ if (!mode) {
78
+ throw new Refusal(
79
+ Code.MALFORMED_REQUEST,
80
+ 'a non-empty permission_mode is required; the frame carried none',
81
+ );
82
+ }
83
+ if (mode === FORBIDDEN_PERMISSION_MODE) {
84
+ throw new Refusal(Code.FORBIDDEN_VALUE, FORBIDDEN_PERMISSION_MODE_MESSAGE(mode));
85
+ }
86
+ if (!ALLOWED_PERMISSION_MODES.includes(mode)) {
87
+ throw new Refusal(
88
+ Code.UNSUPPORTED_MODE,
89
+ `permission_mode ${quote(mode)} is not a mode the Claude CLI accepts; it accepts ` +
90
+ `one of ${ALLOWED_PERMISSION_MODES.join(', ')}`,
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Options for query() from session.start/session.resume params (D07).
97
+ * Empty cwd/model/effort/system_prompt leave the option unset. extra carries
98
+ * what is per session: sessionId (start) or resume (resume), canUseTool,
99
+ * mcpServers. settingSources [] cuts off the operator's own settings, hooks
100
+ * and MCP servers.
101
+ *
102
+ * @param {object} params
103
+ * @param {object} extra
104
+ * @returns {object}
105
+ */
106
+ export function buildOptions(params, extra) {
107
+ const permissionMode = params.permission_mode || '';
108
+ checkPermissionMode(permissionMode);
109
+ const options = {};
110
+ if (params.cwd) options.cwd = params.cwd;
111
+ if (params.model) options.model = params.model;
112
+ if (params.effort) options.effort = params.effort;
113
+ if (params.system_prompt) options.systemPrompt = params.system_prompt;
114
+ return {
115
+ ...options,
116
+ permissionMode,
117
+ includePartialMessages: true,
118
+ settingSources: [],
119
+ disallowedTools: [...(params.withheld_tools ?? [])],
120
+ ...extra,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * The prompt of the one streaming query(): an async iterable that yields what
126
+ * push() queued and finishes after end(). Never throws.
127
+ */
128
+ class InputQueue {
129
+ #items = [];
130
+ #waiter = null;
131
+ #ended = false;
132
+
133
+ push(message) {
134
+ if (this.#ended) return;
135
+ if (this.#waiter) {
136
+ const w = this.#waiter;
137
+ this.#waiter = null;
138
+ w({ value: message, done: false });
139
+ } else {
140
+ this.#items.push(message);
141
+ }
142
+ }
143
+
144
+ end() {
145
+ this.#ended = true;
146
+ if (this.#waiter) {
147
+ const w = this.#waiter;
148
+ this.#waiter = null;
149
+ w({ value: undefined, done: true });
150
+ }
151
+ }
152
+
153
+ next() {
154
+ if (this.#items.length) return Promise.resolve({ value: this.#items.shift(), done: false });
155
+ if (this.#ended) return Promise.resolve({ value: undefined, done: true });
156
+ return new Promise((resolve) => { this.#waiter = resolve; });
157
+ }
158
+
159
+ return() {
160
+ this.end();
161
+ return Promise.resolve({ value: undefined, done: true });
162
+ }
163
+
164
+ [Symbol.asyncIterator]() {
165
+ return this;
166
+ }
167
+ }
168
+
169
+ export class Session {
170
+ // Connection under construction during #open: set before the first await,
171
+ // cleared when #open succeeds or fails. #requireUnopened refuses while this
172
+ // is set; disconnect() closes it so a concurrent close never orphans the query.
173
+ #pending = null;
174
+
175
+ /**
176
+ * @param {{ sdk: {query: Function, getSessionMessages: Function},
177
+ * emit: (frame: object) => void,
178
+ * correlation: {request: Function, failPending: Function},
179
+ * logUnknown: (what: string, name: string) => void,
180
+ * logProblem?: (line: string) => void }} deps
181
+ */
182
+ constructor({ sdk, emit, correlation, logUnknown, logProblem }) {
183
+ this.sdk = sdk;
184
+ this.emit = emit;
185
+ this.correlation = correlation;
186
+ this.logUnknown = logUnknown;
187
+ this.logProblem = logProblem ?? ((s) => process.stderr.write(s));
188
+ this.sessionId = null;
189
+ this.nativeId = '';
190
+ this.cwd = '';
191
+ this.model = '';
192
+ this.effort = '';
193
+ this.permissionMode = '';
194
+ // The live connection: {q, input, state, loop}, or null when no CLI is
195
+ // behind this session (never opened, closed, or the stream ended).
196
+ this.conn = null;
197
+ }
198
+
199
+ async start(params) {
200
+ checkPermissionMode(params.permission_mode);
201
+ this.#requireUnopened();
202
+ if (params.mcp_endpoint) {
203
+ // Accepting and ignoring it would silently drop the session's tool set.
204
+ throw new Refusal(Code.INTERNAL, 'mcp_endpoint is served by T20, not by this build');
205
+ }
206
+ const sessionId = randomUUID();
207
+ const nativeId = randomUUID();
208
+ await this.#open(params, sessionId, nativeId, { sessionId: nativeId });
209
+ return this.#startResult();
210
+ }
211
+
212
+ async resume(params) {
213
+ checkPermissionMode(params.permission_mode);
214
+ this.#requireUnopened();
215
+ const nativeId = params.native_id || '';
216
+ if (!nativeId) {
217
+ throw new Refusal(
218
+ Code.MALFORMED_REQUEST,
219
+ 'session.resume requires a non-empty native_id; the frame carried none',
220
+ );
221
+ }
222
+ const sessionId = params.session_id || '';
223
+ if (!sessionId) {
224
+ throw new Refusal(
225
+ Code.MALFORMED_REQUEST,
226
+ 'session.resume requires a non-empty session_id; the frame carried none',
227
+ );
228
+ }
229
+ await this.#open(params, sessionId, nativeId, { resume: nativeId });
230
+ return this.#startResult();
231
+ }
232
+
233
+ read(sessionId) {
234
+ this.#requireServing(sessionId);
235
+ return {
236
+ session_id: this.sessionId,
237
+ native_id: this.nativeId,
238
+ cwd: this.cwd,
239
+ model: this.model,
240
+ };
241
+ }
242
+
243
+ async history(params) {
244
+ this.#requireServing(params.session_id || '');
245
+ const cursor = params.cursor || '';
246
+ const limit = params.limit || 0;
247
+ if (cursor && !/^\d+$/.test(String(cursor))) {
248
+ throw new Refusal(
249
+ Code.MALFORMED_REQUEST,
250
+ `session.history cursor ${quote(cursor)} is not a position this sidecar issued`,
251
+ );
252
+ }
253
+ const start = cursor ? Number(cursor) : 0;
254
+ let messages;
255
+ try {
256
+ messages = await this.sdk.getSessionMessages(this.nativeId, { dir: this.cwd });
257
+ } catch (err) {
258
+ throw new Refusal(Code.INTERNAL, `session.history could not be read: ${why(err)}`);
259
+ }
260
+ const items = messages.map(serializeMessage);
261
+ const end = limit <= 0 ? items.length : Math.min(start + limit, items.length);
262
+ const hasMore = end < items.length;
263
+ return {
264
+ items: items.slice(start, end),
265
+ next_cursor: hasMore ? String(end) : '',
266
+ has_more: hasMore,
267
+ };
268
+ }
269
+
270
+ async submit(params) {
271
+ const sessionId = params.session_id || '';
272
+ const conn = this.#requireLive(sessionId);
273
+
274
+ const mode = params.mode || '';
275
+ if (!SERVED_SUBMIT_MODES.includes(mode)) {
276
+ const reason =
277
+ UNSERVED_SUBMIT_MODES[mode] ??
278
+ `this sidecar serves mode ${SERVED_SUBMIT_MODES.join(' and ')}, and knows no mode ${quote(mode)}`;
279
+ throw new Refusal(Code.UNSUPPORTED_MODE, `input.submit mode ${quote(mode)} is not served: ${reason}`);
280
+ }
281
+ const text = params.text || '';
282
+ if (!text) {
283
+ throw new Refusal(
284
+ Code.MALFORMED_REQUEST,
285
+ 'input.submit requires a non-empty text; the frame carried none',
286
+ );
287
+ }
288
+
289
+ // uuid is the one field measured to round-trip as the carrier; no
290
+ // carrier means no uuid key at all (measured-constraints.md section 2).
291
+ const carrier = params.carrier || '';
292
+ const message = { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null };
293
+ if (carrier) message.uuid = carrier;
294
+ conn.input.push(message);
295
+
296
+ // After the message is queued, never before; a steer folded into a
297
+ // running turn gets no frame and no turn_id (I11: beginTurn mints).
298
+ const frames = beginTurn(conn.state, carrier);
299
+ for (const f of frames) this.emit(f);
300
+
301
+ const result = { accepted: true };
302
+ if (carrier) result.carrier = carrier;
303
+ if (frames.length) result.turn_id = conn.state.currentTurnId;
304
+ return result;
305
+ }
306
+
307
+ async interrupt(sessionId) {
308
+ const conn = this.#requireLive(sessionId);
309
+ if (!conn.state.currentTurnId) {
310
+ throw new Refusal(Code.NO_ACTIVE_TURN, `session ${quote(sessionId)} has no turn in flight to interrupt`);
311
+ }
312
+ // The still_queued receipt is ignored; the turn's end is turn.completed.
313
+ await this.#control('turn.interrupt', () => conn.q.interrupt());
314
+ return { interrupted: true };
315
+ }
316
+
317
+ async setModel(params) {
318
+ const conn = this.#requireLive(params.session_id || '');
319
+ const model = params.model || '';
320
+ if (!model) {
321
+ throw new Refusal(
322
+ Code.MALFORMED_REQUEST,
323
+ 'model.set requires a non-empty model; the frame carried none',
324
+ );
325
+ }
326
+ const effort = params.effort || '';
327
+ if (effort) {
328
+ throw new Refusal(
329
+ Code.UNSUPPORTED_MODE,
330
+ `effort ${quote(effort)} cannot be applied: this vendor exposes no reasoning-effort ` +
331
+ 'control on a live session, and applying the model alone would report a change ' +
332
+ 'to effort that never happened. Send model.set with an empty effort to change ' +
333
+ 'the model only',
334
+ );
335
+ }
336
+ await this.#control('model.set', () => conn.q.setModel(model));
337
+ this.model = model; // after the SDK call: a failed change never reads back as applied
338
+ return { model, effort: '' };
339
+ }
340
+
341
+ async setPermissionMode(sessionId, mode) {
342
+ checkPermissionMode(mode);
343
+ this.#requireServing(sessionId);
344
+ const conn = this.conn;
345
+ if (!conn) {
346
+ throw new Refusal(
347
+ Code.UNKNOWN_SESSION,
348
+ `session ${quote(sessionId)} is not connected; there is no live client to change the mode of`,
349
+ );
350
+ }
351
+ await this.#control('permission.set', () => conn.q.setPermissionMode(mode));
352
+ this.permissionMode = mode;
353
+ return { permission_mode: mode };
354
+ }
355
+
356
+ async close(sessionId) {
357
+ this.#requireServing(sessionId);
358
+ await this.disconnect();
359
+ return {};
360
+ }
361
+
362
+ /**
363
+ * Fails every outstanding request, ends the input queue, closes the query
364
+ * and waits for the read loop (D07). Safe with no session, when repeated,
365
+ * and when called concurrently with an in-flight #open.
366
+ */
367
+ async disconnect() {
368
+ this.correlation.failPending(
369
+ 'the session was closed while the tool call was still outstanding; ' +
370
+ `session ${quote(this.sessionId)} is gone and its calls cannot be answered`,
371
+ );
372
+ const conn = this.conn ?? this.#pending;
373
+ if (!conn) return;
374
+ this.conn = null;
375
+ this.#pending = null;
376
+ conn.input.end();
377
+ conn.q?.close();
378
+ if (conn.loop) await conn.loop;
379
+ }
380
+
381
+ /** The session.ready frame; T04 writes it after the start/resume response. */
382
+ readyEvent() {
383
+ return sessionReady(this.sessionId || '', this.nativeId, this.model);
384
+ }
385
+
386
+ // ---- internals
387
+
388
+ async #open(params, sessionId, nativeId, which) {
389
+ const state = new StreamState(sessionId);
390
+ const input = new InputQueue();
391
+ const turnId = () => state.currentTurnId ?? '';
392
+
393
+ // Every tool use is asked of Longshot; updatedInput is the input
394
+ // unchanged, since nobody rewrites a tool call's arguments.
395
+ const canUseTool = async (toolName, toolInput) => {
396
+ const r = await this.correlation.request('permission.request', {
397
+ session_id: sessionId,
398
+ turn_id: turnId(),
399
+ tool_name: toolName,
400
+ input: toolInput,
401
+ });
402
+ return r.behavior === 'allow'
403
+ ? { behavior: 'allow', updatedInput: toolInput }
404
+ : { behavior: 'deny', message: r.message || `${toolName} was refused` };
405
+ };
406
+
407
+ // tool_id must correlate with the tool.started already emitted, and the
408
+ // MCP handler is never told that id: first match by name, else a minted
409
+ // id (session.py:376-396). Two calls of one tool outstanding at once may
410
+ // get each other's id; exact correlation is F06.
411
+ const toolIdFor = (fullName) => {
412
+ for (const [id, name] of state.toolNames) if (name === fullName) return id;
413
+ return randomUUID();
414
+ };
415
+ const callTool = (fullName, args) =>
416
+ this.correlation.request('tool.call', {
417
+ session_id: sessionId,
418
+ turn_id: turnId(),
419
+ tool_id: toolIdFor(fullName),
420
+ tool_name: fullName,
421
+ arguments: args,
422
+ });
423
+
424
+ const options = buildOptions(params, {
425
+ ...which,
426
+ canUseTool,
427
+ mcpServers: { longshot: buildLongshotServer(params.tools ?? [], callTool) },
428
+ });
429
+
430
+ const conn = { q: null, input, state, loop: null };
431
+ // Record before the first await so that #requireUnopened and disconnect()
432
+ // see this connection even while initializationResult() is pending.
433
+ this.#pending = conn;
434
+ let ended = false;
435
+ let loopError;
436
+ try {
437
+ conn.q = this.sdk.query({ prompt: input, options });
438
+ // The loop starts before initialization is awaited (D07).
439
+ conn.loop = (async () => {
440
+ try {
441
+ for await (const m of conn.q) {
442
+ for (const f of mapMessage(m, state, this.logUnknown)) this.emit(f);
443
+ }
444
+ } catch (err) {
445
+ loopError = err;
446
+ }
447
+ ended = true;
448
+ if (this.conn === conn) this.#streamEnded(conn, loopError);
449
+ })();
450
+ const earlyEnd = () => loopError ?? new Error(EARLY_END);
451
+ await Promise.race([
452
+ conn.q.initializationResult(),
453
+ conn.loop.then(() => { throw earlyEnd(); }),
454
+ ]);
455
+ if (ended) throw earlyEnd();
456
+ } catch (err) {
457
+ if (this.#pending === conn) {
458
+ // disconnect() hasn't cleaned up yet; we own the cleanup.
459
+ this.#pending = null;
460
+ input.end();
461
+ conn.q?.close();
462
+ if (conn.loop) await conn.loop;
463
+ } else {
464
+ // disconnect() already cleared #pending and closed the query/loop.
465
+ if (conn.loop) await conn.loop;
466
+ }
467
+ throw new Refusal(Code.INTERNAL, OPEN_FAILED + why(err));
468
+ }
469
+
470
+ if (this.#pending !== conn) {
471
+ // disconnect() was called while initializationResult() was pending and
472
+ // already cleaned up; don't install a dead connection.
473
+ if (conn.loop) await conn.loop;
474
+ throw new Refusal(Code.INTERNAL, OPEN_FAILED + 'the session was disconnected while opening');
475
+ }
476
+
477
+ this.#pending = null;
478
+ this.conn = conn;
479
+ this.sessionId = sessionId;
480
+ this.nativeId = nativeId;
481
+ this.cwd = params.cwd || '';
482
+ this.model = params.model || '';
483
+ this.effort = params.effort || '';
484
+ this.permissionMode = params.permission_mode;
485
+ }
486
+
487
+ /**
488
+ * The read loop finished with nobody closing the session: the CLI died.
489
+ * A turn in flight is ended on the wire, then the session is disconnected.
490
+ */
491
+ #streamEnded(conn, error) {
492
+ const reason = error ? why(error) : 'the CLI closed its output';
493
+ this.logProblem(`claude sidecar: the Claude CLI stream ended: ${reason}\n`);
494
+ const turnId = conn.state.currentTurnId;
495
+ if (turnId !== null) {
496
+ const at = new Date().toISOString();
497
+ this.emit(event('error', {
498
+ session_id: conn.state.sessionId,
499
+ turn_id: turnId,
500
+ message: `the Claude CLI stopped before the turn ended: ${reason}`,
501
+ will_retry: false,
502
+ at,
503
+ }));
504
+ this.emit(event('turn.completed', { session_id: conn.state.sessionId, turn_id: turnId, at }));
505
+ conn.state.currentTurnId = null;
506
+ }
507
+ this.conn = null;
508
+ conn.input.end();
509
+ conn.q.close();
510
+ }
511
+
512
+ async #control(op, call) {
513
+ try {
514
+ await call();
515
+ } catch (err) {
516
+ throw new Refusal(Code.INTERNAL, `the Claude CLI refused ${op}: ${why(err)}`);
517
+ }
518
+ }
519
+
520
+ #startResult() {
521
+ return {
522
+ session_id: this.sessionId,
523
+ native_id: this.nativeId,
524
+ model: this.model,
525
+ effort: this.effort,
526
+ permission_mode: this.permissionMode,
527
+ };
528
+ }
529
+
530
+ // One sidecar serves one session (I03): a second open would orphan the
531
+ // first session's CLI. Also refuse while #pending is set: a concurrent
532
+ // start during an in-flight #open would race for conn.
533
+ #requireUnopened() {
534
+ if (this.conn !== null || this.#pending !== null) {
535
+ throw new Refusal(
536
+ Code.INTERNAL,
537
+ `this sidecar already serves session ${quote(this.sessionId)}; ` +
538
+ 'a second session needs a sidecar of its own',
539
+ );
540
+ }
541
+ }
542
+
543
+ // A session never opened serves no id at all, not even an empty one.
544
+ #requireServing(sessionId) {
545
+ const served = this.sessionId || '';
546
+ if (this.sessionId === null || sessionId !== served) {
547
+ throw new Refusal(Code.UNKNOWN_SESSION, `this sidecar is serving ${quote(served)}, not ${quote(sessionId)}`);
548
+ }
549
+ }
550
+
551
+ #requireLive(sessionId) {
552
+ this.#requireServing(sessionId);
553
+ if (!this.conn) {
554
+ throw new Refusal(
555
+ Code.UNKNOWN_SESSION,
556
+ `session ${quote(sessionId)} is not connected; there is no live client to serve this request`,
557
+ );
558
+ }
559
+ return this.conn;
560
+ }
561
+ }