@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.2
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 +36 -37
- package/ci/README.md +9 -3
- package/ci/openshare.js +916 -199
- package/dist/binding-queue.d.ts +1 -0
- package/dist/binding-queue.js +22 -0
- package/dist/commands.js +41 -285
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +888 -96
- package/dist/dialogs.js +39 -1
- package/dist/settings.js +141 -16
- package/dist/share-flow.d.ts +7 -2
- package/dist/share-flow.js +32 -17
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -38
- package/dist/tui.js +286 -421
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +61 -121
- package/package.json +3 -1
- package/src/binding-queue.ts +26 -0
- package/src/commands.ts +41 -280
- package/src/contract.ts +320 -0
- package/src/core.ts +864 -99
- package/src/dialogs.tsx +45 -2
- package/src/settings.ts +133 -15
- package/src/share-flow.ts +33 -19
- package/src/sidebar.tsx +4 -45
- package/src/v2-server.ts +57 -110
package/dist/v2-server.d.ts
CHANGED
|
@@ -1,30 +1,8 @@
|
|
|
1
|
-
import { type MessageInfo, type PartInfo } from "./core.js";
|
|
2
1
|
type V2Event = {
|
|
3
2
|
type: string;
|
|
4
3
|
data?: Record<string, unknown>;
|
|
5
4
|
[key: string]: unknown;
|
|
6
5
|
};
|
|
7
|
-
type V2Message = {
|
|
8
|
-
id: string;
|
|
9
|
-
type: string;
|
|
10
|
-
time?: {
|
|
11
|
-
created?: number;
|
|
12
|
-
completed?: number;
|
|
13
|
-
streamed?: number;
|
|
14
|
-
};
|
|
15
|
-
text?: string;
|
|
16
|
-
agent?: string;
|
|
17
|
-
model?: {
|
|
18
|
-
providerID: string;
|
|
19
|
-
id?: string;
|
|
20
|
-
modelID?: string;
|
|
21
|
-
};
|
|
22
|
-
content?: Array<Record<string, unknown>>;
|
|
23
|
-
error?: {
|
|
24
|
-
message?: string;
|
|
25
|
-
} | null;
|
|
26
|
-
metadata?: Record<string, unknown>;
|
|
27
|
-
};
|
|
28
6
|
type V2SessionClient = {
|
|
29
7
|
get(input: {
|
|
30
8
|
sessionID: string;
|
|
@@ -72,10 +50,5 @@ type V2Context = {
|
|
|
72
50
|
};
|
|
73
51
|
signal?: AbortSignal;
|
|
74
52
|
};
|
|
75
|
-
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
76
|
-
export declare function convertV2Message(message: V2Message, sessionID: string): {
|
|
77
|
-
info: MessageInfo;
|
|
78
|
-
parts: PartInfo[];
|
|
79
|
-
} | undefined;
|
|
80
53
|
export declare function createV2ServerSetup(): (context: V2Context) => Promise<(() => void) | void>;
|
|
81
54
|
export {};
|
package/dist/v2-server.js
CHANGED
|
@@ -29,125 +29,10 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { METADATA_KEY, moduleDir, packageRoot } from "./config.js";
|
|
31
31
|
import { createEngine } from "./core.js";
|
|
32
|
-
// --- v2 message -> v1 items ---------------------------------------------------
|
|
33
|
-
function toolContentText(content) {
|
|
34
|
-
if (typeof content === "string")
|
|
35
|
-
return content;
|
|
36
|
-
if (Array.isArray(content)) {
|
|
37
|
-
return content
|
|
38
|
-
.map((item) => {
|
|
39
|
-
if (typeof item === "string")
|
|
40
|
-
return item;
|
|
41
|
-
if (item && typeof item === "object") {
|
|
42
|
-
const record = item;
|
|
43
|
-
if (typeof record.text === "string")
|
|
44
|
-
return record.text;
|
|
45
|
-
}
|
|
46
|
-
return "";
|
|
47
|
-
})
|
|
48
|
-
.join("");
|
|
49
|
-
}
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
function convertToolState(state) {
|
|
53
|
-
if (!state || typeof state !== "object")
|
|
54
|
-
return { status: "pending" };
|
|
55
|
-
const record = state;
|
|
56
|
-
const status = String(record.status ?? "pending");
|
|
57
|
-
const mapped = {};
|
|
58
|
-
if (status === "streaming" || status === "running")
|
|
59
|
-
mapped.status = "running";
|
|
60
|
-
else if (status === "completed")
|
|
61
|
-
mapped.status = "completed";
|
|
62
|
-
else if (status === "failed" || status === "error")
|
|
63
|
-
mapped.status = "error";
|
|
64
|
-
else
|
|
65
|
-
mapped.status = "pending";
|
|
66
|
-
if (record.input !== undefined)
|
|
67
|
-
mapped.input = record.input;
|
|
68
|
-
if (record.output !== undefined)
|
|
69
|
-
mapped.output = toolContentText(record.output);
|
|
70
|
-
if (record.error !== undefined)
|
|
71
|
-
mapped.error = record.error;
|
|
72
|
-
if (record.time !== undefined)
|
|
73
|
-
mapped.time = record.time;
|
|
74
|
-
return mapped;
|
|
75
|
-
}
|
|
76
|
-
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
77
|
-
export function convertV2Message(message, sessionID) {
|
|
78
|
-
if (!message || typeof message.id !== "string")
|
|
79
|
-
return undefined;
|
|
80
|
-
const base = {
|
|
81
|
-
id: message.id,
|
|
82
|
-
sessionID,
|
|
83
|
-
};
|
|
84
|
-
if (message.type === "user") {
|
|
85
|
-
return {
|
|
86
|
-
info: {
|
|
87
|
-
...base,
|
|
88
|
-
role: "user",
|
|
89
|
-
time: { created: message.time?.created ?? 0 },
|
|
90
|
-
text: message.text ?? "",
|
|
91
|
-
},
|
|
92
|
-
parts: [],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
if (message.type === "assistant") {
|
|
96
|
-
const parts = [];
|
|
97
|
-
const content = Array.isArray(message.content) ? message.content : [];
|
|
98
|
-
content.forEach((block, index) => {
|
|
99
|
-
const record = block;
|
|
100
|
-
const kind = String(record.type ?? "");
|
|
101
|
-
if (kind === "text") {
|
|
102
|
-
parts.push({
|
|
103
|
-
id: `${message.id}_t${index}`,
|
|
104
|
-
messageID: message.id,
|
|
105
|
-
sessionID,
|
|
106
|
-
type: "text",
|
|
107
|
-
text: String(record.text ?? ""),
|
|
108
|
-
synthetic: false,
|
|
109
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
else if (kind === "reasoning") {
|
|
113
|
-
parts.push({
|
|
114
|
-
id: `${message.id}_r${index}`,
|
|
115
|
-
messageID: message.id,
|
|
116
|
-
sessionID,
|
|
117
|
-
type: "reasoning",
|
|
118
|
-
text: String(record.text ?? ""),
|
|
119
|
-
synthetic: false,
|
|
120
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
else if (kind === "tool") {
|
|
124
|
-
parts.push({
|
|
125
|
-
id: String(record.id ?? `${message.id}_tool${index}`),
|
|
126
|
-
messageID: message.id,
|
|
127
|
-
sessionID,
|
|
128
|
-
type: "tool",
|
|
129
|
-
tool: String(record.name ?? "unknown"),
|
|
130
|
-
state: convertToolState(record.state),
|
|
131
|
-
synthetic: false,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
return {
|
|
136
|
-
info: {
|
|
137
|
-
...base,
|
|
138
|
-
role: "assistant",
|
|
139
|
-
agent: message.agent,
|
|
140
|
-
model: message.model
|
|
141
|
-
? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
|
|
142
|
-
: undefined,
|
|
143
|
-
time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
|
|
144
|
-
},
|
|
145
|
-
parts,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return undefined;
|
|
149
|
-
}
|
|
150
32
|
// --- v2 client -> engine ClientApi ---------------------------------------------
|
|
33
|
+
// Contract v2: messages pass through VERBATIM as v2 session messages (the
|
|
34
|
+
// engine's ingestion detects v2-native shapes and enqueues them directly —
|
|
35
|
+
// no v1 conversion, no dropped compaction, no dropped user text).
|
|
151
36
|
function adaptSessionClient(session, message, directory) {
|
|
152
37
|
if (!session)
|
|
153
38
|
return undefined;
|
|
@@ -178,9 +63,9 @@ function adaptSessionClient(session, message, directory) {
|
|
|
178
63
|
if (!Array.isArray(data))
|
|
179
64
|
break;
|
|
180
65
|
for (const raw of data) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
66
|
+
if (raw && typeof raw === "object" && typeof raw.id === "string") {
|
|
67
|
+
out.push({ info: raw, parts: [] });
|
|
68
|
+
}
|
|
184
69
|
}
|
|
185
70
|
const next = res?.cursor?.next ?? undefined;
|
|
186
71
|
if (!next || data.length === 0)
|
|
@@ -292,7 +177,20 @@ export function createV2ServerSetup() {
|
|
|
292
177
|
// the client adapter and feed them into the engine as the v1 events it
|
|
293
178
|
// understands (session.updated / message.updated / message.part.updated).
|
|
294
179
|
// The engine's keyed queue makes repeated re-syncs idempotent.
|
|
180
|
+
// NOTE: no content-hash dedupe here — a refresh that runs while the
|
|
181
|
+
// session is still dormant (pre-flag) would poison the cache without
|
|
182
|
+
// emitting anything, and the flag-time refresh would then SKIP the
|
|
183
|
+
// unchanged messages (live-caught in the v2 mock suite: the share's
|
|
184
|
+
// first assistant content arrived only as the next change). The dedupe
|
|
185
|
+
// belongs engine-side, where dormancy is known, if it's ever needed.
|
|
295
186
|
const pendingRefresh = new Map();
|
|
187
|
+
// queued inbox inputs per session: v2 persists the prompt in the inbox
|
|
188
|
+
// (delivery "queue") while the current turn still runs — the message
|
|
189
|
+
// row can exist before the model ever sees it, and a mid-turn refresh
|
|
190
|
+
// would surface it early. Held here until session.inbox.delivered
|
|
191
|
+
// fires (the turn actually starts); "steer" inputs inject into the
|
|
192
|
+
// live run, so they pass straight through.
|
|
193
|
+
const queuedInbox = new Map();
|
|
296
194
|
const refresh = (sessionID) => {
|
|
297
195
|
let timer = pendingRefresh.get(sessionID);
|
|
298
196
|
if (timer)
|
|
@@ -307,7 +205,10 @@ export function createV2ServerSetup() {
|
|
|
307
205
|
});
|
|
308
206
|
const messages = await client.session.messages?.({ path: { id: sessionID } });
|
|
309
207
|
const list = (messages?.data ?? []);
|
|
208
|
+
const held = queuedInbox.get(sessionID);
|
|
310
209
|
for (const message of list) {
|
|
210
|
+
if (held?.has(message.info.id))
|
|
211
|
+
continue; // still queued — not sent to the model yet
|
|
311
212
|
await engine.hooks.event({
|
|
312
213
|
event: { type: "message.updated", properties: { sessionID, info: message.info } },
|
|
313
214
|
});
|
|
@@ -343,6 +244,45 @@ export function createV2ServerSetup() {
|
|
|
343
244
|
await engine.hooks.event({ event: { type, properties: { sessionID: sid } } });
|
|
344
245
|
continue;
|
|
345
246
|
}
|
|
247
|
+
if (type.startsWith("session.inbox.")) {
|
|
248
|
+
// queued-prompt lifecycle: hold delivery="queue" inputs off
|
|
249
|
+
// the share until they are actually delivered to the model
|
|
250
|
+
// (cancelled ones never appear). inboxID shares the message
|
|
251
|
+
// id space (the inbox item promotes into that message row).
|
|
252
|
+
const data = (event.data ?? {});
|
|
253
|
+
const inboxID = data.inboxID;
|
|
254
|
+
if (inboxID) {
|
|
255
|
+
let set = queuedInbox.get(sid);
|
|
256
|
+
if (!set) {
|
|
257
|
+
set = new Set();
|
|
258
|
+
queuedInbox.set(sid, set);
|
|
259
|
+
}
|
|
260
|
+
if (type === "session.inbox.enqueued" && data.item?.delivery === "queue") {
|
|
261
|
+
set.add(inboxID);
|
|
262
|
+
}
|
|
263
|
+
else if (type === "session.inbox.delivered") {
|
|
264
|
+
set.delete(inboxID);
|
|
265
|
+
refresh(sid); // the held prompt's turn is starting now
|
|
266
|
+
}
|
|
267
|
+
else if (type === "session.inbox.cancelled") {
|
|
268
|
+
// cancelled-before-run: the prompt never reaches the
|
|
269
|
+
// model — keep it held forever so no later refresh
|
|
270
|
+
// surfaces the orphaned row
|
|
271
|
+
set.add(inboxID);
|
|
272
|
+
}
|
|
273
|
+
else if (type === "session.inbox.delivery.changed") {
|
|
274
|
+
// steer inputs inject into the live run — the model
|
|
275
|
+
// sees them; only "queue" holds
|
|
276
|
+
if (data.item?.delivery === "queue")
|
|
277
|
+
set.add(inboxID);
|
|
278
|
+
else {
|
|
279
|
+
set.delete(inboxID);
|
|
280
|
+
refresh(sid);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
346
286
|
if (type === "message.removed" || type === "message.part.removed") {
|
|
347
287
|
// undo cleanup: forwarded verbatim — the engine emits the
|
|
348
288
|
// tombstones that purge the reverted items from the share
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirrowel/opencode-openshare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-dev.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -76,6 +76,8 @@
|
|
|
76
76
|
"test:v2": "node scripts/v2-mock-tests.mjs",
|
|
77
77
|
"test:auth": "node scripts/auth-tests.mjs",
|
|
78
78
|
"test:server-auth": "node scripts/auth-tests.mjs --backend bun",
|
|
79
|
+
"test:big": "node scripts/big-tests.mjs",
|
|
80
|
+
"test:server-big": "node scripts/big-tests.mjs --backend bun",
|
|
79
81
|
"test:server-enroll": "node scripts/enroll-tests.mjs --backend bun",
|
|
80
82
|
"test:enroll": "node scripts/enroll-tests.mjs",
|
|
81
83
|
"test:fanout": "node scripts/fanout-tests.mjs",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Serialize session-metadata binding writes.
|
|
2
|
+
*
|
|
3
|
+
* Two writers read-modify-write `metadata.openshare` on the same session:
|
|
4
|
+
* the engine (`writeMetadata` in core.ts — preserves foreign keys, sets
|
|
5
|
+
* the servers map) and the TUI (`patchSessionBinding` in share-flow.ts —
|
|
6
|
+
* opt-outs, collision resolution). Their PATCHes interleave at the host:
|
|
7
|
+
* writer A reads at T0, writer B reads at T1, A PATCHes at T2, B PATCHes
|
|
8
|
+
* at T3 — A's change is silently lost (live-caught class: a TUI opt-out
|
|
9
|
+
* erased by the engine's binding refresh).
|
|
10
|
+
*
|
|
11
|
+
* Both route their whole read-modify-write through this per-session FIFO.
|
|
12
|
+
* The queue never breaks on errors; entries drop when the process exits.
|
|
13
|
+
*/
|
|
14
|
+
const queues = new Map<string, Promise<unknown>>()
|
|
15
|
+
|
|
16
|
+
export function queueBindingWrite<T>(sid: string, fn: () => Promise<T>): Promise<T> {
|
|
17
|
+
const prev = queues.get(sid) ?? Promise.resolve()
|
|
18
|
+
// run regardless of the previous slot's outcome — one failed write must
|
|
19
|
+
// not wedge the session's binding updates forever
|
|
20
|
+
const run = prev.then(fn, fn)
|
|
21
|
+
queues.set(
|
|
22
|
+
sid,
|
|
23
|
+
run.catch(() => {}),
|
|
24
|
+
)
|
|
25
|
+
return run
|
|
26
|
+
}
|