opencode-collaboration 0.5.2 → 0.6.1

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/dist/delivery.js CHANGED
@@ -22,7 +22,9 @@ const REPLY_DIRECTIVE = "This turn was triggered by a plain-text message from an
22
22
  "- If the message is only an acknowledgement, a thank-you, an agreement, a status update that " +
23
23
  "needs no action, or a confirmation that the task is done, do NOT reply — briefly tell your user and stop.\n" +
24
24
  "Never send acknowledgement-only, thanks, or agreement messages: they cause an endless " +
25
- "back-and-forth between agents. Once you and the peer have reached a conclusion, stop.";
25
+ "back-and-forth between agents. Once you and the peer have reached a conclusion, stop.\n" +
26
+ "When you call send_message (or any tool), always include a brief text message in the same " +
27
+ "assistant turn; never emit a bare tool call with no text.";
26
28
  const NOTICE_FOOTER = "---\n" +
27
29
  "This is an automated notification from the opencode-collaboration plugin. " +
28
30
  "Show it to the user verbatim, then stop. Do not take further action.";
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { Outbox } from "./outbox.js";
31
31
  import { PeerPermissions } from "./permissions.js";
32
32
  import { buildPeerTools } from "./tools/peers-tools.js";
33
33
  import { handlePeersCommand } from "./commands.js";
34
+ import { sanitizeMessages } from "./sanitize.js";
34
35
  import { consumeCommand, createLogger, errorMessage } from "./feedback.js";
35
36
  // Read the real package version at runtime so registry entries never report a
36
37
  // stale hardcoded number. Falls back to "0.0.0" if package.json is unavailable.
@@ -280,6 +281,15 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
280
281
  if (disposing)
281
282
  return;
282
283
  const e = event;
284
+ if (e.type && e.type.includes("agent.switched")) {
285
+ const probe = e.properties ?? {};
286
+ await logger("debug", "agent switch event observed", {
287
+ type: e.type,
288
+ sessionID: typeof probe.sessionID === "string" ? probe.sessionID : undefined,
289
+ agent: typeof probe.agent === "string" ? probe.agent : undefined,
290
+ messageID: typeof probe.messageID === "string" ? probe.messageID : undefined,
291
+ });
292
+ }
283
293
  const changed = await runtime.handleEvent(e);
284
294
  if (changed && !disposing)
285
295
  await registry.heartbeat();
@@ -296,6 +306,22 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
296
306
  if (!disposing)
297
307
  await registry.heartbeat();
298
308
  },
309
+ "experimental.chat.messages.transform": async (_input, output) => {
310
+ if (disposing)
311
+ return;
312
+ // Clean the model-bound history before any provider sees it. opencode can
313
+ // leave empty text parts on tool-call turns and fully-empty assistant
314
+ // messages on failed turns; strict providers reject both, poisoning every
315
+ // later request in the session. Stripping them here is non-destructive to
316
+ // the stored history (parentID/tool pairing is untouched).
317
+ const messages = output.messages;
318
+ if (!Array.isArray(messages) || messages.length === 0)
319
+ return;
320
+ const dropped = sanitizeMessages(messages);
321
+ if (dropped > 0) {
322
+ await logger("warn", "stripped empty assistant messages from the outgoing history", { dropped });
323
+ }
324
+ },
299
325
  "command.execute.before": async (input, output) => {
300
326
  if (disposing)
301
327
  return;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Sanitization for the model-bound conversation history.
3
+ *
4
+ * opencode can record tool-call turns with an empty text part (text="") and,
5
+ * when a turn fails to stream, a fully empty assistant message (0 parts).
6
+ * Strict model providers reject either ("missing input.content.text",
7
+ * "assistant must not be empty"), poisoning every later request in the
8
+ * session. We clean the outgoing history in the
9
+ * experimental.chat.messages.transform hook instead of touching storage, so
10
+ * provider-side parentID/tool pairing is never disturbed.
11
+ */
12
+ export interface SanitizeMessage {
13
+ info?: {
14
+ role?: string;
15
+ summary?: boolean;
16
+ };
17
+ parts?: Array<{
18
+ type?: string;
19
+ text?: string;
20
+ }>;
21
+ }
22
+ /**
23
+ * Mutates `messages` in place:
24
+ * - removes empty/whitespace-only text parts from every message, and
25
+ * - drops assistant messages that end up with no parts at all.
26
+ * Returns how many assistant messages were dropped.
27
+ */
28
+ export declare function sanitizeMessages<T extends SanitizeMessage>(messages: T[]): number;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Sanitization for the model-bound conversation history.
3
+ *
4
+ * opencode can record tool-call turns with an empty text part (text="") and,
5
+ * when a turn fails to stream, a fully empty assistant message (0 parts).
6
+ * Strict model providers reject either ("missing input.content.text",
7
+ * "assistant must not be empty"), poisoning every later request in the
8
+ * session. We clean the outgoing history in the
9
+ * experimental.chat.messages.transform hook instead of touching storage, so
10
+ * provider-side parentID/tool pairing is never disturbed.
11
+ */
12
+ /**
13
+ * Mutates `messages` in place:
14
+ * - removes empty/whitespace-only text parts from every message, and
15
+ * - drops assistant messages that end up with no parts at all.
16
+ * Returns how many assistant messages were dropped.
17
+ */
18
+ export function sanitizeMessages(messages) {
19
+ for (const message of messages) {
20
+ if (Array.isArray(message.parts)) {
21
+ message.parts = message.parts.filter((part) => part?.type !== "text" || (part.text ?? "").trim().length > 0);
22
+ }
23
+ }
24
+ let dropped = 0;
25
+ for (let index = messages.length - 1; index >= 0; index--) {
26
+ const message = messages[index];
27
+ const role = message.info?.role;
28
+ // Never drop compaction summary messages (info.summary === true): the model
29
+ // may depend on them for context that predates the compaction.
30
+ if (role === "assistant" &&
31
+ message.info?.summary !== true &&
32
+ (!message.parts || message.parts.length === 0)) {
33
+ messages.splice(index, 1);
34
+ dropped++;
35
+ }
36
+ }
37
+ return dropped;
38
+ }
@@ -387,6 +387,22 @@ export function SessionRuntime(opts) {
387
387
  setStatus(endpoint, event.type === "session.idle" ? "idle" : normalizeStatus(properties.status));
388
388
  return true;
389
389
  }
390
+ // The user toggled the session's agent/mode (e.g. build<->plan). Peer
391
+ // injections use the last-known agent, so refreshing here keeps the
392
+ // next injected turn under the mode the user actually selected — even
393
+ // when the toggle happens without a follow-up user message.
394
+ if (event.type === "session.next.agent.switched" ||
395
+ event.type === "session.next.agent.switched.1") {
396
+ const sessionId = properties.sessionID;
397
+ const agent = properties.agent;
398
+ if (!sessionId || !agent)
399
+ return false;
400
+ const endpoint = await findSession(sessionId);
401
+ if (!endpoint)
402
+ return false;
403
+ endpoint.agent = agent;
404
+ return true;
405
+ }
390
406
  return false;
391
407
  });
392
408
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-collaboration",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Cross-session messaging for opencode — let independent sessions discover and text each other, modeled after Claude Code's cross-session messaging",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",