opencode-collaboration 0.2.3
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/LICENSE +194 -0
- package/README.md +224 -0
- package/README.zh-CN.md +224 -0
- package/commands/list-agents.md +8 -0
- package/commands/peers-inbox.md +8 -0
- package/commands/peers-name.md +8 -0
- package/commands/peers-outbox.md +8 -0
- package/commands/peers.md +8 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.js +95 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +50 -0
- package/dist/delivery.d.ts +42 -0
- package/dist/delivery.js +177 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +40 -0
- package/dist/format.d.ts +32 -0
- package/dist/format.js +107 -0
- package/dist/gating.d.ts +4 -0
- package/dist/gating.js +16 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +410 -0
- package/dist/listener.d.ts +37 -0
- package/dist/listener.js +335 -0
- package/dist/outbox.d.ts +12 -0
- package/dist/outbox.js +110 -0
- package/dist/permissions.d.ts +47 -0
- package/dist/permissions.js +194 -0
- package/dist/queue.d.ts +89 -0
- package/dist/queue.js +824 -0
- package/dist/registry.d.ts +70 -0
- package/dist/registry.js +308 -0
- package/dist/sender.d.ts +27 -0
- package/dist/sender.js +139 -0
- package/dist/session-runtime.d.ts +40 -0
- package/dist/session-runtime.js +355 -0
- package/dist/session-tracker.d.ts +16 -0
- package/dist/session-tracker.js +39 -0
- package/dist/tools/peers-tools.d.ts +26 -0
- package/dist/tools/peers-tools.js +173 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.js +46 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +228 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.js +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-resolve permission requests that originate from a peer-triggered
|
|
3
|
+
* turn, modeled after Claude Code's per-source permission modes.
|
|
4
|
+
*
|
|
5
|
+
* A turn counts as peer-triggered when the user message that started it was
|
|
6
|
+
* injected by this plugin — detectable via the `peerMessage: true` metadata
|
|
7
|
+
* stamped on the injected part (see delivery.ts). Permission requests point
|
|
8
|
+
* at the *assistant* message holding the tool call, so the lookup walks up
|
|
9
|
+
* via parentID to the originating user message before checking its parts.
|
|
10
|
+
*
|
|
11
|
+
* Mechanism: opencode 1.18 does not invoke the plugin SDK's `permission.ask`
|
|
12
|
+
* hook, but it publishes permission request events on the bus and exposes a
|
|
13
|
+
* reply endpoint — the same pair the TUI uses. The plugin listens for
|
|
14
|
+
* `permission.v2.asked` (and the legacy `permission.asked`) and replies
|
|
15
|
+
* "once" (allow) or "reject" (deny) when the requesting turn is
|
|
16
|
+
* peer-triggered. Local user turns get no reply and fall through to
|
|
17
|
+
* opencode's normal prompt flow untouched.
|
|
18
|
+
*/
|
|
19
|
+
const CACHE_CAP = 200;
|
|
20
|
+
/** Assistant message -> its parent user message; 4 hops is generous. */
|
|
21
|
+
const MAX_HOPS = 4;
|
|
22
|
+
function flattenedPermissionText(props) {
|
|
23
|
+
const values = [];
|
|
24
|
+
const visit = (value, depth) => {
|
|
25
|
+
if (depth > 3 || value == null)
|
|
26
|
+
return;
|
|
27
|
+
if (typeof value === "string")
|
|
28
|
+
values.push(value);
|
|
29
|
+
else if (Array.isArray(value))
|
|
30
|
+
for (const item of value)
|
|
31
|
+
visit(item, depth + 1);
|
|
32
|
+
else if (typeof value === "object")
|
|
33
|
+
for (const item of Object.values(value))
|
|
34
|
+
visit(item, depth + 1);
|
|
35
|
+
};
|
|
36
|
+
visit(props, 0);
|
|
37
|
+
return values.join("\n").toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Requests in these categories always remain under OpenCode's native policy/UI.
|
|
41
|
+
*
|
|
42
|
+
* This is a best-effort denylist, not a security boundary: it matches on the
|
|
43
|
+
* flattened event text, so a determined peer message can phrase a request to
|
|
44
|
+
* avoid these patterns (e.g. `npm config set` never names `.npmrc`). Treat
|
|
45
|
+
* `peerPermissions: "allow"` as fully trusting your peers; use "ask" for
|
|
46
|
+
* anything sensitive.
|
|
47
|
+
*/
|
|
48
|
+
export function isProtectedPermission(props) {
|
|
49
|
+
const permission = String(props.permission ?? props.action ?? props.type ?? "").toLowerCase();
|
|
50
|
+
const text = flattenedPermissionText(props);
|
|
51
|
+
if (permission === "permission" || /permission[ _.-]*(?:escalat|config|rule)/.test(text))
|
|
52
|
+
return true;
|
|
53
|
+
return /(?:^|[\\/\s])agents\.md(?:$|\s)/i.test(text) ||
|
|
54
|
+
/(?:^|[\\/\s])(?:opencode(?:\.jsonc?)?|\.opencode)(?:$|[\\/\s])/i.test(text) ||
|
|
55
|
+
/(?:^|[\\/\s])(?:\.env(?:\.[^\s\\/]*)?|credentials?|secrets?|\.npmrc|\.pypirc|\.netrc|\.gitconfig)(?:$|\s)/i.test(text) ||
|
|
56
|
+
/(?:^|[\\/\s])(?:\.aws|\.ssh|\.gnupg|\.kube|\.docker)[\\/]/i.test(text) ||
|
|
57
|
+
// shell startup / persistence paths
|
|
58
|
+
/(?:^|[\\/\s])(?:\.zshrc|\.zshenv|\.zprofile|\.bashrc|\.bash_profile|\.bash_login|\.profile|config\.fish)(?:$|\s)/i.test(text) ||
|
|
59
|
+
/(?:^|[\\/\s])(?:launchagents|launchdaemons)[\\/]/i.test(text) ||
|
|
60
|
+
/(?:^|\s)(?:crontab|visudo)(?:\s|$)/i.test(text) ||
|
|
61
|
+
/\/(?:etc|private\/etc)\/(?:sudoers|crontab|cron\.)/i.test(text);
|
|
62
|
+
}
|
|
63
|
+
export function PeerPermissions(opts) {
|
|
64
|
+
// messageID -> whether that message's turn is peer-triggered. Verdicts are
|
|
65
|
+
// stable per message, and one turn typically raises several permission
|
|
66
|
+
// requests against the same messageID.
|
|
67
|
+
const turnCache = new Map();
|
|
68
|
+
// permission request IDs already replied to (both "permission.asked" and
|
|
69
|
+
// "permission.updated" may fire for one request)
|
|
70
|
+
const replied = new Set();
|
|
71
|
+
async function fetchMessage(sessionID, messageID) {
|
|
72
|
+
const res = await opts.client.session.message({
|
|
73
|
+
path: { id: sessionID, messageID },
|
|
74
|
+
});
|
|
75
|
+
const data = res.data;
|
|
76
|
+
if (!data)
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
role: data.info?.role,
|
|
80
|
+
parentID: data.info?.parentID,
|
|
81
|
+
isPeer: Boolean(data.parts?.some((p) => Boolean(p.metadata?.peerMessage))),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function isPeerTurn(sessionID, messageID) {
|
|
85
|
+
const key = `${sessionID}:${messageID}`;
|
|
86
|
+
const hit = turnCache.get(key);
|
|
87
|
+
if (hit !== undefined)
|
|
88
|
+
return hit;
|
|
89
|
+
let verdict = false;
|
|
90
|
+
let transient = false;
|
|
91
|
+
try {
|
|
92
|
+
let cursor = messageID;
|
|
93
|
+
for (let hop = 0; cursor && hop < MAX_HOPS; hop++) {
|
|
94
|
+
const view = await fetchMessage(sessionID, cursor);
|
|
95
|
+
if (!view) {
|
|
96
|
+
// Message not retrievable yet (event raced storage) — the verdict
|
|
97
|
+
// is not stable, so do not cache it.
|
|
98
|
+
transient = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (view.isPeer) {
|
|
102
|
+
verdict = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (view.role === "user" || !view.parentID)
|
|
106
|
+
break;
|
|
107
|
+
cursor = view.parentID;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Message gone, server unreachable, ... — stay out of the way.
|
|
112
|
+
await opts.logger("debug", "peer-message lookup failed; leaving permission to default", {
|
|
113
|
+
error: String(err),
|
|
114
|
+
sessionID,
|
|
115
|
+
messageID,
|
|
116
|
+
});
|
|
117
|
+
return false; // not cached: a transient failure may succeed next time
|
|
118
|
+
}
|
|
119
|
+
if (transient)
|
|
120
|
+
return false; // not cached: re-evaluate on the next event
|
|
121
|
+
if (turnCache.size >= CACHE_CAP) {
|
|
122
|
+
const oldest = turnCache.keys().next().value;
|
|
123
|
+
if (oldest !== undefined)
|
|
124
|
+
turnCache.delete(oldest);
|
|
125
|
+
}
|
|
126
|
+
turnCache.set(key, verdict);
|
|
127
|
+
return verdict;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
async handleEvent(event) {
|
|
131
|
+
if (event.type !== "permission.v2.asked" && event.type !== "permission.asked")
|
|
132
|
+
return;
|
|
133
|
+
const mode = opts.mode();
|
|
134
|
+
if (mode === "ask")
|
|
135
|
+
return;
|
|
136
|
+
const props = event.properties ?? {};
|
|
137
|
+
const permissionID = props.id;
|
|
138
|
+
const sessionID = props.sessionID;
|
|
139
|
+
// v2 events carry the requesting tool call in `source`; legacy events
|
|
140
|
+
// (and the SSE compat mapping) use `messageID` / `tool.messageID`.
|
|
141
|
+
const source = props.source;
|
|
142
|
+
const tool = props.tool;
|
|
143
|
+
const messageID = (source?.messageID ?? tool?.messageID ?? props.messageID);
|
|
144
|
+
if (!permissionID || !sessionID || !messageID)
|
|
145
|
+
return;
|
|
146
|
+
if (replied.has(permissionID))
|
|
147
|
+
return;
|
|
148
|
+
if (!(await isPeerTurn(sessionID, messageID)))
|
|
149
|
+
return;
|
|
150
|
+
if (mode === "allow" && isProtectedPermission(props)) {
|
|
151
|
+
await opts.logger("warn", "protected peer permission left to OpenCode policy", {
|
|
152
|
+
permission: props.permission ?? props.action ?? props.type,
|
|
153
|
+
sessionID,
|
|
154
|
+
permissionID,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
replied.add(permissionID);
|
|
159
|
+
if (replied.size > CACHE_CAP) {
|
|
160
|
+
const oldest = replied.values().next().value;
|
|
161
|
+
if (oldest !== undefined)
|
|
162
|
+
replied.delete(oldest);
|
|
163
|
+
}
|
|
164
|
+
const response = mode === "deny" ? "reject" : "once";
|
|
165
|
+
try {
|
|
166
|
+
const client = opts.client;
|
|
167
|
+
if (typeof client.postSessionIdPermissionsPermissionId !== "function") {
|
|
168
|
+
await opts.logger("warn", "permission reply endpoint unavailable in this SDK");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// invoked as a method: the SDK class needs its `this` binding
|
|
172
|
+
await client.postSessionIdPermissionsPermissionId({
|
|
173
|
+
path: { id: sessionID, permissionID },
|
|
174
|
+
body: { response },
|
|
175
|
+
query: { directory: opts.directory },
|
|
176
|
+
});
|
|
177
|
+
await opts.logger("info", `auto-${mode} permission in peer-triggered turn`, {
|
|
178
|
+
permission: props.action ?? props.type,
|
|
179
|
+
title: props.title,
|
|
180
|
+
sessionID,
|
|
181
|
+
permissionID,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
replied.delete(permissionID); // allow a retry on the next event
|
|
186
|
+
await opts.logger("warn", "failed to auto-resolve permission", {
|
|
187
|
+
error: String(err),
|
|
188
|
+
sessionID,
|
|
189
|
+
permissionID,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-scoped durable delivery queue and held inbox.
|
|
3
|
+
*/
|
|
4
|
+
import type { ResolvedConfig } from "./config.js";
|
|
5
|
+
import type { HeldMessage, InboundMessage, Logger, PeerAcknowledgementV2, ReceiveStatus } from "./types.js";
|
|
6
|
+
export declare function stableSpoolEndpointId(directory: string): string;
|
|
7
|
+
export declare function stableSessionEndpointId(sessionId: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* True when the session's durable spool holds any records — undelivered
|
|
10
|
+
* (queued/held/inflight) or delivered (done, retained 24h for dedupe).
|
|
11
|
+
* Startup adopts exactly these sessions (plus snapshot-busy ones): pending
|
|
12
|
+
* work must resume, and done records must load or a post-restart retry would
|
|
13
|
+
* deliver a duplicate. Everything else stays unpublished until real activity.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hasSpoolRecords(config: ResolvedConfig, sessionId: string): boolean;
|
|
16
|
+
export declare function createSessionMessageQueue(opts: {
|
|
17
|
+
config: ResolvedConfig;
|
|
18
|
+
sessionId: string;
|
|
19
|
+
logger: Logger;
|
|
20
|
+
}): QueueInstance;
|
|
21
|
+
export declare function createProcessMessageQueue(opts: {
|
|
22
|
+
config: ResolvedConfig;
|
|
23
|
+
directory: string;
|
|
24
|
+
logger: Logger;
|
|
25
|
+
}): QueueInstance;
|
|
26
|
+
export interface QueueOptions {
|
|
27
|
+
/** Logical receiver identity; Task 2 supplies one value per session endpoint. */
|
|
28
|
+
endpointId?: string;
|
|
29
|
+
maxQueue: number;
|
|
30
|
+
maxHeld: number;
|
|
31
|
+
/** Held messages expire after five minutes unless explicitly configured otherwise. */
|
|
32
|
+
heldExpiryMs?: number;
|
|
33
|
+
/** Short same-sender/content debounce window. */
|
|
34
|
+
debounceMs?: number;
|
|
35
|
+
inboxFile: string;
|
|
36
|
+
logger: Logger;
|
|
37
|
+
}
|
|
38
|
+
export interface QueueInstance {
|
|
39
|
+
/** Returns false when the queue is full. */
|
|
40
|
+
enqueue: (msg: InboundMessage) => boolean;
|
|
41
|
+
drain: () => InboundMessage[];
|
|
42
|
+
/** Mark a flushed batch as delivered and return durable final acknowledgements. */
|
|
43
|
+
complete: (messages: InboundMessage[]) => Promise<PeerAcknowledgementV2[]>;
|
|
44
|
+
/** Return a failed delivery batch from inflight to the front of the queue. */
|
|
45
|
+
requeue: (messages: InboundMessage[]) => Promise<void>;
|
|
46
|
+
/** Returns a durable duplicate acknowledgement when this sender/message pair already exists. */
|
|
47
|
+
duplicateAcknowledgement: (msg: InboundMessage) => PeerAcknowledgementV2 | null;
|
|
48
|
+
/** The original receiver state returned for an idempotent retry. */
|
|
49
|
+
existingStatus: (msg: InboundMessage) => ReceiveStatus | null;
|
|
50
|
+
isDebounced: (msg: InboundMessage) => boolean;
|
|
51
|
+
/** Refuse an inbound message and retain its final acknowledgement for deduplication. */
|
|
52
|
+
refuse: (msg: InboundMessage) => Promise<PeerAcknowledgementV2>;
|
|
53
|
+
pending: () => InboundMessage[];
|
|
54
|
+
size: () => number;
|
|
55
|
+
hold: (msg: InboundMessage) => Promise<boolean>;
|
|
56
|
+
held: () => HeldMessage[];
|
|
57
|
+
/** Move expired held records to done and return their final acknowledgements. */
|
|
58
|
+
expireHeld: () => Promise<PeerAcknowledgementV2[]>;
|
|
59
|
+
/** Final outcomes that still need to be returned to their sender. */
|
|
60
|
+
pendingAcknowledgements: () => PeerAcknowledgementV2[];
|
|
61
|
+
/** Durably record successful ACK transport so restarts do not resend forever. */
|
|
62
|
+
markAcknowledgementSent: (ack: PeerAcknowledgementV2) => Promise<void>;
|
|
63
|
+
/** Accept by 1-based index or "all"; returns the messages moved to the queue. */
|
|
64
|
+
acceptHeld: (which: number | "all") => Promise<HeldMessage[]>;
|
|
65
|
+
dropHeld: (which: number | "all") => Promise<number>;
|
|
66
|
+
loadHeld: () => Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
export interface SpoolMigrationResult {
|
|
69
|
+
migrated: number;
|
|
70
|
+
deduplicated: number;
|
|
71
|
+
quarantined: number;
|
|
72
|
+
sourceEndpointId: string;
|
|
73
|
+
targetEndpointId: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Move Task 1's directory-scoped spool into Task 2's compatibility session.
|
|
77
|
+
* Individual renames are atomic and the operation is restart-safe. Both
|
|
78
|
+
* endpoint locks are acquired in endpoint-ID order so concurrent recovery
|
|
79
|
+
* cannot deadlock or race normal queue state transitions.
|
|
80
|
+
*/
|
|
81
|
+
export declare function migrateWorkspaceSpool(opts: {
|
|
82
|
+
config: ResolvedConfig;
|
|
83
|
+
directory: string;
|
|
84
|
+
targetSessionId: string;
|
|
85
|
+
logger: Logger;
|
|
86
|
+
}): Promise<SpoolMigrationResult>;
|
|
87
|
+
export declare function MessageQueue(opts: QueueOptions): QueueInstance;
|
|
88
|
+
/** Per-key sliding-window rate limiter. */
|
|
89
|
+
export declare function RateLimiter(limitPerMin: number): (key: string) => boolean;
|