opencode-collaboration 0.2.4 → 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
@@ -26,6 +26,7 @@ Run several opencode terminals in parallel (different repos, worktrees, or tasks
26
26
  - Command results and notifications are shown **inline in the session** — no toast popups
27
27
  - **Explicit TUI controls**: palette actions use host dialogs for selection and confirmation; slash wrappers remain available for automation and compatibility
28
28
  - Local only: everything stays on your machine (Unix-domain sockets on macOS/Linux, loopback TCP on Windows, plus a compatibility loopback listener for v1 peers)
29
+ - The peer name is appended to this process's root session titles, so every opencode window shows who it is at a glance
29
30
 
30
31
  ## Install
31
32
 
@@ -122,6 +123,7 @@ Options can be passed via the tuple form in `opencode.json`:
122
123
  | `inboundPolicy` | `"accept"` | `accept` delivers immediately; `auto` accepts only when sender and receiver directories match and otherwise holds; `hold` parks messages for review; `refuse` rejects them |
123
124
  | `peerPermissions` | `"allow"` | Peer-origin permission requests: `allow` auto-approves ordinary requests, `ask` leaves native prompts untouched, `deny` rejects. Even in `allow`, OpenCode/plugin permission configuration, `AGENTS.md`, credentials/secrets and permission escalation are never auto-approved; existing OpenCode deny rules always win |
124
125
  | `name` | `<dir>-<hex4>` | display name other peers use to address you; the default appends a short hex suffix (from the instance ID) to the directory basename so same-directory instances are distinguishable, matching Claude Code's `my-app-3f` pattern |
126
+ | `showNameInTitle` | `true` | Append this process's peer name to its root session titles (e.g. `Fix login bug(张三)`); the suffix is removed on graceful exit |
125
127
  | `storageDir` | `$XDG_DATA_HOME/opencode-collaboration` | where the registry and held inbox live |
126
128
  | `heartbeatMs` | `10000` | registry heartbeat interval |
127
129
  | `staleMs` | `30000` | peer is offline if its heartbeat is older than this |
package/README.zh-CN.md CHANGED
@@ -26,6 +26,7 @@
26
26
  - 命令结果和通知**内联显示在会话中** —— 没有 toast 弹窗
27
27
  - **明确的 TUI 控制**:面板操作使用宿主对话框进行选择确认;斜杠命令封装仍可用于自动化和兼容性场景
28
28
  - 纯本地运行:一切都在你的机器上(macOS/Linux 使用 Unix 域套接字,Windows 使用回环 TCP,另有一个兼容 v1 对端的回环监听器)
29
+ - 本进程的 peer 名字会追加到根会话标题后面,一眼就能看出每个 opencode 窗口是谁
29
30
 
30
31
  ## 安装
31
32
 
@@ -122,6 +123,7 @@ Use send_message to tell "backend" that the login form now posts to /v2/login.
122
123
  | `inboundPolicy` | `"accept"` | `accept` 立即投递;`auto` 仅当发送方与接收方目录相同时接受,否则进入待审;`hold` 暂存消息供人工审阅;`refuse` 直接拒绝 |
123
124
  | `peerPermissions` | `"allow"` | 对端来源的权限请求:`allow` 自动批准普通请求,`ask` 保持原生提示不变,`deny` 拒绝。即使在 `allow` 模式下,OpenCode/插件权限配置、`AGENTS.md`、凭据/密钥以及权限升级也永远不会被自动批准;已存在的 OpenCode 拒绝规则始终优先 |
124
125
  | `name` | `<dir>-<hex4>` | 其他对端用来寻址你的显示名;默认值在目录名后追加一个短十六进制后缀(取自实例 ID),使同目录的多个实例可以区分,与 Claude Code 的 `my-app-3f` 命名方式一致 |
126
+ | `showNameInTitle` | `true` | 把本进程的 peer 名字追加到根会话标题后面(如 `Fix login bug(张三)`);优雅退出时会自动移除该后缀 |
125
127
  | `storageDir` | `$XDG_DATA_HOME/opencode-collaboration` | 注册表与待审收件箱的存储目录 |
126
128
  | `heartbeatMs` | `10000` | 注册表心跳间隔 |
127
129
  | `staleMs` | `30000` | 心跳早于该时长则视为对端离线 |
package/dist/commands.js CHANGED
@@ -17,7 +17,8 @@ export async function handlePeersCommand(ctx, command, args) {
17
17
  const suffix = held.length > 0 || pending > 0
18
18
  ? `\n(${pending} queued, ${held.length} held — /peers-inbox to review)`
19
19
  : "";
20
- return { handled: true, message: `📋 ${listing}${suffix}` };
20
+ const selfLine = `You are "${ctx.getName()}" — /peers-name to change.`;
21
+ return { handled: true, message: `📋 ${selfLine}\n${listing}${suffix}` };
21
22
  }
22
23
  if (command === "peers-name") {
23
24
  const desired = args.trim();
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ResolvedConfig {
5
5
  inboxFile: string;
6
6
  spoolDir: string;
7
7
  name: string | undefined;
8
+ showNameInTitle: boolean;
8
9
  inboundPolicy: "accept" | "auto" | "hold" | "refuse";
9
10
  peerPermissions: PeerPermissionMode;
10
11
  heartbeatMs: number;
package/dist/config.js CHANGED
@@ -14,6 +14,7 @@ export function resolveConfig(opts, env = process.env) {
14
14
  inboxFile: join(storageDir, "inbox.json"),
15
15
  spoolDir: join(storageDir, "spool"),
16
16
  name: opts?.name,
17
+ showNameInTitle: opts?.showNameInTitle ?? true,
17
18
  inboundPolicy: opts?.inboundPolicy ?? "accept",
18
19
  peerPermissions: opts?.peerPermissions ?? "allow",
19
20
  heartbeatMs: opts?.heartbeatMs ?? 10_000,
package/dist/index.js CHANGED
@@ -302,6 +302,7 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
302
302
  setName: async (name) => {
303
303
  if (name === currentName) {
304
304
  await registry.heartbeat();
305
+ await runtime.retitleRoots(currentName);
305
306
  return { name, taken: false };
306
307
  }
307
308
  const peers = await registry.list();
@@ -311,6 +312,7 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
311
312
  }
312
313
  currentName = name;
313
314
  await registry.heartbeat();
315
+ await runtime.retitleRoots(currentName);
314
316
  return { name, taken: false };
315
317
  },
316
318
  selfInstanceId: instanceId,
@@ -353,14 +355,17 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
353
355
  clearTimeout(discoveryTimer);
354
356
  discoveryTimer = null;
355
357
  clearInterval(sweeper);
356
- const registryStopping = registry.stop();
357
- const runtimeStopping = runtime.stop();
358
- disposePromise = Promise.all([
359
- registryStopping,
360
- runtimeStopping,
361
- listener.stop(),
362
- acknowledgementTransport.close(),
363
- ]).then(() => undefined);
358
+ const cleanup = (async () => {
359
+ // Titles must be stripped while the runtime is still live.
360
+ await runtime.clearSuffixes();
361
+ await Promise.all([
362
+ registry.stop(),
363
+ runtime.stop(),
364
+ listener.stop(),
365
+ acknowledgementTransport.close(),
366
+ ]);
367
+ })().catch(() => undefined);
368
+ disposePromise = cleanup.then(() => undefined);
364
369
  return disposePromise;
365
370
  };
366
371
  await logger("info", "opencode-collaboration started", {
@@ -379,6 +384,8 @@ export const PeersPlugin = async (ctx, pluginOptions) => {
379
384
  return;
380
385
  void runtime.initialize()
381
386
  .then(async () => {
387
+ if (!disposing)
388
+ await runtime.retitleRoots(currentName);
382
389
  if (!disposing)
383
390
  await registry.heartbeat();
384
391
  })
@@ -31,6 +31,8 @@ export interface SessionRuntimeInstance {
31
31
  queueForSession: (sessionId: string) => QueueInstance | null;
32
32
  deliveryForSession: (sessionId: string) => DeliveryInstance | null;
33
33
  sweep: () => Promise<void>;
34
+ retitleRoots: (name: string) => Promise<void>;
35
+ clearSuffixes: () => Promise<void>;
34
36
  pendingAcknowledgements: () => Array<{
35
37
  queue: QueueInstance;
36
38
  acknowledgement: PeerAcknowledgementV2;
@@ -2,6 +2,7 @@ import { Delivery } from "./delivery.js";
2
2
  import { gateMessage } from "./gating.js";
3
3
  import { createSessionMessageQueue, hasSpoolRecords, migrateWorkspaceSpool, stableSessionEndpointId, } from "./queue.js";
4
4
  import { SessionTracker } from "./session-tracker.js";
5
+ import { stripNameSuffix, withNameSuffix } from "./title-suffix.js";
5
6
  function responseData(response) {
6
7
  return response?.data;
7
8
  }
@@ -118,6 +119,57 @@ export function SessionRuntime(opts) {
118
119
  return null;
119
120
  }
120
121
  }
122
+ function rootEndpoints() {
123
+ return [...endpoints.values()].filter((endpoint) => !endpoint.session.parentID);
124
+ }
125
+ async function updateTitle(endpoint, title) {
126
+ const api = opts.client.session;
127
+ if (typeof api.update !== "function")
128
+ return;
129
+ await api.update({
130
+ path: { id: endpoint.session.id },
131
+ query: { directory: endpoint.session.directory || opts.directory },
132
+ body: { title },
133
+ });
134
+ endpoint.session = { ...endpoint.session, title };
135
+ }
136
+ async function applyNameToTitle(endpoint, name) {
137
+ const current = endpoint.session.title ?? "";
138
+ const desired = withNameSuffix(current, name);
139
+ if (desired === current)
140
+ return;
141
+ try {
142
+ await updateTitle(endpoint, desired);
143
+ }
144
+ catch (err) {
145
+ await opts.logger("warn", "failed to update session title", {
146
+ error: String(err),
147
+ sessionId: endpoint.session.id,
148
+ });
149
+ }
150
+ }
151
+ async function retitleRootsImpl(name) {
152
+ if (!opts.config.showNameInTitle)
153
+ return;
154
+ await Promise.all(rootEndpoints().map((endpoint) => applyNameToTitle(endpoint, name)));
155
+ }
156
+ async function clearSuffixesImpl() {
157
+ await Promise.all(rootEndpoints().map(async (endpoint) => {
158
+ const current = endpoint.session.title ?? "";
159
+ const stripped = stripNameSuffix(current);
160
+ if (stripped === current)
161
+ return;
162
+ try {
163
+ await updateTitle(endpoint, stripped);
164
+ }
165
+ catch (err) {
166
+ await opts.logger("warn", "failed to clear session title suffix", {
167
+ error: String(err),
168
+ sessionId: endpoint.session.id,
169
+ });
170
+ }
171
+ }));
172
+ }
121
173
  return {
122
174
  initialize() {
123
175
  if (!readyPromise) {
@@ -293,6 +345,11 @@ export function SessionRuntime(opts) {
293
345
  await upsert(info);
294
346
  if (event.type === "session.created")
295
347
  await loadChildren(info);
348
+ if (opts.config.showNameInTitle && !info.parentID) {
349
+ const endpoint = endpoints.get(info.id);
350
+ if (endpoint)
351
+ await applyNameToTitle(endpoint, opts.name());
352
+ }
296
353
  return true;
297
354
  }
298
355
  if (event.type === "session.deleted") {
@@ -351,5 +408,13 @@ export function SessionRuntime(opts) {
351
408
  return [...endpoints.values()].flatMap((endpoint) => endpoint.queue.pendingAcknowledgements()
352
409
  .map((acknowledgement) => ({ queue: endpoint.queue, acknowledgement })));
353
410
  },
411
+ retitleRoots(name) {
412
+ return whileRunning(undefined, () => retitleRootsImpl(name));
413
+ },
414
+ clearSuffixes() {
415
+ // Runs during dispose, possibly after lifecycle moved to "stopping",
416
+ // so it deliberately bypasses whileRunning().
417
+ return clearSuffixesImpl();
418
+ },
354
419
  };
355
420
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Session-title suffix helpers. The peer name is appended in parentheses,
3
+ * e.g. "Fix login bug(张三)", so a user can always see which opencode process
4
+ * a session belongs to. The suffix is stripped again on graceful exit.
5
+ *
6
+ * A trailing "(...)" is only treated as our suffix when its content is a
7
+ * valid peer name; ordinary parenthetical text is left alone.
8
+ */
9
+ /** Remove a trailing "(name)" suffix whose content is a valid peer name. */
10
+ export declare function stripNameSuffix(title: string): string;
11
+ /** Append the name suffix, replacing any existing name suffix. */
12
+ export declare function withNameSuffix(title: string, name: string): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Session-title suffix helpers. The peer name is appended in parentheses,
3
+ * e.g. "Fix login bug(张三)", so a user can always see which opencode process
4
+ * a session belongs to. The suffix is stripped again on graceful exit.
5
+ *
6
+ * A trailing "(...)" is only treated as our suffix when its content is a
7
+ * valid peer name; ordinary parenthetical text is left alone.
8
+ */
9
+ import { validateName } from "./config.js";
10
+ /** Remove a trailing "(name)" suffix whose content is a valid peer name. */
11
+ export function stripNameSuffix(title) {
12
+ const match = title.match(/^(.*?)\s*\(([^()]*)\)$/);
13
+ if (!match)
14
+ return title;
15
+ if (validateName(match[2].trim()) !== null)
16
+ return title;
17
+ return match[1].trimEnd();
18
+ }
19
+ /** Append the name suffix, replacing any existing name suffix. */
20
+ export function withNameSuffix(title, name) {
21
+ const base = stripNameSuffix(title).trim();
22
+ return base ? `${base}(${name})` : `(${name})`;
23
+ }
package/dist/types.d.ts CHANGED
@@ -127,6 +127,12 @@ export interface PluginConfig {
127
127
  storageDir?: string;
128
128
  /** Display name for this instance (default: basename of directory). */
129
129
  name?: string;
130
+ /**
131
+ * Append this process's peer name to its root session titles, e.g.
132
+ * "Fix login bug(张三)". The suffix is removed again on graceful exit.
133
+ * Default true.
134
+ */
135
+ showNameInTitle?: boolean;
130
136
  /** What to do with inbound messages. Default "accept". */
131
137
  inboundPolicy?: InboundPolicy;
132
138
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-collaboration",
3
- "version": "0.2.4",
3
+ "version": "0.3.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",