opencode-collaboration 0.2.3 → 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 +8 -6
- package/README.zh-CN.md +8 -6
- package/dist/commands.js +2 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/index.js +15 -8
- package/dist/session-runtime.d.ts +2 -0
- package/dist/session-runtime.js +65 -0
- package/dist/title-suffix.d.ts +12 -0
- package/dist/title-suffix.js +23 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,23 +26,24 @@ 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
|
|
|
32
|
-
|
|
33
|
+
```bash
|
|
34
|
+
opencode plugin -g opencode-collaboration
|
|
35
|
+
```
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
or add to your `opencode.json`:
|
|
35
38
|
|
|
36
39
|
```json
|
|
37
40
|
{
|
|
38
|
-
"plugin": ["opencode-collaboration
|
|
41
|
+
"plugin": ["opencode-collaboration"]
|
|
39
42
|
}
|
|
40
43
|
```
|
|
41
44
|
|
|
42
45
|
Requires opencode >= 1.18.0.
|
|
43
46
|
|
|
44
|
-
> **Updating:** opencode caches git plugins under `~/.cache/opencode/packages/`. After a newer commit is pushed, delete the cached `opencode-collaboration@git+...` folder (or bump the repo ref) and restart opencode to pick it up — git plugins are not re-fetched on every launch.
|
|
45
|
-
|
|
46
47
|
**Single-Enter commands (optional but recommended).** The package ships a TUI entry that makes the plugin's slash commands execute on the first Enter. opencode's TUI loads plugins from `~/.config/opencode/tui.json` (a separate list from `opencode.json`), so add the plugin there too:
|
|
47
48
|
|
|
48
49
|
```json
|
|
@@ -112,7 +113,7 @@ Options can be passed via the tuple form in `opencode.json`:
|
|
|
112
113
|
```json
|
|
113
114
|
{
|
|
114
115
|
"plugin": [
|
|
115
|
-
["opencode-collaboration
|
|
116
|
+
["opencode-collaboration", { "inboundPolicy": "hold", "name": "frontend" }]
|
|
116
117
|
]
|
|
117
118
|
}
|
|
118
119
|
```
|
|
@@ -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,23 +26,24 @@
|
|
|
26
26
|
- 命令结果和通知**内联显示在会话中** —— 没有 toast 弹窗
|
|
27
27
|
- **明确的 TUI 控制**:面板操作使用宿主对话框进行选择确认;斜杠命令封装仍可用于自动化和兼容性场景
|
|
28
28
|
- 纯本地运行:一切都在你的机器上(macOS/Linux 使用 Unix 域套接字,Windows 使用回环 TCP,另有一个兼容 v1 对端的回环监听器)
|
|
29
|
+
- 本进程的 peer 名字会追加到根会话标题后面,一眼就能看出每个 opencode 窗口是谁
|
|
29
30
|
|
|
30
31
|
## 安装
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
```bash
|
|
34
|
+
opencode plugin -g opencode-collaboration
|
|
35
|
+
```
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
或者加入你的 `opencode.json`:
|
|
35
38
|
|
|
36
39
|
```json
|
|
37
40
|
{
|
|
38
|
-
"plugin": ["opencode-collaboration
|
|
41
|
+
"plugin": ["opencode-collaboration"]
|
|
39
42
|
}
|
|
40
43
|
```
|
|
41
44
|
|
|
42
45
|
要求 opencode >= 1.18.0。
|
|
43
46
|
|
|
44
|
-
> **更新方式:** opencode 会把 git 插件缓存在 `~/.cache/opencode/packages/` 下。推送新提交后,删除缓存的 `opencode-collaboration@git+...` 目录(或改动仓库 ref),再重启 opencode 才会拉取最新 —— git 插件不会每次启动都重新拉取。
|
|
45
|
-
|
|
46
47
|
**单回车执行命令(可选但推荐)。** 本包附带一个 TUI 入口,让插件的斜杠命令按一次回车即可执行。opencode 的 TUI 从 `~/.config/opencode/tui.json` 加载插件(这是与 `opencode.json` 相互独立的另一个列表),所以请把插件也加进去:
|
|
47
48
|
|
|
48
49
|
```json
|
|
@@ -112,7 +113,7 @@ Use send_message to tell "backend" that the login form now posts to /v2/login.
|
|
|
112
113
|
```json
|
|
113
114
|
{
|
|
114
115
|
"plugin": [
|
|
115
|
-
["opencode-collaboration
|
|
116
|
+
["opencode-collaboration", { "inboundPolicy": "hold", "name": "frontend" }]
|
|
116
117
|
]
|
|
117
118
|
}
|
|
118
119
|
```
|
|
@@ -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
|
-
|
|
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
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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;
|
package/dist/session-runtime.js
CHANGED
|
@@ -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.
|
|
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",
|