opencode-muse 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1.0"],
19
+ "plugins": ["opencode-muse@0.1.2"],
20
20
  }
21
21
  ```
22
22
 
@@ -104,6 +104,16 @@ setup placeholder rather than advertising a model that will fail on use.
104
104
  tested. The plugin also allows only one active writer per workspace, so a
105
105
  new turn may be reported as busy while another is still finishing.
106
106
 
107
+ ## Troubleshooting
108
+
109
+ - **"Muse Spark 1.3 Contributor" never connects after a model switch:** that
110
+ entry is a retired OpenCode-gateway model (`opencode/muse-spark-1.3-contributor-free`),
111
+ not this plugin's model. It no longer resolves anywhere, so OpenCode keeps
112
+ retrying and the session appears stuck on "connecting". Pick the model under
113
+ provider **Muse Code** — `muse/muse-profile-tbh-model-muse-spark-1.3` —
114
+ instead of any historical "contributor-free" entry in recent-model lists. See
115
+ [#26](https://github.com/pgquiles/opencode-muse/issues/26).
116
+
107
117
  ## Credits and references
108
118
 
109
119
  - [opencode-claude](https://github.com/openchamber/opencode-claude) — a
@@ -117,5 +127,6 @@ setup placeholder rather than advertising a model that will fail on use.
117
127
 
118
128
  ## Related projects
119
129
 
120
- - [opencode-muse-auth](https://github.com/TheStreamCode/opencode-muse-auth) lets OpenCode use a Muse Code subscription by extracting the credentials (API key) embedded in that subscription. Unsure this abides by the Muse Code subscription ToS (issue #12](https://github.com/TheStreamCode/opencode-muse-auth/issues/12)).
121
- - [Official Muse Code documenation](https://dev.meta.ai/docs/quickstart#agent-setup), describing how to use the Muse models in OpenCode via API key (a lot more expensive)
130
+ - [opencode-muse-auth](https://github.com/TheStreamCode/opencode-muse-auth) is another credential-extraction/direct-API approach; see its [issue #12](https://github.com/TheStreamCode/opencode-muse-auth/issues/12) for its discussion of subscription-term concerns.
131
+ - [opencode-muse-code](https://github.com/swalker326/opencode-muse-code) is an unofficial direct-API alternative. It reads the Muse CLI login, requests an API key from Meta, and sends model requests to Meta's API from OpenCode instead of routing inference through `muse serve` and MSP. Its subscription eligibility is separate from this plugin's CLI-mediated approach and has not been established by this project.
132
+ - [Official Muse Code documentation](https://dev.meta.ai/docs/quickstart#agent-setup) describes using Muse models in OpenCode through the Meta API.
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MspCommandRejectedError, MuseBridgeError, MuseTranscriptDivergedError, MuseUnsupportedInputError, MuseUnsupportedRequestError, MspProtocolError, MspTransportError } from "../errors.js";
3
+ import { notificationCursorKey } from "../msp/notification.js";
3
4
  import { MUSE_CONTEXT_HEADERS, readRequestContext } from "./context.js";
4
5
  import { classifyMetaRequest, makeCompactionDigest, makeLocalTitle } from "./meta.js";
5
6
  import { promptContainsCompactionFingerprint } from "../session/fingerprint.js";
@@ -93,7 +94,7 @@ function eventTurnId(notification, itemTurnIds) {
93
94
  async function* pageGapNotifications(host, sessionId, gap) {
94
95
  let cursor = gap.after;
95
96
  const requestedCursors = new Set([gap.after]);
96
- const eventCursors = new Set();
97
+ const eventKeys = new Set();
97
98
  for (let pageNumber = 0; pageNumber < 100; pageNumber += 1) {
98
99
  const requestCursor = cursor;
99
100
  const page = await host.request("view/page", {
@@ -104,6 +105,7 @@ async function* pageGapNotifications(host, sessionId, gap) {
104
105
  });
105
106
  if (!Array.isArray(page.events))
106
107
  throw new MspProtocolError("view/page");
108
+ let boundaryReached = false;
107
109
  for (const event of page.events) {
108
110
  if (typeof event !== "object" || event === null)
109
111
  throw new MspProtocolError("view/page");
@@ -112,14 +114,20 @@ async function* pageGapNotifications(host, sessionId, gap) {
112
114
  if (typeof record.method !== "string" || !params || params.sessionId !== sessionId || typeof params.viewCursor !== "string") {
113
115
  throw new MspProtocolError("view/page");
114
116
  }
115
- if (params.viewCursor === gap.next)
116
- return;
117
- if (eventCursors.has(params.viewCursor))
118
- throw new MspProtocolError("view/page");
119
- eventCursors.add(params.viewCursor);
117
+ const atBoundary = params.viewCursor === gap.next;
118
+ if (atBoundary)
119
+ boundaryReached = true;
120
120
  cursor = params.viewCursor;
121
- yield { jsonrpc: "2.0", method: record.method, params };
121
+ const notification = { jsonrpc: "2.0", method: record.method, params };
122
+ const key = notificationCursorKey(notification);
123
+ if (key && eventKeys.has(key))
124
+ throw new MspProtocolError("view/page");
125
+ if (key)
126
+ eventKeys.add(key);
127
+ yield notification;
122
128
  }
129
+ if (boundaryReached)
130
+ return;
123
131
  if (page.nextCursor === null || typeof page.nextCursor !== "string"
124
132
  || page.nextCursor === requestCursor || requestedCursors.has(page.nextCursor)) {
125
133
  throw new MspProtocolError("view/page");
@@ -196,7 +204,7 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
196
204
  const events = {
197
205
  [Symbol.asyncIterator]: async function* () {
198
206
  const itemTurnIds = new Map();
199
- const seenCursors = new Set();
207
+ const seenNotifications = new Set();
200
208
  const processNotification = async function* (notification) {
201
209
  const params = notification.params ?? {};
202
210
  if (params.sessionId !== session.sessionId)
@@ -227,11 +235,11 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
227
235
  if (typeof params.after !== "string" || typeof params.next !== "string")
228
236
  throw new MspProtocolError("view/gap");
229
237
  for await (const replay of pageGapNotifications(session.host, session.sessionId, { after: params.after, next: params.next })) {
230
- const replayCursor = replay.params?.viewCursor;
231
- if (typeof replayCursor === "string" && seenCursors.has(replayCursor))
238
+ const replayKey = notificationCursorKey(replay);
239
+ if (replayKey && seenNotifications.has(replayKey))
232
240
  continue;
233
- if (typeof replayCursor === "string")
234
- seenCursors.add(replayCursor);
241
+ if (replayKey)
242
+ seenNotifications.add(replayKey);
235
243
  for await (const output of processNotification(replay))
236
244
  yield output;
237
245
  if (terminalSeen)
@@ -239,13 +247,13 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
239
247
  }
240
248
  }
241
249
  else {
242
- const viewCursor = params.viewCursor;
243
- if (typeof viewCursor === "string" && seenCursors.has(viewCursor)) {
250
+ const notificationKey = notificationCursorKey(notification);
251
+ if (notificationKey && seenNotifications.has(notificationKey)) {
244
252
  next = await subscription.next();
245
253
  continue;
246
254
  }
247
- if (typeof viewCursor === "string")
248
- seenCursors.add(viewCursor);
255
+ if (notificationKey)
256
+ seenNotifications.add(notificationKey);
249
257
  for await (const output of processNotification(notification))
250
258
  yield output;
251
259
  if (terminalSeen)
@@ -14,7 +14,7 @@ export declare class MuseStreamMapper {
14
14
  private readonly reasoningStarted;
15
15
  private readonly itemDeltaCounts;
16
16
  private readonly itemText;
17
- private readonly seenUsageCursors;
17
+ private readonly seenUsageNotifications;
18
18
  private readonly textParts;
19
19
  private terminalUsage;
20
20
  private callUsage;
@@ -1,4 +1,5 @@
1
1
  import { MspProtocolError, MuseTurnFailedError, MuseTurnUnqueuedError, } from "../errors.js";
2
+ import { notificationCursorKey } from "../msp/notification.js";
2
3
  function isNumber(value) {
3
4
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
4
5
  }
@@ -17,7 +18,7 @@ export class MuseStreamMapper {
17
18
  reasoningStarted = new Set();
18
19
  itemDeltaCounts = new Map();
19
20
  itemText = new Map();
20
- seenUsageCursors = new Set();
21
+ seenUsageNotifications = new Set();
21
22
  textParts = [];
22
23
  terminalUsage;
23
24
  callUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0 };
@@ -168,9 +169,10 @@ export class MuseStreamMapper {
168
169
  tokenUsageEvent(params) {
169
170
  if (params.turnId !== this.turnId || typeof params.viewCursor !== "string")
170
171
  return;
171
- if (this.seenUsageCursors.has(params.viewCursor))
172
+ const key = notificationCursorKey({ method: "session/tokenUsage", params });
173
+ if (!key || this.seenUsageNotifications.has(key))
172
174
  return;
173
- this.seenUsageCursors.add(params.viewCursor);
175
+ this.seenUsageNotifications.add(key);
174
176
  const raw = params.usage;
175
177
  if (!raw)
176
178
  return;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stable identity for exact notification replay suppression.
3
+ * A view cursor may be shared by multiple distinct notification methods or
4
+ * payloads, so the cursor alone is not an event identity.
5
+ */
6
+ export declare function notificationCursorKey(notification: {
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }): string | undefined;
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ function stableJson(value) {
3
+ if (value === null || typeof value !== "object")
4
+ return JSON.stringify(value) ?? "null";
5
+ if (Array.isArray(value))
6
+ return `[${value.map(stableJson).join(",")}]`;
7
+ const entries = Object.entries(value)
8
+ .filter(([, child]) => child !== undefined)
9
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
10
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
11
+ }
12
+ /**
13
+ * Stable identity for exact notification replay suppression.
14
+ * A view cursor may be shared by multiple distinct notification methods or
15
+ * payloads, so the cursor alone is not an event identity.
16
+ */
17
+ export function notificationCursorKey(notification) {
18
+ const params = notification.params ?? {};
19
+ const cursor = params.viewCursor;
20
+ if (typeof cursor !== "string")
21
+ return undefined;
22
+ const digest = createHash("sha256")
23
+ .update(stableJson({ method: notification.method, params }))
24
+ .digest("hex");
25
+ return `${cursor}:${digest}`;
26
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { closeSync, chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { closeSync, chmodSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readlinkSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
4
+ import { dirname, isAbsolute, join, posix, win32 } from "node:path";
5
5
  import { RegistryCorruptError, RegistryStorageError, RegistryValidationError, RegistryWorkspaceMismatchError, } from "../errors.js";
6
6
  const REGISTRY_VERSION = 1;
7
7
  const MAX_ENTRY_BYTES = 1_048_576;
@@ -9,29 +9,82 @@ const HASH_PATTERN = /^[a-f0-9]{64}$/;
9
9
  const UUID_V7_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
10
10
  const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:@+_-]{1,512}$/;
11
11
  const METHOD_PATTERN = /^[a-z][A-Za-z0-9]*(?:\/[A-Za-z][A-Za-z0-9]*)+$/;
12
+ const MAX_PATH_SYMLINKS = 40;
13
+ /** Resolve existing path components while retaining a normalized not-yet-created suffix. */
14
+ function canonicalPath(path, platform) {
15
+ const pathApi = platform === "win32" ? win32 : posix;
16
+ if (platform !== process.platform)
17
+ return pathApi.resolve(path);
18
+ const absolute = pathApi.resolve(path);
19
+ const root = pathApi.parse(absolute).root;
20
+ let current = root;
21
+ let remaining = absolute.slice(root.length).split(pathApi.sep).filter(Boolean);
22
+ let followedSymlinks = 0;
23
+ while (remaining.length > 0) {
24
+ const [component, ...rest] = remaining;
25
+ const candidate = pathApi.join(current, component);
26
+ let stats;
27
+ try {
28
+ stats = lstatSync(candidate);
29
+ }
30
+ catch (error) {
31
+ // A missing component ends filesystem traversal; normalize the remaining suffix lexically.
32
+ if (isMissingFile(error))
33
+ return pathApi.resolve(current, ...remaining);
34
+ throw new RegistryValidationError();
35
+ }
36
+ if (stats.isSymbolicLink()) {
37
+ followedSymlinks += 1;
38
+ if (followedSymlinks > MAX_PATH_SYMLINKS)
39
+ throw new RegistryValidationError();
40
+ let target;
41
+ try {
42
+ target = readlinkSync(candidate);
43
+ }
44
+ catch {
45
+ throw new RegistryValidationError();
46
+ }
47
+ const absoluteTarget = pathApi.isAbsolute(target)
48
+ ? pathApi.resolve(target)
49
+ : pathApi.resolve(current, target);
50
+ const targetRoot = pathApi.parse(absoluteTarget).root;
51
+ current = targetRoot;
52
+ remaining = [
53
+ ...absoluteTarget.slice(targetRoot.length).split(pathApi.sep).filter(Boolean),
54
+ ...rest,
55
+ ];
56
+ continue;
57
+ }
58
+ try {
59
+ current = realpathSync(candidate);
60
+ }
61
+ catch {
62
+ throw new RegistryValidationError();
63
+ }
64
+ remaining = rest;
65
+ }
66
+ return pathApi.resolve(current);
67
+ }
12
68
  /** Refuse a caller-selected state root inside Muse's CLI-owned config directory. */
13
69
  export function assertSafeStateDirectory(path, options = {}) {
14
70
  const env = options.env ?? process.env;
15
71
  const homeDirectory = options.homeDirectory ?? homedir();
16
72
  const platform = options.platform ?? process.platform;
17
73
  const windows = platform === "win32";
18
- const pathJoin = windows ? win32.join : join;
19
- const pathResolve = windows ? win32.resolve : resolve;
20
- const pathRelative = windows ? win32.relative : relative;
21
- const pathSeparator = windows ? win32.sep : sep;
22
- const pathIsAbsolute = windows ? win32.isAbsolute : isAbsolute;
74
+ const pathApi = windows ? win32 : posix;
23
75
  const configuredHome = env.XDG_CONFIG_HOME;
24
- const configHome = requiredString(configuredHome) && pathIsAbsolute(configuredHome)
76
+ const configHome = requiredString(configuredHome) && pathApi.isAbsolute(configuredHome)
25
77
  ? configuredHome
26
- : pathJoin(homeDirectory, ".config");
27
- const museConfigDirectory = pathResolve(pathJoin(configHome, "muse"));
28
- const relativePath = pathRelative(museConfigDirectory, pathResolve(path));
78
+ : pathApi.join(homeDirectory, ".config");
79
+ const museConfigDirectory = canonicalPath(pathApi.join(configHome, "muse"), platform);
80
+ const canonicalStateDirectory = canonicalPath(path, platform);
81
+ const relativePath = pathApi.relative(museConfigDirectory, canonicalStateDirectory);
29
82
  const outsideMuseConfig = relativePath === ".."
30
- || relativePath.startsWith(`..${pathSeparator}`)
31
- || pathIsAbsolute(relativePath);
83
+ || relativePath.startsWith(`..${pathApi.sep}`)
84
+ || pathApi.isAbsolute(relativePath);
32
85
  if (!outsideMuseConfig)
33
86
  throw new RegistryValidationError();
34
- return path;
87
+ return canonicalStateDirectory;
35
88
  }
36
89
  function isRecord(value) {
37
90
  return typeof value === "object" && value !== null && !Array.isArray(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-muse",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin backed by the official Muse Code CLI and MSP",
5
5
  "license": "MIT",
6
6
  "type": "module",