@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.
@@ -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
- const converted = convertV2Message(raw, sessionID);
182
- if (converted)
183
- out.push(converted);
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.1.1",
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
+ }