opencode-muse 0.2.0 → 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 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.2.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. The verified Muse CLI and SDK version is 1.4.12.
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 behavior has not yet been
105
- tested. The plugin also allows only one active writer per workspace, so a
106
- new turn may be reported as busy while another is still finishing.
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 Promise.resolve();
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
- if (!sameSelection(session.currentSelection, context.museSelection)) {
170
- const model = {
171
- providerId: context.museSelection.providerId,
172
- modelId: context.museSelection.modelId,
173
- };
174
- if (context.museSelection.profileId !== null)
175
- model.profileId = context.museSelection.profileId;
176
- await submitCommand(session, "session/setModel", session.host.mintCommandId(), {
177
- sessionId: session.sessionId,
178
- model,
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
- const inputParts = await mapVideoParts(session, input, options.abortSignal, runtime.videoChunkBytes);
182
- if (options.abortSignal?.aborted)
183
- throw new DOMException("Muse request aborted before turn start", "AbortError");
184
- subscriptionController = new AbortController();
185
- subscription = session.host.subscribe(session.sessionId, session.replayAfterCursor, subscriptionController.signal)[Symbol.asyncIterator]();
186
- firstNotification = subscription.next();
187
- const commandId = session.host.mintCommandId();
188
- const params = {
189
- sessionId: session.sessionId,
190
- input: inputParts,
191
- };
192
- if (context.effort)
193
- params.reasoningEffort = context.effort;
194
- const ack = await submitCommand(session, "turn/start", commandId, params);
195
- if (typeof ack.turnId !== "string" || typeof ack.disposition !== "string") {
196
- throw new MspProtocolError("turn/start");
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
- await session.release?.();
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
- await session.release?.().catch(() => { });
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
- if (!ambiguousTurnStart)
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
- await session.release?.();
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
- await session.release?.();
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
- const execution = await runMuseTurn(context, extracted.parts, options, {
429
- ...runtime,
430
- resolveSession: async () => session,
431
- }, extracted.nextTranscriptFingerprints);
432
- return { context, execution, mapper: new MuseStreamMapper(execution.turnId), warnings: ignoredOptionWarnings(supportedOptions.options.ignoredOptions) };
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;
@@ -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;
@@ -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" }) };
@@ -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;
@@ -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
+ }
@@ -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. */
@@ -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;
@@ -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;
@@ -7,7 +7,11 @@ export type MuseProviderContext = Pick<Plugin.Context, "provider" | "session" |
7
7
  export interface ProviderPluginDependencies {
8
8
  fetchCatalog?: (directory: string) => Promise<MuseCatalogSnapshot>;
9
9
  createSessionManager?: () => MuseSessionManager;
10
+ /** Bounds how long setup waits for the live catalog before falling back. Defaults to 15s. */
11
+ setupRefreshTimeoutMs?: number;
10
12
  }
13
+ /** Upper bound on delaying provider readiness for live catalog discovery. */
14
+ export declare const SETUP_REFRESH_TIMEOUT_MS = 15000;
11
15
  export interface MuseProviderRuntime {
12
16
  catalog: CatalogCache;
13
17
  refreshTask: Promise<boolean>;
package/dist/provider.js CHANGED
@@ -9,6 +9,8 @@ import { MuseBridgeError } from "./errors.js";
9
9
  import { makeCompactionDigest, makeLocalTitle } from "./model/meta.js";
10
10
  import { fingerprintCompactionOutput } from "./session/fingerprint.js";
11
11
  import { extractAllowedAgentContext } from "./model/agent-context.js";
12
+ /** Upper bound on delaying provider readiness for live catalog discovery. */
13
+ export const SETUP_REFRESH_TIMEOUT_MS = 15_000;
12
14
  function providerInfo(moduleUrl) {
13
15
  return {
14
16
  id: MUSE_PROVIDER_ID,
@@ -65,6 +67,9 @@ export async function createProviderPlugin(ctx, moduleUrl, dependencies = {}) {
65
67
  input.language = createMuseLanguageModel({
66
68
  selectionIndex: catalog.selectionIndex,
67
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),
68
73
  }, input.model.modelID);
69
74
  }, { providerID: MUSE_PROVIDER_ID });
70
75
  localMetaRegistrations.push(await ctx.session.hook("title", async (input) => {
@@ -93,6 +98,23 @@ export async function createProviderPlugin(ctx, moduleUrl, dependencies = {}) {
93
98
  await ctx.provider.reload();
94
99
  });
95
100
  });
101
+ // Wait for live discovery (bounded) so consumers never see the pre-refresh
102
+ // fallback. On timeout or failure the disabled placeholder stays in place
103
+ // and a late refresh still replaces it via provider.reload().
104
+ const refreshTimeoutMs = dependencies.setupRefreshTimeoutMs ?? SETUP_REFRESH_TIMEOUT_MS;
105
+ let refreshTimer;
106
+ try {
107
+ await Promise.race([
108
+ refreshTask.then(() => true, () => false),
109
+ new Promise((resolve) => {
110
+ refreshTimer = setTimeout(() => resolve(false), refreshTimeoutMs);
111
+ }),
112
+ ]);
113
+ }
114
+ finally {
115
+ if (refreshTimer !== undefined)
116
+ clearTimeout(refreshTimer);
117
+ }
96
118
  return {
97
119
  catalog,
98
120
  refreshTask,
@@ -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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-muse",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin backed by the official Muse Code CLI and MSP",
5
5
  "license": "MIT",
6
6
  "type": "module",