opencode-muse 0.2.1 → 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.
- package/README.md +9 -5
- package/dist/errors.d.ts +4 -1
- package/dist/errors.js +6 -0
- package/dist/model/language-model.d.ts +14 -1
- package/dist/model/language-model.js +160 -48
- package/dist/model/prompt.d.ts +10 -0
- package/dist/model/prompt.js +26 -0
- package/dist/model/stream.d.ts +3 -1
- package/dist/model/stream.js +23 -1
- package/dist/msp/approvals.d.ts +52 -0
- package/dist/msp/approvals.js +146 -0
- package/dist/msp/client.d.ts +6 -0
- package/dist/msp/client.js +4 -0
- package/dist/msp/host.d.ts +15 -1
- package/dist/msp/host.js +72 -0
- package/dist/provider.js +3 -0
- package/dist/session/manager.d.ts +14 -1
- package/dist/session/manager.js +72 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Add the published package to your project-level `opencode.jsonc` (or
|
|
|
16
16
|
```jsonc
|
|
17
17
|
{
|
|
18
18
|
"$schema": "https://opencode.ai/config.json",
|
|
19
|
-
"plugins": ["opencode-muse@0.
|
|
19
|
+
"plugins": ["opencode-muse@0.3.0"],
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -41,7 +41,10 @@ EOF
|
|
|
41
41
|
|
|
42
42
|
The locally packed package passed provider discovery and a text-turn smoke
|
|
43
43
|
test. Muse models and a text turn were also confirmed in OpenChamber after
|
|
44
|
-
restarting it.
|
|
44
|
+
restarting it. Verified Muse CLI versions are 1.4.12 and 1.4.13 (the SDK
|
|
45
|
+
stays 1.4.12; a newer CLI fingerprint starts with a recorded warning
|
|
46
|
+
instead of failing). See
|
|
47
|
+
[#21](https://github.com/pgquiles/opencode-muse/issues/21).
|
|
45
48
|
|
|
46
49
|
## Authentication
|
|
47
50
|
|
|
@@ -101,9 +104,10 @@ setup placeholder rather than advertising a model that will fail on use.
|
|
|
101
104
|
warning instead of failing closed; a missing/malformed fingerprint or an
|
|
102
105
|
unsupported envelope version still fails closed. See
|
|
103
106
|
[#21](https://github.com/pgquiles/opencode-muse/issues/21).
|
|
104
|
-
- **Billing and simultaneous work:** Pay-as-you-go
|
|
105
|
-
|
|
106
|
-
new turn may be reported as busy
|
|
107
|
+
- **Billing and simultaneous work:** Pay-as-you-go catalog and text turns pass
|
|
108
|
+
on Muse 1.4.13 (see the compatibility matrix). The plugin also allows only
|
|
109
|
+
one active writer per workspace, so a new turn may be reported as busy
|
|
110
|
+
while another is still finishing.
|
|
107
111
|
|
|
108
112
|
## Troubleshooting
|
|
109
113
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MuseBridgeErrorCode = "MUSE_HOST_START_FAILED" | "MUSE_STARTUP_TIMEOUT" | "MUSE_COMMAND_TIMEOUT" | "MUSE_REQUEST_TIMEOUT" | "MUSE_COMMAND_REJECTED" | "MUSE_PROTOCOL_ERROR" | "MUSE_TRANSPORT_ERROR" | "MUSE_HOST_CLOSED" | "MUSE_REGISTRY_ERROR" | "MUSE_REGISTRY_STORAGE_ERROR" | "MUSE_WORKSPACE_BUSY" | "MUSE_WORKSPACE_LOCK_CORRUPT" | "MUSE_TURN_FAILED" | "MUSE_TURN_UNQUEUED" | "MUSE_UNSUPPORTED_REQUEST" | "MUSE_UNSUPPORTED_INPUT" | "MUSE_RECOVERY_CHOICE_REQUIRED" | "MUSE_TRANSCRIPT_DIVERGED";
|
|
1
|
+
export type MuseBridgeErrorCode = "MUSE_HOST_START_FAILED" | "MUSE_STARTUP_TIMEOUT" | "MUSE_COMMAND_TIMEOUT" | "MUSE_REQUEST_TIMEOUT" | "MUSE_COMMAND_REJECTED" | "MUSE_PROTOCOL_ERROR" | "MUSE_TRANSPORT_ERROR" | "MUSE_HOST_CLOSED" | "MUSE_REGISTRY_ERROR" | "MUSE_REGISTRY_STORAGE_ERROR" | "MUSE_WORKSPACE_BUSY" | "MUSE_WORKSPACE_LOCK_CORRUPT" | "MUSE_TURN_FAILED" | "MUSE_TURN_UNQUEUED" | "MUSE_UNSUPPORTED_REQUEST" | "MUSE_UNSUPPORTED_INPUT" | "MUSE_RECOVERY_CHOICE_REQUIRED" | "MUSE_TRANSCRIPT_DIVERGED" | "MUSE_APPROVAL_PENDING";
|
|
2
2
|
/** Safe plugin error: never attach raw CLI stderr, MSP text, prompts, or payloads. */
|
|
3
3
|
export declare class MuseBridgeError extends Error {
|
|
4
4
|
readonly code: MuseBridgeErrorCode;
|
|
@@ -90,3 +90,6 @@ export declare class MuseRecoveryChoiceRequiredError extends MuseBridgeError {
|
|
|
90
90
|
export declare class MuseTranscriptDivergedError extends MuseBridgeError {
|
|
91
91
|
constructor();
|
|
92
92
|
}
|
|
93
|
+
export declare class MuseApprovalPendingError extends MuseBridgeError {
|
|
94
|
+
constructor();
|
|
95
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -179,3 +179,9 @@ export class MuseTranscriptDivergedError extends MuseBridgeError {
|
|
|
179
179
|
this.name = "MuseTranscriptDivergedError";
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
+
export class MuseApprovalPendingError extends MuseBridgeError {
|
|
183
|
+
constructor() {
|
|
184
|
+
super("MUSE_APPROVAL_PENDING", "Muse turn is parked awaiting an approval verdict; answer the pending prompt first", true);
|
|
185
|
+
this.name = "MuseApprovalPendingError";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider";
|
|
2
2
|
import type { MspNotification } from "../msp/client.js";
|
|
3
|
-
import { type ContextFragment, type ExtractedTurnPart } from "./prompt.js";
|
|
3
|
+
import { type ApprovalResponse, type ContextFragment, type ExtractedTurnPart } from "./prompt.js";
|
|
4
4
|
import type { MuseModelSelection, RequestContext } from "../types.js";
|
|
5
5
|
export interface MuseTurnHost {
|
|
6
6
|
mintCommandId(): string;
|
|
@@ -31,11 +31,24 @@ export interface MuseTurnSession {
|
|
|
31
31
|
updateCursor?(cursor: string): Promise<void>;
|
|
32
32
|
recordTurnCompleted?(turnId: string, terminal: string, usage?: Record<string, unknown>): Promise<void>;
|
|
33
33
|
release?(): Promise<void>;
|
|
34
|
+
/** Cross-turn chunk dedup for approval prompts; shared per OpenCode session. */
|
|
35
|
+
emittedApprovalPrompts?: Set<string>;
|
|
36
|
+
/** Set by the session manager when this call must attach to a parked turn. */
|
|
37
|
+
joinParkedTurnId?: string;
|
|
38
|
+
/** Bumped by the manager when a newer call takes over this session's output. */
|
|
39
|
+
streamEpoch?: number;
|
|
40
|
+
/** When true, the lease already moved to the joining call: detach, never interrupt or release. */
|
|
41
|
+
handoffLeaseToJoiner?: boolean;
|
|
42
|
+
/** Settle every still-pending approval verdict as denied (cancel path). */
|
|
43
|
+
cancelParkedApprovals?(): Promise<void> | void;
|
|
34
44
|
}
|
|
35
45
|
export interface MuseLanguageRuntime {
|
|
36
46
|
selectionIndex: ReadonlyMap<string, MuseModelSelection>;
|
|
37
47
|
resolveSession(context: RequestContext): Promise<MuseTurnSession>;
|
|
38
48
|
videoChunkBytes?: number;
|
|
49
|
+
settleApprovalResponses?(sessionID: string, responses: ApprovalResponse[]): Promise<string[]>;
|
|
50
|
+
hasParkedApprovals?(sessionID: string): boolean | Promise<boolean>;
|
|
51
|
+
disarmParkedJoin?(sessionID: string): void;
|
|
39
52
|
}
|
|
40
53
|
export interface MuseTurnExecution {
|
|
41
54
|
turnId: string;
|
|
@@ -4,7 +4,7 @@ import { notificationCursorKey } from "../msp/notification.js";
|
|
|
4
4
|
import { MUSE_CONTEXT_HEADERS, readRequestContext } from "./context.js";
|
|
5
5
|
import { classifyMetaRequest, makeCompactionDigest, makeLocalTitle } from "./meta.js";
|
|
6
6
|
import { promptContainsCompactionFingerprint } from "../session/fingerprint.js";
|
|
7
|
-
import { extractTurnInput, validateModelOptions, } from "./prompt.js";
|
|
7
|
+
import { extractApprovalResponses, extractTurnInput, validateModelOptions, } from "./prompt.js";
|
|
8
8
|
import { MuseStreamMapper } from "./stream.js";
|
|
9
9
|
const DEFAULT_VIDEO_CHUNK_BYTES = 6 * 1024 * 1024;
|
|
10
10
|
function fingerprint(method, params) {
|
|
@@ -148,57 +148,88 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
148
148
|
let subscription;
|
|
149
149
|
let firstNotification;
|
|
150
150
|
let subscriptionController;
|
|
151
|
+
const joinTurnId = session.joinParkedTurnId;
|
|
152
|
+
session.joinParkedTurnId = undefined;
|
|
153
|
+
const joinedTurn = joinTurnId !== undefined;
|
|
154
|
+
if (!joinedTurn) {
|
|
155
|
+
// A fresh turn starts a new ownership epoch; stale handoff markers die here.
|
|
156
|
+
session.handoffLeaseToJoiner = false;
|
|
157
|
+
}
|
|
151
158
|
const cancel = () => {
|
|
152
159
|
if (cancelPromise)
|
|
153
160
|
return cancelPromise;
|
|
161
|
+
// A superseded stream detaches silently: the joining call owns the turn now.
|
|
162
|
+
if (!joinedTurn && session.handoffLeaseToJoiner)
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
const denyParked = async () => {
|
|
165
|
+
try {
|
|
166
|
+
await session.cancelParkedApprovals?.();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// The verdict latch settles denied on its own timeout; a cancel-time
|
|
170
|
+
// settle failure must not mask the interrupt below.
|
|
171
|
+
}
|
|
172
|
+
};
|
|
154
173
|
if (!turnId || !disposition) {
|
|
155
174
|
abortRequested = true;
|
|
156
|
-
return
|
|
175
|
+
return denyParked();
|
|
157
176
|
}
|
|
158
177
|
const unqueue = disposition === "queued";
|
|
159
178
|
const method = unqueue ? "turn/unqueue" : "turn/interrupt";
|
|
160
179
|
const params = unqueue
|
|
161
180
|
? { sessionId: session.sessionId, turnId }
|
|
162
181
|
: { sessionId: session.sessionId, turnId, retract: true };
|
|
163
|
-
cancelPromise = submitCommand(session, method, session.host.mintCommandId(), params).then(() => { });
|
|
182
|
+
cancelPromise = denyParked().then(() => submitCommand(session, method, session.host.mintCommandId(), params).then(() => { }));
|
|
164
183
|
return cancelPromise;
|
|
165
184
|
};
|
|
166
185
|
const onAbort = () => { abortRequested = true; void cancel().catch(() => { }); };
|
|
167
186
|
options.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
168
187
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
});
|
|
188
|
+
const turnCommandId = session.host.mintCommandId();
|
|
189
|
+
if (joinedTurn) {
|
|
190
|
+
// Attach to the parked turn: no turn/start, no model switch, no
|
|
191
|
+
// acceptance record. The settled verdict already resumed it server-side.
|
|
192
|
+
turnId = joinTurnId;
|
|
193
|
+
disposition = "joined";
|
|
194
|
+
subscriptionController = new AbortController();
|
|
195
|
+
subscription = session.host.subscribe(session.sessionId, session.replayAfterCursor, subscriptionController.signal)[Symbol.asyncIterator]();
|
|
196
|
+
firstNotification = subscription.next();
|
|
197
|
+
accepted = true;
|
|
180
198
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
else {
|
|
200
|
+
if (!sameSelection(session.currentSelection, context.museSelection)) {
|
|
201
|
+
const model = {
|
|
202
|
+
providerId: context.museSelection.providerId,
|
|
203
|
+
modelId: context.museSelection.modelId,
|
|
204
|
+
};
|
|
205
|
+
if (context.museSelection.profileId !== null)
|
|
206
|
+
model.profileId = context.museSelection.profileId;
|
|
207
|
+
await submitCommand(session, "session/setModel", session.host.mintCommandId(), {
|
|
208
|
+
sessionId: session.sessionId,
|
|
209
|
+
model,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const inputParts = await mapVideoParts(session, input, options.abortSignal, runtime.videoChunkBytes);
|
|
213
|
+
if (options.abortSignal?.aborted)
|
|
214
|
+
throw new DOMException("Muse request aborted before turn start", "AbortError");
|
|
215
|
+
subscriptionController = new AbortController();
|
|
216
|
+
subscription = session.host.subscribe(session.sessionId, session.replayAfterCursor, subscriptionController.signal)[Symbol.asyncIterator]();
|
|
217
|
+
firstNotification = subscription.next();
|
|
218
|
+
const params = {
|
|
219
|
+
sessionId: session.sessionId,
|
|
220
|
+
input: inputParts,
|
|
221
|
+
};
|
|
222
|
+
if (context.effort)
|
|
223
|
+
params.reasoningEffort = context.effort;
|
|
224
|
+
const ack = await submitCommand(session, "turn/start", turnCommandId, params);
|
|
225
|
+
if (typeof ack.turnId !== "string" || typeof ack.disposition !== "string") {
|
|
226
|
+
throw new MspProtocolError("turn/start");
|
|
227
|
+
}
|
|
228
|
+
turnId = ack.turnId;
|
|
229
|
+
disposition = ack.disposition;
|
|
230
|
+
accepted = true;
|
|
231
|
+
await session.recordTurnAccepted?.(turnCommandId, turnId);
|
|
197
232
|
}
|
|
198
|
-
turnId = ack.turnId;
|
|
199
|
-
disposition = ack.disposition;
|
|
200
|
-
accepted = true;
|
|
201
|
-
await session.recordTurnAccepted?.(commandId, turnId);
|
|
202
233
|
if (abortRequested)
|
|
203
234
|
await cancel();
|
|
204
235
|
const events = {
|
|
@@ -268,11 +299,16 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
268
299
|
options.abortSignal?.removeEventListener("abort", onAbort);
|
|
269
300
|
subscriptionController?.abort();
|
|
270
301
|
await subscription?.return?.();
|
|
271
|
-
|
|
302
|
+
// The joining call owns the lease now; the superseded owner detaches
|
|
303
|
+
// without releasing. The first release wins; exactly one runs.
|
|
304
|
+
if (session.handoffLeaseToJoiner)
|
|
305
|
+
session.handoffLeaseToJoiner = false;
|
|
306
|
+
else
|
|
307
|
+
await session.release?.();
|
|
272
308
|
}
|
|
273
309
|
},
|
|
274
310
|
};
|
|
275
|
-
return { turnId, commandId, disposition, events, cancel };
|
|
311
|
+
return { turnId, commandId: turnCommandId, disposition, events, cancel };
|
|
276
312
|
}
|
|
277
313
|
catch (error) {
|
|
278
314
|
options.abortSignal?.removeEventListener("abort", onAbort);
|
|
@@ -315,7 +351,10 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
315
351
|
finally {
|
|
316
352
|
subscriptionController?.abort();
|
|
317
353
|
await subscription?.return?.().catch(() => { });
|
|
318
|
-
|
|
354
|
+
if (session.handoffLeaseToJoiner)
|
|
355
|
+
session.handoffLeaseToJoiner = false;
|
|
356
|
+
else
|
|
357
|
+
await session.release?.().catch(() => { });
|
|
319
358
|
}
|
|
320
359
|
})();
|
|
321
360
|
}
|
|
@@ -325,7 +364,8 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
325
364
|
await subscription?.return?.();
|
|
326
365
|
const commandMethod = error?.method;
|
|
327
366
|
const ambiguousTurnStart = commandMethod === "turn/start" && !(error instanceof MspCommandRejectedError);
|
|
328
|
-
|
|
367
|
+
// A failed join never owned the lease; the parked owner still holds it.
|
|
368
|
+
if (!ambiguousTurnStart && !joinedTurn)
|
|
329
369
|
await session.release?.();
|
|
330
370
|
}
|
|
331
371
|
throw error;
|
|
@@ -399,7 +439,7 @@ function localMetaStream(kind, text) {
|
|
|
399
439
|
});
|
|
400
440
|
}
|
|
401
441
|
export function createMuseLanguageModel(runtime, modelID) {
|
|
402
|
-
const doPrepare = async (options) => {
|
|
442
|
+
const doPrepare = async (options, allowParkedJoin) => {
|
|
403
443
|
const requestKind = classifyCall(options);
|
|
404
444
|
if (requestKind !== "agent")
|
|
405
445
|
throw new MuseUnsupportedRequestError(requestKind);
|
|
@@ -414,22 +454,82 @@ export function createMuseLanguageModel(runtime, modelID) {
|
|
|
414
454
|
const initialExtraction = extractTurnInput(options.prompt, [], [], { signal: options.abortSignal });
|
|
415
455
|
if (!initialExtraction.ok)
|
|
416
456
|
throw inputErrorToException(initialExtraction.error);
|
|
457
|
+
const approvalResponses = extractApprovalResponses(options.prompt);
|
|
458
|
+
if (!allowParkedJoin && approvalResponses.length > 0) {
|
|
459
|
+
if (await runtime.hasParkedApprovals?.(context.sessionID)) {
|
|
460
|
+
throw new MuseUnsupportedRequestError("approval-response");
|
|
461
|
+
}
|
|
462
|
+
await runtime.settleApprovalResponses?.(context.sessionID, approvalResponses);
|
|
463
|
+
}
|
|
464
|
+
if (allowParkedJoin && approvalResponses.length > 0) {
|
|
465
|
+
if (!runtime.settleApprovalResponses) {
|
|
466
|
+
if (await runtime.hasParkedApprovals?.(context.sessionID)) {
|
|
467
|
+
throw new MuseUnsupportedRequestError("approval-response");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
await runtime.settleApprovalResponses(context.sessionID, approvalResponses);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
417
474
|
const session = await runtime.resolveSession(context);
|
|
475
|
+
// Set by the manager when the settled verdicts matched a parked turn.
|
|
476
|
+
const joining = session.joinParkedTurnId !== undefined;
|
|
418
477
|
if (session.contextError) {
|
|
419
|
-
|
|
478
|
+
if (joining)
|
|
479
|
+
runtime.disarmParkedJoin?.(context.sessionID);
|
|
480
|
+
else
|
|
481
|
+
await session.release?.();
|
|
420
482
|
throw new MuseUnsupportedRequestError(session.contextError);
|
|
421
483
|
}
|
|
422
484
|
const observedCompaction = (session.compactionFingerprints ?? []).some((candidate) => promptContainsCompactionFingerprint(options.prompt, candidate));
|
|
423
485
|
const extracted = extractTurnInput(options.prompt, observedCompaction ? [] : session.previousTranscriptFingerprints, session.contextAllowlist, { signal: options.abortSignal });
|
|
424
486
|
if (!extracted.ok) {
|
|
425
|
-
|
|
487
|
+
if (joining && extracted.error.code === "no-new-user-input") {
|
|
488
|
+
try {
|
|
489
|
+
const execution = await runMuseTurn(context, [], options, {
|
|
490
|
+
...runtime,
|
|
491
|
+
resolveSession: async () => session,
|
|
492
|
+
}, session.previousTranscriptFingerprints);
|
|
493
|
+
return {
|
|
494
|
+
context,
|
|
495
|
+
execution,
|
|
496
|
+
mapper: new MuseStreamMapper(execution.turnId, session.emittedApprovalPrompts),
|
|
497
|
+
warnings: ignoredOptionWarnings(supportedOptions.options.ignoredOptions),
|
|
498
|
+
epoch: session.streamEpoch,
|
|
499
|
+
session,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
runtime.disarmParkedJoin?.(context.sessionID);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (joining)
|
|
508
|
+
runtime.disarmParkedJoin?.(context.sessionID);
|
|
509
|
+
else
|
|
510
|
+
await session.release?.();
|
|
426
511
|
throw inputErrorToException(extracted.error);
|
|
427
512
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
513
|
+
let execution;
|
|
514
|
+
try {
|
|
515
|
+
execution = await runMuseTurn(context, extracted.parts, options, {
|
|
516
|
+
...runtime,
|
|
517
|
+
resolveSession: async () => session,
|
|
518
|
+
}, extracted.nextTranscriptFingerprints);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (joining)
|
|
522
|
+
runtime.disarmParkedJoin?.(context.sessionID);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
context,
|
|
527
|
+
execution,
|
|
528
|
+
mapper: new MuseStreamMapper(execution.turnId, session.emittedApprovalPrompts),
|
|
529
|
+
warnings: ignoredOptionWarnings(supportedOptions.options.ignoredOptions),
|
|
530
|
+
epoch: session.streamEpoch,
|
|
531
|
+
session,
|
|
532
|
+
};
|
|
433
533
|
};
|
|
434
534
|
return {
|
|
435
535
|
specificationVersion: "v3",
|
|
@@ -446,7 +546,7 @@ export function createMuseLanguageModel(runtime, modelID) {
|
|
|
446
546
|
}
|
|
447
547
|
if (requestKind === "unsupported-generate")
|
|
448
548
|
throw new MuseUnsupportedRequestError("generate");
|
|
449
|
-
const prepared = await doPrepare(options);
|
|
549
|
+
const prepared = await doPrepare(options, true);
|
|
450
550
|
let outputClosed = false;
|
|
451
551
|
let controllerRef;
|
|
452
552
|
const closeOutput = () => {
|
|
@@ -465,6 +565,14 @@ export function createMuseLanguageModel(runtime, modelID) {
|
|
|
465
565
|
void (async () => {
|
|
466
566
|
try {
|
|
467
567
|
for await (const notification of prepared.execution.events) {
|
|
568
|
+
// A newer call took over this session's output: end this
|
|
569
|
+
// stream without a finish part. The generator's finally
|
|
570
|
+
// detaches the lease to the joining call.
|
|
571
|
+
if (prepared.epoch !== undefined && prepared.session.streamEpoch !== undefined
|
|
572
|
+
&& prepared.session.streamEpoch !== prepared.epoch) {
|
|
573
|
+
closeOutput();
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
468
576
|
const parts = prepared.mapper.accept(notification);
|
|
469
577
|
for (const part of parts) {
|
|
470
578
|
if (outputClosed)
|
|
@@ -508,10 +616,14 @@ export function createMuseLanguageModel(runtime, modelID) {
|
|
|
508
616
|
}
|
|
509
617
|
if (requestKind === "unsupported-generate")
|
|
510
618
|
throw new MuseUnsupportedRequestError("generate");
|
|
511
|
-
const prepared = await doPrepare(options);
|
|
619
|
+
const prepared = await doPrepare(options, false);
|
|
512
620
|
let terminalError;
|
|
513
621
|
const iterator = prepared.execution.events[Symbol.asyncIterator]();
|
|
514
622
|
while (true) {
|
|
623
|
+
if (prepared.epoch !== undefined && prepared.session.streamEpoch !== undefined
|
|
624
|
+
&& prepared.session.streamEpoch !== prepared.epoch) {
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
515
627
|
const next = await iterator.next();
|
|
516
628
|
if (next.done)
|
|
517
629
|
break;
|
package/dist/model/prompt.d.ts
CHANGED
|
@@ -65,4 +65,14 @@ export declare const DEFAULT_MAX_MEDIA_BYTES: number;
|
|
|
65
65
|
export declare const DEFAULT_MAX_INLINE_IMAGE_BYTES: number;
|
|
66
66
|
export declare function fingerprintVisibleTranscript(prompt: LanguageModelV3Prompt): string[];
|
|
67
67
|
export declare function extractTurnInput(prompt: LanguageModelV3Prompt, previousTranscriptFingerprints: readonly string[], contextAllowlist: readonly ContextFragment[], options?: ExtractTurnInputOptions): ExtractTurnInputResult;
|
|
68
|
+
export interface ApprovalResponse {
|
|
69
|
+
approvalId: string;
|
|
70
|
+
approved: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Collect OpenCode's answers to previously emitted `tool-approval-request`
|
|
74
|
+
* chunks. Malformed entries are ignored; the caller treats an unmatched
|
|
75
|
+
* response as stale and drops it rather than failing the turn.
|
|
76
|
+
*/
|
|
77
|
+
export declare function extractApprovalResponses(prompt: LanguageModelV3Prompt): ApprovalResponse[];
|
|
68
78
|
export declare function validateModelOptions(options: ModelOptionsInput): ModelOptionsResult;
|
package/dist/model/prompt.js
CHANGED
|
@@ -150,6 +150,32 @@ export function extractTurnInput(prompt, previousTranscriptFingerprints, context
|
|
|
150
150
|
return { ok: false, error: inputError("no-new-user-input") };
|
|
151
151
|
return { ok: true, parts, nextTranscriptFingerprints: currentFingerprints };
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Collect OpenCode's answers to previously emitted `tool-approval-request`
|
|
155
|
+
* chunks. Malformed entries are ignored; the caller treats an unmatched
|
|
156
|
+
* response as stale and drops it rather than failing the turn.
|
|
157
|
+
*/
|
|
158
|
+
export function extractApprovalResponses(prompt) {
|
|
159
|
+
const responses = [];
|
|
160
|
+
for (const message of prompt) {
|
|
161
|
+
if (message.role !== "user")
|
|
162
|
+
continue;
|
|
163
|
+
const content = message.content;
|
|
164
|
+
if (!Array.isArray(content))
|
|
165
|
+
continue;
|
|
166
|
+
for (const part of content) {
|
|
167
|
+
if (typeof part !== "object" || part === null)
|
|
168
|
+
continue;
|
|
169
|
+
const candidate = part;
|
|
170
|
+
if (candidate.type !== "tool-approval-response")
|
|
171
|
+
continue;
|
|
172
|
+
if (typeof candidate.approvalId !== "string" || typeof candidate.approved !== "boolean")
|
|
173
|
+
continue;
|
|
174
|
+
responses.push({ approvalId: candidate.approvalId, approved: candidate.approved });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return responses;
|
|
178
|
+
}
|
|
153
179
|
export function validateModelOptions(options) {
|
|
154
180
|
if (options.responseFormat?.type === "json") {
|
|
155
181
|
return { ok: false, error: inputError("unsupported-response-format", { field: "responseFormat" }) };
|
package/dist/model/stream.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type MuseStreamPart = Record<string, unknown>;
|
|
|
9
9
|
/** Maps typed Muse view facts to text/reasoning-only V3 events and safe usage. */
|
|
10
10
|
export declare class MuseStreamMapper {
|
|
11
11
|
readonly turnId: string;
|
|
12
|
+
private readonly emittedApprovals;
|
|
12
13
|
private readonly itemKinds;
|
|
13
14
|
private readonly itemStarted;
|
|
14
15
|
private readonly reasoningStarted;
|
|
@@ -23,7 +24,7 @@ export declare class MuseStreamMapper {
|
|
|
23
24
|
private currentFinishReason;
|
|
24
25
|
terminal?: string;
|
|
25
26
|
failure?: Error;
|
|
26
|
-
constructor(turnId: string);
|
|
27
|
+
constructor(turnId: string, emittedApprovals?: Set<string>);
|
|
27
28
|
get generatedText(): string;
|
|
28
29
|
get content(): Array<{
|
|
29
30
|
type: "text";
|
|
@@ -36,6 +37,7 @@ export declare class MuseStreamMapper {
|
|
|
36
37
|
raw: string;
|
|
37
38
|
} | undefined;
|
|
38
39
|
accept(notification: MspNotification): MuseStreamPart[];
|
|
40
|
+
private approvalRequestedEvent;
|
|
39
41
|
private itemStartedEvent;
|
|
40
42
|
private itemDeltaEvent;
|
|
41
43
|
private itemCompletedEvent;
|
package/dist/model/stream.js
CHANGED
|
@@ -13,6 +13,7 @@ function terminalFinishReason(terminal) {
|
|
|
13
13
|
/** Maps typed Muse view facts to text/reasoning-only V3 events and safe usage. */
|
|
14
14
|
export class MuseStreamMapper {
|
|
15
15
|
turnId;
|
|
16
|
+
emittedApprovals;
|
|
16
17
|
itemKinds = new Map();
|
|
17
18
|
itemStarted = new Set();
|
|
18
19
|
reasoningStarted = new Set();
|
|
@@ -27,8 +28,9 @@ export class MuseStreamMapper {
|
|
|
27
28
|
currentFinishReason;
|
|
28
29
|
terminal;
|
|
29
30
|
failure;
|
|
30
|
-
constructor(turnId) {
|
|
31
|
+
constructor(turnId, emittedApprovals = new Set()) {
|
|
31
32
|
this.turnId = turnId;
|
|
33
|
+
this.emittedApprovals = emittedApprovals;
|
|
32
34
|
}
|
|
33
35
|
get generatedText() {
|
|
34
36
|
return this.textParts.map((part) => part.text).join("");
|
|
@@ -80,10 +82,30 @@ export class MuseStreamMapper {
|
|
|
80
82
|
return this.finishWasSent ? [] : [this.finishPart("other", "retracted")];
|
|
81
83
|
case "turn/completed":
|
|
82
84
|
return this.turnCompleted(params);
|
|
85
|
+
case "approval/requested":
|
|
86
|
+
return this.approvalRequestedEvent(params);
|
|
83
87
|
default:
|
|
84
88
|
return [];
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
approvalRequestedEvent(params) {
|
|
92
|
+
const approvalId = params.approvalId;
|
|
93
|
+
const toolCallId = params.toolCallId;
|
|
94
|
+
if (typeof approvalId !== "string" || typeof toolCallId !== "string")
|
|
95
|
+
return [];
|
|
96
|
+
const requirementId = typeof params.currentRequirementId === "string" ? params.currentRequirementId : "";
|
|
97
|
+
const stageKey = `${approvalId} ${requirementId}`;
|
|
98
|
+
if (this.emittedApprovals.has(stageKey))
|
|
99
|
+
return [];
|
|
100
|
+
this.emittedApprovals.add(stageKey);
|
|
101
|
+
const toolName = params.toolName;
|
|
102
|
+
return [{
|
|
103
|
+
type: "tool-approval-request",
|
|
104
|
+
approvalId,
|
|
105
|
+
toolCallId,
|
|
106
|
+
...(typeof toolName === "string" ? { providerMetadata: { muse: { toolName } } } : {}),
|
|
107
|
+
}];
|
|
108
|
+
}
|
|
87
109
|
itemStartedEvent(params) {
|
|
88
110
|
const item = params.item;
|
|
89
111
|
if (!item || item.turnId !== this.turnId || typeof item.itemId !== "string")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map an OpenCode boolean approval verdict onto an MSP approval choice id.
|
|
3
|
+
*
|
|
4
|
+
* @param choices The `choices` array from the `approval/request` payload.
|
|
5
|
+
* @param approved True when OpenCode approved, false when denied.
|
|
6
|
+
* @returns The selected `choiceId`, or null when no single safe choice fits.
|
|
7
|
+
* Null means the caller must fail closed (deny with a fresh-session hint).
|
|
8
|
+
*/
|
|
9
|
+
export interface ApprovalRequestParams {
|
|
10
|
+
approvalId: string;
|
|
11
|
+
currentRequirementId: string;
|
|
12
|
+
availableChoices: readonly unknown[];
|
|
13
|
+
sessionId: string;
|
|
14
|
+
turnId: string;
|
|
15
|
+
toolCallId: string;
|
|
16
|
+
toolName?: string;
|
|
17
|
+
}
|
|
18
|
+
export type ApprovalVerdict = {
|
|
19
|
+
approved: boolean;
|
|
20
|
+
params: ApprovalRequestParams;
|
|
21
|
+
} | {
|
|
22
|
+
approved: false;
|
|
23
|
+
timedOut: true;
|
|
24
|
+
params: ApprovalRequestParams;
|
|
25
|
+
} | {
|
|
26
|
+
alreadyDecided: true;
|
|
27
|
+
params: ApprovalRequestParams;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Per-session latch for MSP approval round trips. Mirrors the SDK router's
|
|
31
|
+
* discipline without duplicating it: one verdict per approval, a new stage
|
|
32
|
+
* (requirement id) replaces nothing but parks alongside, decided stages
|
|
33
|
+
* never re-park, and an undecided wait fails closed as denied on timeout.
|
|
34
|
+
* Late verdicts for settled approvals are dropped as stale.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ApprovalExchange {
|
|
37
|
+
private readonly options;
|
|
38
|
+
private readonly pending;
|
|
39
|
+
private readonly decided;
|
|
40
|
+
constructor(options?: {
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
});
|
|
43
|
+
pendingApprovalIds(): string[];
|
|
44
|
+
/** The Muse turn that opened a pending approval, if it is still parked. */
|
|
45
|
+
pendingApprovalTurnId(approvalId: string): string | undefined;
|
|
46
|
+
requestApproval(params: unknown): Promise<ApprovalVerdict>;
|
|
47
|
+
settle(approvalId: string, approved: boolean): boolean;
|
|
48
|
+
markDecided(approvalId: string, requirementId: string): void;
|
|
49
|
+
private timeOut;
|
|
50
|
+
private clear;
|
|
51
|
+
}
|
|
52
|
+
export declare function selectApprovalChoiceId(choices: readonly unknown[], approved: boolean): string | null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Selection of an MSP approval choice id from an OpenCode-style boolean verdict.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode owns persistence (its "always allow" equivalent suppresses future
|
|
4
|
+
// prompts before MSP is ever asked), so a bare `true` maps to the minimal
|
|
5
|
+
// grant: one `once`-scoped approve choice. Anything broader or ambiguous
|
|
6
|
+
// fails closed by returning null, and `localPersistent` choices are never
|
|
7
|
+
// selected: the plugin must not create host-side persistent policy.
|
|
8
|
+
function validChoices(choices) {
|
|
9
|
+
const valid = [];
|
|
10
|
+
for (const entry of choices) {
|
|
11
|
+
if (typeof entry !== "object" || entry === null)
|
|
12
|
+
continue;
|
|
13
|
+
const candidate = entry;
|
|
14
|
+
if (typeof candidate.choiceId !== "string")
|
|
15
|
+
continue;
|
|
16
|
+
if (candidate.decision !== "approve" && candidate.decision !== "reject")
|
|
17
|
+
continue;
|
|
18
|
+
if (typeof candidate.scope !== "string")
|
|
19
|
+
continue;
|
|
20
|
+
valid.push({ choiceId: candidate.choiceId, decision: candidate.decision, scope: candidate.scope });
|
|
21
|
+
}
|
|
22
|
+
return valid;
|
|
23
|
+
}
|
|
24
|
+
function singleChoiceId(choices) {
|
|
25
|
+
return choices.length === 1 ? choices[0].choiceId : null;
|
|
26
|
+
}
|
|
27
|
+
function asApprovalRequestParams(value) {
|
|
28
|
+
if (typeof value !== "object" || value === null)
|
|
29
|
+
return null;
|
|
30
|
+
const params = value;
|
|
31
|
+
if (typeof params.approvalId !== "string")
|
|
32
|
+
return null;
|
|
33
|
+
if (typeof params.currentRequirementId !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
if (!Array.isArray(params.availableChoices))
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof params.sessionId !== "string")
|
|
38
|
+
return null;
|
|
39
|
+
if (typeof params.turnId !== "string")
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof params.toolCallId !== "string")
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
approvalId: params.approvalId,
|
|
45
|
+
currentRequirementId: params.currentRequirementId,
|
|
46
|
+
availableChoices: params.availableChoices,
|
|
47
|
+
sessionId: params.sessionId,
|
|
48
|
+
turnId: params.turnId,
|
|
49
|
+
toolCallId: params.toolCallId,
|
|
50
|
+
...(typeof params.toolName === "string" ? { toolName: params.toolName } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Per-session latch for MSP approval round trips. Mirrors the SDK router's
|
|
55
|
+
* discipline without duplicating it: one verdict per approval, a new stage
|
|
56
|
+
* (requirement id) replaces nothing but parks alongside, decided stages
|
|
57
|
+
* never re-park, and an undecided wait fails closed as denied on timeout.
|
|
58
|
+
* Late verdicts for settled approvals are dropped as stale.
|
|
59
|
+
*/
|
|
60
|
+
export class ApprovalExchange {
|
|
61
|
+
options;
|
|
62
|
+
pending = new Map();
|
|
63
|
+
decided = new Set();
|
|
64
|
+
constructor(options = { timeoutMs: 10 * 60 * 1000 }) {
|
|
65
|
+
this.options = options;
|
|
66
|
+
}
|
|
67
|
+
pendingApprovalIds() {
|
|
68
|
+
return [...this.pending.keys()];
|
|
69
|
+
}
|
|
70
|
+
/** The Muse turn that opened a pending approval, if it is still parked. */
|
|
71
|
+
pendingApprovalTurnId(approvalId) {
|
|
72
|
+
const stages = this.pending.get(approvalId)?.stages.values();
|
|
73
|
+
return stages?.next().value?.params.turnId;
|
|
74
|
+
}
|
|
75
|
+
requestApproval(params) {
|
|
76
|
+
const parsed = asApprovalRequestParams(params);
|
|
77
|
+
if (parsed === null)
|
|
78
|
+
return Promise.reject(new Error("unroutable-approval-request"));
|
|
79
|
+
const stageKey = `${parsed.approvalId} ${parsed.currentRequirementId}`;
|
|
80
|
+
if (this.decided.has(stageKey)) {
|
|
81
|
+
return Promise.resolve({ alreadyDecided: true, params: parsed });
|
|
82
|
+
}
|
|
83
|
+
const existing = this.pending.get(parsed.approvalId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
const staged = existing.stages.get(parsed.currentRequirementId);
|
|
86
|
+
if (staged) {
|
|
87
|
+
staged.params = parsed;
|
|
88
|
+
return staged.promise;
|
|
89
|
+
}
|
|
90
|
+
let resolveStage;
|
|
91
|
+
const promise = new Promise((resolve) => { resolveStage = resolve; });
|
|
92
|
+
existing.stages.set(parsed.currentRequirementId, { params: parsed, resolve: resolveStage, promise });
|
|
93
|
+
return promise;
|
|
94
|
+
}
|
|
95
|
+
let resolveStage;
|
|
96
|
+
const promise = new Promise((resolve) => { resolveStage = resolve; });
|
|
97
|
+
const pending = {
|
|
98
|
+
approvalId: parsed.approvalId,
|
|
99
|
+
stages: new Map([[parsed.currentRequirementId, { params: parsed, resolve: resolveStage, promise }]]),
|
|
100
|
+
timer: undefined,
|
|
101
|
+
};
|
|
102
|
+
pending.timer = setTimeout(() => this.timeOut(parsed.approvalId), this.options.timeoutMs);
|
|
103
|
+
this.pending.set(parsed.approvalId, pending);
|
|
104
|
+
return promise;
|
|
105
|
+
}
|
|
106
|
+
settle(approvalId, approved) {
|
|
107
|
+
const pending = this.pending.get(approvalId);
|
|
108
|
+
if (!pending)
|
|
109
|
+
return false;
|
|
110
|
+
this.clear(approvalId);
|
|
111
|
+
for (const stage of pending.stages.values()) {
|
|
112
|
+
stage.resolve({ approved, params: stage.params });
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
markDecided(approvalId, requirementId) {
|
|
117
|
+
this.decided.add(`${approvalId} ${requirementId}`);
|
|
118
|
+
this.clear(approvalId);
|
|
119
|
+
}
|
|
120
|
+
timeOut(approvalId) {
|
|
121
|
+
const pending = this.pending.get(approvalId);
|
|
122
|
+
if (!pending)
|
|
123
|
+
return;
|
|
124
|
+
this.clear(approvalId);
|
|
125
|
+
for (const stage of pending.stages.values()) {
|
|
126
|
+
stage.resolve({ approved: false, timedOut: true, params: stage.params });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
clear(approvalId) {
|
|
130
|
+
const pending = this.pending.get(approvalId);
|
|
131
|
+
if (pending?.timer !== undefined)
|
|
132
|
+
clearTimeout(pending.timer);
|
|
133
|
+
this.pending.delete(approvalId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function selectApprovalChoiceId(choices, approved) {
|
|
137
|
+
const valid = validChoices(choices);
|
|
138
|
+
const wantedDecision = approved ? "approve" : "reject";
|
|
139
|
+
const once = valid.filter((choice) => choice.decision === wantedDecision && choice.scope === "once");
|
|
140
|
+
if (once.length === 1)
|
|
141
|
+
return once[0].choiceId;
|
|
142
|
+
if (once.length > 1)
|
|
143
|
+
return null;
|
|
144
|
+
const session = valid.filter((choice) => choice.decision === wantedDecision && choice.scope === "session");
|
|
145
|
+
return singleChoiceId(session);
|
|
146
|
+
}
|
package/dist/msp/client.d.ts
CHANGED
|
@@ -6,12 +6,17 @@ export interface MspNotification {
|
|
|
6
6
|
params?: Record<string, unknown>;
|
|
7
7
|
emittedAtMs?: number;
|
|
8
8
|
}
|
|
9
|
+
export interface MspServerRequest {
|
|
10
|
+
method: string;
|
|
11
|
+
params?: Record<string, unknown>;
|
|
12
|
+
}
|
|
9
13
|
export interface MspConnectionLike {
|
|
10
14
|
readonly closed?: Promise<void>;
|
|
11
15
|
command(method: string, params: Record<string, unknown>, options?: CommandOptions): Promise<Record<string, unknown>>;
|
|
12
16
|
request(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
13
17
|
mintCommandId(): string;
|
|
14
18
|
onNotification(handler: (notification: MspNotification) => void): void;
|
|
19
|
+
onServerRequest?(handler: (request: MspServerRequest) => Promise<Record<string, unknown>>): void;
|
|
15
20
|
onProtocolError?(handler: (error: unknown) => void): void;
|
|
16
21
|
close(): Promise<void>;
|
|
17
22
|
}
|
|
@@ -19,6 +24,7 @@ export interface MspClientOptions {
|
|
|
19
24
|
commandTimeoutMs?: number;
|
|
20
25
|
requestTimeoutMs?: number;
|
|
21
26
|
closeConnection?: boolean;
|
|
27
|
+
onServerRequest?: (request: MspServerRequest) => Promise<Record<string, unknown>>;
|
|
22
28
|
onFatal?: () => void;
|
|
23
29
|
}
|
|
24
30
|
/** Thin typed facade over the SDK connection; it never logs or retains wire payloads. */
|
package/dist/msp/client.js
CHANGED
|
@@ -107,6 +107,10 @@ export class MspClient {
|
|
|
107
107
|
this.requestTimeoutMs = options.requestTimeoutMs ?? this.commandTimeoutMs;
|
|
108
108
|
this.closeConnection = options.closeConnection ?? true;
|
|
109
109
|
connection.onNotification((notification) => this.publish(notification));
|
|
110
|
+
if (options.onServerRequest) {
|
|
111
|
+
const handler = options.onServerRequest;
|
|
112
|
+
connection.onServerRequest?.((request) => handler(request));
|
|
113
|
+
}
|
|
110
114
|
connection.onProtocolError?.(() => {
|
|
111
115
|
if (this.observedProtocolFailure)
|
|
112
116
|
return;
|
package/dist/msp/host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FingerprintWarning, type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
|
|
2
|
-
import { MspClient, type MspConnectionLike, type MspNotification } from "./client.js";
|
|
2
|
+
import { MspClient, type MspConnectionLike, type MspNotification, type MspServerRequest } from "./client.js";
|
|
3
|
+
import { ApprovalExchange } from "./approvals.js";
|
|
3
4
|
export interface MspHostRuntime {
|
|
4
5
|
connection: MspConnectionLike;
|
|
5
6
|
initializeResult: Record<string, unknown>;
|
|
@@ -24,6 +25,13 @@ export interface MspHostOptions {
|
|
|
24
25
|
version: string;
|
|
25
26
|
};
|
|
26
27
|
capabilities?: Parameters<MspHandshake["initialize"]>[0]["capabilities"];
|
|
28
|
+
onServerRequest?: (request: MspServerRequest) => Promise<Record<string, unknown>>;
|
|
29
|
+
approvalTimeoutMs?: number;
|
|
30
|
+
onApprovalParked?: (info: {
|
|
31
|
+
museTurnId: string;
|
|
32
|
+
approvalId: string;
|
|
33
|
+
}) => void;
|
|
34
|
+
onApprovalUnparked?: (approvalId: string) => void;
|
|
27
35
|
}
|
|
28
36
|
export interface MspHostDependencies {
|
|
29
37
|
createHandshake?: (options: SpawnMspConnectionOptions) => MspHandshakeLike;
|
|
@@ -41,9 +49,13 @@ export declare class MspHost {
|
|
|
41
49
|
readonly initializeResult: Record<string, unknown>;
|
|
42
50
|
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
43
51
|
readonly fingerprintWarning?: FingerprintWarning;
|
|
52
|
+
/** Per-session latch for approval round trips; settled by OpenCode verdicts. */
|
|
53
|
+
readonly approvals: ApprovalExchange;
|
|
44
54
|
readonly exited: Promise<MspHostExit>;
|
|
45
55
|
private state;
|
|
46
56
|
private closePromise;
|
|
57
|
+
private readonly onApprovalParked;
|
|
58
|
+
private readonly onApprovalUnparked;
|
|
47
59
|
private constructor();
|
|
48
60
|
static start(options?: MspHostOptions, dependencies?: MspHostDependencies): Promise<MspHost>;
|
|
49
61
|
get isClosed(): boolean;
|
|
@@ -53,5 +65,7 @@ export declare class MspHost {
|
|
|
53
65
|
}): Promise<TResult>;
|
|
54
66
|
request<TResult extends object = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<TResult>;
|
|
55
67
|
subscribe(sessionId: string, after?: string, signal?: AbortSignal): AsyncIterable<MspNotification>;
|
|
68
|
+
private handleServerRequest;
|
|
69
|
+
private handleApprovalRequest;
|
|
56
70
|
close(): Promise<void>;
|
|
57
71
|
}
|
package/dist/msp/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MspError, checkServedFingerprint, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
|
|
2
2
|
import { MspCommandTimeoutError, MspHostClosedError, MspHostStartError, MspInitializeRejectedError, MspSchemaMismatchError, MspStartupTimeoutError, MspTransportError, } from "../errors.js";
|
|
3
3
|
import { MspClient } from "./client.js";
|
|
4
|
+
import { ApprovalExchange, selectApprovalChoiceId } from "./approvals.js";
|
|
4
5
|
function defaultHandshake(options) {
|
|
5
6
|
const handshake = spawnMspConnection(options);
|
|
6
7
|
return {
|
|
@@ -70,17 +71,28 @@ export class MspHost {
|
|
|
70
71
|
initializeResult;
|
|
71
72
|
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
72
73
|
fingerprintWarning;
|
|
74
|
+
/** Per-session latch for approval round trips; settled by OpenCode verdicts. */
|
|
75
|
+
approvals;
|
|
73
76
|
exited;
|
|
74
77
|
state = "open";
|
|
75
78
|
closePromise;
|
|
79
|
+
onApprovalParked;
|
|
80
|
+
onApprovalUnparked;
|
|
76
81
|
constructor(runtime, options, fingerprintWarning) {
|
|
77
82
|
this.runtime = runtime;
|
|
78
83
|
this.initializeResult = runtime.initializeResult;
|
|
79
84
|
this.fingerprintWarning = fingerprintWarning;
|
|
85
|
+
this.approvals = new ApprovalExchange({ timeoutMs: options.approvalTimeoutMs ?? 10 * 60 * 1000 });
|
|
86
|
+
this.onApprovalParked = options.onApprovalParked;
|
|
87
|
+
this.onApprovalUnparked = options.onApprovalUnparked;
|
|
88
|
+
const customServerRequest = options.onServerRequest;
|
|
80
89
|
this.client = new MspClient(runtime.connection, {
|
|
81
90
|
commandTimeoutMs: options.commandTimeoutMs,
|
|
82
91
|
requestTimeoutMs: options.requestTimeoutMs,
|
|
83
92
|
closeConnection: false,
|
|
93
|
+
onServerRequest: customServerRequest
|
|
94
|
+
? (request) => customServerRequest(request)
|
|
95
|
+
: (request) => this.handleServerRequest(request),
|
|
84
96
|
onFatal: () => {
|
|
85
97
|
queueMicrotask(() => { void this.close().catch(() => { }); });
|
|
86
98
|
},
|
|
@@ -175,6 +187,66 @@ export class MspHost {
|
|
|
175
187
|
throw new MspHostClosedError();
|
|
176
188
|
return this.client.subscribe(sessionId, after, signal);
|
|
177
189
|
}
|
|
190
|
+
async handleServerRequest(request) {
|
|
191
|
+
if (request.method === "approval/request")
|
|
192
|
+
return this.handleApprovalRequest(request.params);
|
|
193
|
+
// Mirror the SDK's own client: answered methods ack with {}; every other
|
|
194
|
+
// server-request method keeps the bare-connection -32601.
|
|
195
|
+
throw new MspError({
|
|
196
|
+
code: -32601,
|
|
197
|
+
message: `method not found: ${request.method}`,
|
|
198
|
+
data: { kind: "methodNotFound", retryable: false },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async handleApprovalRequest(params) {
|
|
202
|
+
let verdict;
|
|
203
|
+
try {
|
|
204
|
+
const pending = this.approvals.requestApproval(params);
|
|
205
|
+
const raw = (params ?? {});
|
|
206
|
+
if (typeof raw.approvalId === "string" && typeof raw.turnId === "string"
|
|
207
|
+
&& this.approvals.pendingApprovalIds().includes(raw.approvalId)) {
|
|
208
|
+
this.onApprovalParked?.({ museTurnId: raw.turnId, approvalId: raw.approvalId });
|
|
209
|
+
}
|
|
210
|
+
verdict = await pending;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Unroutable (malformed) approval requests are dropped like the SDK
|
|
214
|
+
// drops them; the server holds the turn under its own default-deny.
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
if ("alreadyDecided" in verdict)
|
|
218
|
+
return {};
|
|
219
|
+
const stage = verdict.params;
|
|
220
|
+
const choiceId = selectApprovalChoiceId(stage.availableChoices, verdict.approved)
|
|
221
|
+
?? (verdict.approved ? selectApprovalChoiceId(stage.availableChoices, false) : null);
|
|
222
|
+
try {
|
|
223
|
+
if (choiceId !== null) {
|
|
224
|
+
await this.command("approval/decide", {
|
|
225
|
+
commandId: this.client.mintCommandId(),
|
|
226
|
+
sessionId: stage.sessionId,
|
|
227
|
+
approvalId: stage.approvalId,
|
|
228
|
+
requirementId: stage.currentRequirementId,
|
|
229
|
+
choiceId,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
await this.command("turn/interrupt", {
|
|
234
|
+
commandId: this.client.mintCommandId(),
|
|
235
|
+
sessionId: stage.sessionId,
|
|
236
|
+
turnId: stage.turnId,
|
|
237
|
+
retract: true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// The turn may already be gone (cancel raced the verdict). Mark the
|
|
243
|
+
// stage so a re-sent request does not re-park a dead round trip; the
|
|
244
|
+
// request plane is still acked.
|
|
245
|
+
}
|
|
246
|
+
this.approvals.markDecided(stage.approvalId, stage.currentRequirementId);
|
|
247
|
+
this.onApprovalUnparked?.(stage.approvalId);
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
178
250
|
close() {
|
|
179
251
|
if (this.closePromise)
|
|
180
252
|
return this.closePromise;
|
package/dist/provider.js
CHANGED
|
@@ -67,6 +67,9 @@ export async function createProviderPlugin(ctx, moduleUrl, dependencies = {}) {
|
|
|
67
67
|
input.language = createMuseLanguageModel({
|
|
68
68
|
selectionIndex: catalog.selectionIndex,
|
|
69
69
|
resolveSession: (context) => sessions.resolveSession(context),
|
|
70
|
+
settleApprovalResponses: (sessionID, responses) => sessions.settleApprovalResponses(sessionID, responses),
|
|
71
|
+
hasParkedApprovals: (sessionID) => sessions.hasParkedApprovals(sessionID),
|
|
72
|
+
disarmParkedJoin: (sessionID) => sessions.disarmParkedJoin(sessionID),
|
|
70
73
|
}, input.model.modelID);
|
|
71
74
|
}, { providerID: MUSE_PROVIDER_ID });
|
|
72
75
|
localMetaRegistrations.push(await ctx.session.hook("title", async (input) => {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { MspHandshake } from "@muse-code/sdk";
|
|
2
2
|
import type { RequestContext } from "../types.js";
|
|
3
3
|
import { MspHost } from "../msp/host.js";
|
|
4
|
+
import type { ApprovalResponse } from "../model/prompt.js";
|
|
4
5
|
import type { SessionRegistry } from "./registry.js";
|
|
5
6
|
import type { WorkspaceLock } from "./workspace-lock.js";
|
|
6
7
|
import type { MuseTurnSession } from "../model/language-model.js";
|
|
7
8
|
import type { AgentContextResult } from "../model/agent-context.js";
|
|
8
|
-
export interface MuseSessionHost extends Pick<MspHost, "command" | "request" | "subscribe" | "mintCommandId" | "close"> {
|
|
9
|
+
export interface MuseSessionHost extends Pick<MspHost, "command" | "request" | "subscribe" | "mintCommandId" | "close" | "approvals"> {
|
|
9
10
|
initializeResult: Record<string, unknown>;
|
|
10
11
|
}
|
|
11
12
|
export interface MuseSessionManagerOptions {
|
|
@@ -31,6 +32,18 @@ export declare class MuseSessionManager {
|
|
|
31
32
|
constructor(options: MuseSessionManagerOptions);
|
|
32
33
|
setContextForSession(openCodeSessionId: string, context: AgentContextResult): void;
|
|
33
34
|
resolveSession(context: RequestContext): Promise<MuseTurnSession>;
|
|
35
|
+
/**
|
|
36
|
+
* Route OpenCode's answers to previously emitted `tool-approval-request`
|
|
37
|
+
* chunks into the parked MSP approval round trips. Returns the approval
|
|
38
|
+
* ids that matched a still-parked approval. When a matched approval
|
|
39
|
+
* belongs to the binding's active turn, the next `resolveSession` call
|
|
40
|
+
* attaches to that turn instead of starting a new one.
|
|
41
|
+
*/
|
|
42
|
+
settleApprovalResponses(openCodeSessionId: string, responses: ApprovalResponse[]): Promise<string[]>;
|
|
43
|
+
/** True while the binding's active turn is parked awaiting a verdict. */
|
|
44
|
+
hasParkedApprovals(openCodeSessionId: string): boolean;
|
|
45
|
+
/** Drop a join that can no longer attach (diverged prompt, failed start). */
|
|
46
|
+
disarmParkedJoin(openCodeSessionId: string): void;
|
|
34
47
|
close(): Promise<void>;
|
|
35
48
|
recordCompactionCandidate(openCodeSessionId: string, fingerprint: string): Promise<void>;
|
|
36
49
|
private applySelectionIfChanged;
|
package/dist/session/manager.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { MspHost } from "../msp/host.js";
|
|
5
|
-
import { MspCommandRejectedError, MuseRecoveryChoiceRequiredError, MuseUnsupportedRequestError, RegistryWorkspaceMismatchError, } from "../errors.js";
|
|
5
|
+
import { MspCommandRejectedError, MuseApprovalPendingError, MuseRecoveryChoiceRequiredError, MuseUnsupportedRequestError, RegistryWorkspaceMismatchError, } from "../errors.js";
|
|
6
6
|
function canonicalWorkspacePath(directory) {
|
|
7
7
|
const absolute = resolve(directory);
|
|
8
8
|
return existsSync(absolute) ? realpathSync(absolute) : absolute;
|
|
@@ -70,6 +70,15 @@ export class MuseSessionManager {
|
|
|
70
70
|
if (existingBinding && existingBinding.workspaceFingerprint !== fingerprint) {
|
|
71
71
|
throw new RegistryWorkspaceMismatchError();
|
|
72
72
|
}
|
|
73
|
+
if (existingBinding?.joinParkedTurnId !== undefined) {
|
|
74
|
+
// The settled verdicts matched a parked turn: attach to it instead of
|
|
75
|
+
// starting a new one. The parked call still holds the lease; the
|
|
76
|
+
// handoff flag tells it to detach without releasing.
|
|
77
|
+
return existingBinding;
|
|
78
|
+
}
|
|
79
|
+
if (existingBinding && existingBinding.host.approvals.pendingApprovalIds().length > 0) {
|
|
80
|
+
throw new MuseApprovalPendingError();
|
|
81
|
+
}
|
|
73
82
|
const lease = await this.options.workspaceLock.acquire(fingerprint, context.sessionID);
|
|
74
83
|
let host;
|
|
75
84
|
try {
|
|
@@ -123,6 +132,10 @@ export class MuseSessionManager {
|
|
|
123
132
|
workspaceRoot: canonicalDirectory,
|
|
124
133
|
providerId: selected.providerId,
|
|
125
134
|
modelId: selected.modelId,
|
|
135
|
+
// #22: Muse must ask through approval/request so OpenCode stays in
|
|
136
|
+
// the loop; the host default would let side-effectful actions
|
|
137
|
+
// through silently.
|
|
138
|
+
approvalMode: "onRequest",
|
|
126
139
|
});
|
|
127
140
|
const startedSessionId = sessionIdFrom(start);
|
|
128
141
|
if (!startedSessionId)
|
|
@@ -142,6 +155,8 @@ export class MuseSessionManager {
|
|
|
142
155
|
workspaceFingerprint: fingerprint,
|
|
143
156
|
viewCursor,
|
|
144
157
|
replayAfterCursor,
|
|
158
|
+
emittedApprovalPrompts: new Set(),
|
|
159
|
+
streamEpoch: 0,
|
|
145
160
|
currentSelection: initialSelection,
|
|
146
161
|
previousTranscriptFingerprints: stored?.transcriptFingerprints ?? [],
|
|
147
162
|
transcriptFingerprints: stored?.transcriptFingerprints ?? [],
|
|
@@ -188,6 +203,9 @@ export class MuseSessionManager {
|
|
|
188
203
|
binding.activeTurnId = undefined;
|
|
189
204
|
binding.replayAfterCursor = undefined;
|
|
190
205
|
binding.pendingCommands = {};
|
|
206
|
+
binding.joinParkedTurnId = undefined;
|
|
207
|
+
binding.handoffLeaseToJoiner = false;
|
|
208
|
+
binding.emittedApprovalPrompts = new Set();
|
|
191
209
|
try {
|
|
192
210
|
await persist();
|
|
193
211
|
}
|
|
@@ -197,6 +215,11 @@ export class MuseSessionManager {
|
|
|
197
215
|
throw error;
|
|
198
216
|
}
|
|
199
217
|
},
|
|
218
|
+
async cancelParkedApprovals() {
|
|
219
|
+
for (const approvalId of binding.host.approvals.pendingApprovalIds()) {
|
|
220
|
+
binding.host.approvals.settle(approvalId, false);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
200
223
|
async release() {
|
|
201
224
|
const currentLease = binding.lease;
|
|
202
225
|
binding.lease = undefined;
|
|
@@ -238,6 +261,54 @@ export class MuseSessionManager {
|
|
|
238
261
|
throw error;
|
|
239
262
|
}
|
|
240
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Route OpenCode's answers to previously emitted `tool-approval-request`
|
|
266
|
+
* chunks into the parked MSP approval round trips. Returns the approval
|
|
267
|
+
* ids that matched a still-parked approval. When a matched approval
|
|
268
|
+
* belongs to the binding's active turn, the next `resolveSession` call
|
|
269
|
+
* attaches to that turn instead of starting a new one.
|
|
270
|
+
*/
|
|
271
|
+
async settleApprovalResponses(openCodeSessionId, responses) {
|
|
272
|
+
const binding = this.bindings.get(openCodeSessionId);
|
|
273
|
+
if (!binding)
|
|
274
|
+
return [];
|
|
275
|
+
const matched = [];
|
|
276
|
+
let joinTurnId;
|
|
277
|
+
for (const response of responses) {
|
|
278
|
+
// Capture the parked turn before settle clears the pending entry.
|
|
279
|
+
const parkedTurnId = binding.host.approvals.pendingApprovalTurnId(response.approvalId);
|
|
280
|
+
if (parkedTurnId === undefined)
|
|
281
|
+
continue;
|
|
282
|
+
if (!binding.host.approvals.settle(response.approvalId, response.approved))
|
|
283
|
+
continue;
|
|
284
|
+
matched.push(response.approvalId);
|
|
285
|
+
// Arm the join only for verdicts that belong to the still-active turn;
|
|
286
|
+
// a verdict for a dead turn is delivered but never joined.
|
|
287
|
+
if (joinTurnId === undefined && parkedTurnId === binding.activeTurnId)
|
|
288
|
+
joinTurnId = parkedTurnId;
|
|
289
|
+
}
|
|
290
|
+
if (joinTurnId !== undefined) {
|
|
291
|
+
binding.joinParkedTurnId = joinTurnId;
|
|
292
|
+
binding.streamEpoch = (binding.streamEpoch ?? 0) + 1;
|
|
293
|
+
binding.handoffLeaseToJoiner = true;
|
|
294
|
+
}
|
|
295
|
+
return matched;
|
|
296
|
+
}
|
|
297
|
+
/** True while the binding's active turn is parked awaiting a verdict. */
|
|
298
|
+
hasParkedApprovals(openCodeSessionId) {
|
|
299
|
+
const binding = this.bindings.get(openCodeSessionId);
|
|
300
|
+
return binding !== undefined
|
|
301
|
+
&& binding.activeTurnId !== undefined
|
|
302
|
+
&& binding.host.approvals.pendingApprovalIds().length > 0;
|
|
303
|
+
}
|
|
304
|
+
/** Drop a join that can no longer attach (diverged prompt, failed start). */
|
|
305
|
+
disarmParkedJoin(openCodeSessionId) {
|
|
306
|
+
const binding = this.bindings.get(openCodeSessionId);
|
|
307
|
+
if (!binding)
|
|
308
|
+
return;
|
|
309
|
+
binding.joinParkedTurnId = undefined;
|
|
310
|
+
binding.handoffLeaseToJoiner = false;
|
|
311
|
+
}
|
|
241
312
|
async close() {
|
|
242
313
|
if (this.closed)
|
|
243
314
|
return;
|