opencode-collaboration 0.5.1 → 0.6.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
@@ -96,7 +96,7 @@ Other Opencode sessions (2):
96
96
  Use send_message to tell "backend" that the login form now posts to /v2/login.
97
97
  ```
98
98
 
99
- The receiving session gets the text immediately as a synthetic user message, including the sender's exact endpoint ID and how to reply. `send_message` returns a tracking ID; use `peer_message_status` or `/peers-outbox` to distinguish transport receipt from final delivery.
99
+ The receiving session gets the text immediately as a synthetic user message, including the sender's exact endpoint ID and how to reply. The receiver is told to reply via `send_message` only when a response is actually expected, and is explicitly told not to send acknowledgement-only messages — so two agents do not loop politely forever. `send_message` returns a tracking ID; use `peer_message_status` or `/peers-outbox` to distinguish transport receipt from final delivery.
100
100
 
101
101
  **Review held messages** (when `inboundPolicy` is `"hold"`):
102
102
 
package/README.zh-CN.md CHANGED
@@ -96,7 +96,7 @@ Other Opencode sessions (2):
96
96
  Use send_message to tell "backend" that the login form now posts to /v2/login.
97
97
  ```
98
98
 
99
- 接收方会话会立即收到这条文本,以一条合成的用户消息形式出现,其中包含发送方精确的端点 ID 以及回复方式。`send_message` 返回一个追踪 ID;用 `peer_message_status` 或 `/peers-outbox` 来区分"传输层已收到"和"最终已投递"。
99
+ 接收方会话会立即收到这条文本,以一条合成的用户消息形式出现,其中包含发送方精确的端点 ID 以及回复方式。接收方被要求**只在确实需要回应时**才通过 `send_message` 回复,并明确禁止仅确认/致谢类消息——避免两个 agent 无限礼貌往返。`send_message` 返回一个追踪 ID;用 `peer_message_status` 或 `/peers-outbox` 来区分"传输层已收到"和"最终已投递"。
100
100
 
101
101
  **审阅待审消息**(当 `inboundPolicy` 为 `"hold"` 时):
102
102
 
package/dist/delivery.js CHANGED
@@ -15,10 +15,16 @@ const FOOTER = "---\n" +
15
15
  // appends this to the agent's own system prompt (LLMRequestPrep.prepare), so it
16
16
  // never replaces it and does not leak into later turns or other sessions.
17
17
  const REPLY_DIRECTIVE = "This turn was triggered by a plain-text message from another opencode session (a peer), " +
18
- "not by your user. Your user cannot see it, and your normal chat reply is NOT delivered to " +
19
- "the peer. To respond — including to ask a clarifying question or request missing details — " +
20
- "you MUST call the send_message tool with `to` set to the sender's name. Never answer a peer " +
21
- "by typing a normal chat reply.";
18
+ "not by your user. Your user cannot see it, and your normal chat reply is NOT delivered to the peer. " +
19
+ "Reply only when a reply is actually expected:\n" +
20
+ "- If the peer asked a question, requested work, or is waiting for your response, call the " +
21
+ "send_message tool with `to` set to the sender's name.\n" +
22
+ "- If the message is only an acknowledgement, a thank-you, an agreement, a status update that " +
23
+ "needs no action, or a confirmation that the task is done, do NOT reply — briefly tell your user and stop.\n" +
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.\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.";
22
28
  const NOTICE_FOOTER = "---\n" +
23
29
  "This is an automated notification from the opencode-collaboration plugin. " +
24
30
  "Show it to the user verbatim, then stop. Do not take further action.";
@@ -29,8 +35,11 @@ export function formatMessages(messages) {
29
35
  ? `to="${senders[0].name}" (exact endpoint ID "${senders[0].instanceId}")`
30
36
  : `to the sender name shown in each message header (endpoint IDs ${senders.map((sender) => `"${sender.instanceId}"`).join(", ")})`;
31
37
  const header = "[incoming message from another opencode session — this is NOT from your user. " +
32
- "Reply by calling the send_message tool; a normal chat reply is never delivered to the sender.]";
33
- return header + "\n\n" + blocks.join("\n\n") + "\n\n" + FOOTER + ` To reply, call the send_message tool with ${replyTarget}.`;
38
+ "To reply to the sender, call the send_message tool; a normal chat reply is never delivered. " +
39
+ "Reply only if the peer expects a response do not acknowledge just to be polite.]";
40
+ return header + "\n\n" + blocks.join("\n\n") + "\n\n" + FOOTER +
41
+ ` To reply, call the send_message tool with ${replyTarget}. ` +
42
+ "Reply only if a response is expected; do not send acknowledgement-only messages.";
34
43
  }
35
44
  export function deterministicPeerMessageId(sessionId, message) {
36
45
  const digest = createHash("sha256")
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.
@@ -296,6 +297,22 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
296
297
  if (!disposing)
297
298
  await registry.heartbeat();
298
299
  },
300
+ "experimental.chat.messages.transform": async (_input, output) => {
301
+ if (disposing)
302
+ return;
303
+ // Clean the model-bound history before any provider sees it. opencode can
304
+ // leave empty text parts on tool-call turns and fully-empty assistant
305
+ // messages on failed turns; strict providers reject both, poisoning every
306
+ // later request in the session. Stripping them here is non-destructive to
307
+ // the stored history (parentID/tool pairing is untouched).
308
+ const messages = output.messages;
309
+ if (!Array.isArray(messages) || messages.length === 0)
310
+ return;
311
+ const dropped = sanitizeMessages(messages);
312
+ if (dropped > 0) {
313
+ await logger("warn", "stripped empty assistant messages from the outgoing history", { dropped });
314
+ }
315
+ },
299
316
  "command.execute.before": async (input, output) => {
300
317
  if (disposing)
301
318
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-collaboration",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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",