@mirrowel/opencode-openshare 0.2.0-dev.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/dist/v2-server.js CHANGED
@@ -177,7 +177,20 @@ export function createV2ServerSetup() {
177
177
  // the client adapter and feed them into the engine as the v1 events it
178
178
  // understands (session.updated / message.updated / message.part.updated).
179
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.
180
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();
181
194
  const refresh = (sessionID) => {
182
195
  let timer = pendingRefresh.get(sessionID);
183
196
  if (timer)
@@ -192,7 +205,10 @@ export function createV2ServerSetup() {
192
205
  });
193
206
  const messages = await client.session.messages?.({ path: { id: sessionID } });
194
207
  const list = (messages?.data ?? []);
208
+ const held = queuedInbox.get(sessionID);
195
209
  for (const message of list) {
210
+ if (held?.has(message.info.id))
211
+ continue; // still queued — not sent to the model yet
196
212
  await engine.hooks.event({
197
213
  event: { type: "message.updated", properties: { sessionID, info: message.info } },
198
214
  });
@@ -228,6 +244,45 @@ export function createV2ServerSetup() {
228
244
  await engine.hooks.event({ event: { type, properties: { sessionID: sid } } });
229
245
  continue;
230
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
+ }
231
286
  if (type === "message.removed" || type === "message.part.removed") {
232
287
  // undo cleanup: forwarded verbatim — the engine emits the
233
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.2.0-dev.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",
@@ -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
+ }
package/src/commands.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // OpenShare command registration. ONE user-facing /share command shadows
2
2
  // the deprecated built-in (opncd.ai) by registering the same command name
3
- // on a plugin keymap layer; running it on a shared session opens the
4
- // share manager (copy link / stop sharing) instead — you can't share
5
- // twice, so /share is a toggle. /openshare opens settings.
3
+ // on a plugin keymap layer. Running it on a shared session opens the SAME
4
+ // session menu as /openshare → This session (copy / stop / TTL / read
5
+ // auth — one surface, no duplicated manager); on an unshared session it
6
+ // asks consent and creates the share. /openshare opens settings.
6
7
  //
7
8
  // LAYER SHAPE (live-caught 2026-10-19): the v1 host keymap gates layers
8
9
  // that declare `mode` on the mode stack (resting mode is "base"), so a
@@ -14,21 +15,16 @@
14
15
  // deliberately NOT joined into the shared family palette category).
15
16
 
16
17
  import {
17
- CONSENT_KV_KEY,
18
18
  clearGoneBindingEntries,
19
- confirmShare,
20
19
  currentConfig,
21
- isConfigured,
22
20
  hostOf,
21
+ isConfigured,
23
22
  patchSessionBinding,
24
- readBinding,
25
23
  readBindings,
26
24
  resolveRoot,
27
- runShareFlow,
28
25
  runUnshareFlow,
29
- serverEntries,
26
+ shareWithConsent,
30
27
  verifyShare,
31
- workerDeleteShare,
32
28
  } from "./share-flow.js"
33
29
  import { onboardingDialog, settingsDialog } from "./settings.js"
34
30
  import { dbgLog } from "./sidebar.js"
@@ -51,30 +47,28 @@ function finish(host: ShareHostApi, result: { ok: boolean; message: string; url?
51
47
  })
52
48
  }
53
49
 
54
- /** The already-shared state: per-server management for the fan-out era.
55
- * Single binding keeps the compact classic menu; multiple bindings get a
56
- * per-server submenu (copy / stop here / leave as-is) plus all-stop.
57
- * Opens with a liveness sweep: bindings whose page died server-side
58
- * (purge/TTL/delete) are auto-cleared and surfaced in a gone modal. */
59
- async function showShareManager(host: ShareHostApi, sessionID: () => string | undefined) {
60
- const sid = sessionID()
61
- if (!sid) return
62
- const root = await resolveRoot(host.client, sid)
50
+ /** Shared pre-menu pass: collision check + liveness sweep. Bindings whose
51
+ * page died server-side (purge/TTL/delete) are auto-cleared and surfaced
52
+ * in a gone modal offering a re-share. Returns null when a dialog flow
53
+ * took over (collision / re-share) and already finished; otherwise the
54
+ * surviving bindings and whether any gone modal was shown. */
55
+ async function sweepBindings(host: ShareHostApi, sid: string, root: string) {
63
56
  let all = await readBindings(host.client, root)
57
+ let goneShown = false
64
58
 
65
59
  // collision first: the engine marked a server's credentials as rejected
66
60
  const collided = all.find((b) => b.collided)
67
- if (collided) return showCollisionDialog(host, root, collided.server, collided.id, collided.collided ?? "")
61
+ if (collided) {
62
+ await showCollisionDialog(host, root, collided.server, collided.id, collided.collided ?? "")
63
+ return null
64
+ }
68
65
 
69
- // liveness sweep: probe every live-bound server (credential-free /data
70
- // check). GONE entries are removed from the binding automatically; the
71
- // modal says what happened and offers to re-share. Network errors are
72
- // "unknown" and never treated as gone.
73
66
  const live = all.filter((b) => b.url && b.enabled !== false && b.id)
74
67
  if (live.length) {
75
68
  const verdicts = await Promise.all(live.map((b) => verifyShare(b.server, b.id)))
76
69
  const gone = live.filter((b, i) => verdicts[i] === "gone")
77
70
  if (gone.length) {
71
+ goneShown = true
78
72
  await clearGoneBindingEntries(host, root, gone.map((b) => b.server))
79
73
  all = all.filter((b) => !gone.some((g) => g.server === b.server))
80
74
  const list = gone.map((b) => `• ${hostOf(b.url || b.server)} — ${b.url}`).join("\n")
@@ -87,235 +81,15 @@ async function showShareManager(host: ShareHostApi, sessionID: () => string | un
87
81
  ],
88
82
  })
89
83
  if (choice === "again") {
90
- finish(host, await runShareFlow(host, sid))
91
- return
84
+ finish(host, await shareWithConsent(host, sid))
85
+ return null
92
86
  }
93
- if (all.length === 0) return
94
- }
95
- }
96
-
97
- if (all.length === 0) return
98
-
99
- const otherServers = serverEntries().filter((s) => !all.some((b) => b.server === s.url))
100
-
101
- if (all.length === 1 && otherServers.length === 0) {
102
- // the classic single-server menu
103
- const b = all[0]
104
- void host.ui
105
- .showMenu<string>({
106
- title: "Session is shared",
107
- hint: b.url,
108
- options: [
109
- {
110
- title: "Copy share link",
111
- value: "copy",
112
- description: "copy the share URL to the clipboard",
113
- help: `Copies the share URL to the clipboard (navigator + OSC52 fallback).\n\nURL: ${b.url}\nServer: ${b.server}\nShare id: ${b.id}\n\nAnyone with the link can read the page (plus the login, if read auth is set).`,
114
- },
115
- {
116
- title: "Stop sharing",
117
- value: "stop",
118
- description: "Delete the share page — the link stops working for everyone",
119
- help: `Deletes the page on ${hostOf(b.url || b.server)} server-side and clears this session's share flag. Viewers with the link see a final "gone" state. This cannot be undone — sharing again creates a NEW page with a new URL.`,
120
- },
121
- { title: "Cancel", value: "cancel" },
122
- ],
123
- })
124
- .then(async (choice) => {
125
- if (choice === "copy") {
126
- const ok = await host.clipboard.write(b.url)
127
- finish(host, ok ? { ok: true, message: `Share URL copied to clipboard: ${b.url}`, url: b.url } : { ok: false, message: `Could not write the clipboard — link: ${b.url}`, url: b.url })
128
- return
129
- }
130
- if (choice === "stop") {
131
- const how = await host.ui.showMenu<string>({
132
- title: "Stop sharing",
133
- hint: b.url,
134
- options: [
135
- {
136
- title: "Delete the page",
137
- value: "delete",
138
- description: "the link stops working for everyone",
139
- danger: true,
140
- help: `Deletes the page on ${hostOf(b.url || b.server)} and clears this session's share flag. Viewers with the link see a final "gone" state. Sharing again later creates a fresh page with a new URL.`,
141
- },
142
- {
143
- title: "Leave as-is (snapshot)",
144
- value: "leave",
145
- description: "stop updating; the page stays until its TTL",
146
- help: `Stops mirroring WITHOUT deleting anything — viewers keep reading the snapshot until the share's TTL expires. Useful for handing someone a frozen copy while you keep working.`,
147
- },
148
- { title: "Cancel", value: "cancel" },
149
- ],
150
- })
151
- if (!how || how === "cancel") return
152
- if (how === "leave") {
153
- await patchSessionBinding(host.client, root, (servers) => {
154
- delete servers[b.server]
155
- servers[b.server] = { optedOut: true }
156
- })
157
- finish(host, { ok: true, message: `${hostOf(b.url || b.server)} left as-is — a snapshot until its TTL.` })
158
- return
159
- }
160
- const yes = await host.ui.showConfirm({
161
- title: "Delete the share page",
162
- message: `Delete the page on ${hostOf(b.url || b.server)}?\nThe link stops working for everyone.`,
163
- confirmLabel: "Delete",
164
- danger: true,
165
- })
166
- if (!yes) {
167
- host.ui.dialog.clear()
168
- return
169
- }
170
- finish(host, await runUnshareFlow(host, sessionID()))
171
- }
172
- })
173
- return
174
- }
175
-
176
- // fan-out menu
177
- const options: Array<{ title: string; value: string; description?: string; help?: string; danger?: boolean }> = all.map((b) => ({
178
- title: `Copy link — ${hostOf(b.url || b.server)}`,
179
- value: `copy:${b.server}`,
180
- description: b.url,
181
- help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nServer: ${b.server}\nShare id: ${b.id}\n\nEach server carries an independent page of the same session.`,
182
- }))
183
- options.push({
184
- title: "Manage per server…",
185
- value: "manage",
186
- description: `${all.length} servers — stop one, leave a snapshot`,
187
- help: `Per-server actions: copy the link, stop sharing there (page deleted), or leave it as a read-only snapshot (updates stop, the page lives out its TTL). Other servers are unaffected.\n\nBound servers:\n${all.map((b) => `• ${b.server} — ${b.url}`).join("\n")}`,
188
- })
189
- if (otherServers.length) {
190
- options.push({
191
- title: "Share to another server…",
192
- value: "more",
193
- description: otherServers.map((s) => hostOf(s.url)).join(", "),
194
- help: `Adds one of your other configured servers as a destination for THIS session: it becomes the active server and the full history is mirrored there within seconds.\n\nAvailable:\n${otherServers.map((s) => `• ${s.url}`).join("\n")}`,
195
- })
196
- }
197
- options.push({
198
- title: "Stop sharing (all)",
199
- value: "stop-all",
200
- description: "delete every server's page, or leave snapshots",
201
- danger: true,
202
- help: `Deletes the page on all ${all.length} servers (or leaves them as read-only snapshots — your choice on the next screen) and clears the share flag. Sharing again creates fresh pages.`,
203
- })
204
- options.push({ title: "Cancel", value: "cancel" })
205
- const choice = await host.ui.showMenu<string>({ title: `Session is shared · ${all.length} servers`, options })
206
- if (!choice || choice === "cancel") return
207
- if (choice.startsWith("copy:")) {
208
- const b = all.find((x) => `copy:${x.server}` === choice)
209
- if (b) {
210
- const ok = await host.clipboard.write(b.url)
211
- finish(host, ok ? { ok: true, message: `Share URL copied: ${b.url}`, url: b.url } : { ok: false, message: `Clipboard failed — ${b.url}`, url: b.url })
212
- }
213
- return
214
- }
215
- if (choice === "stop-all") {
216
- const how = await host.ui.showMenu<string>({
217
- title: `Stop sharing · ${all.length} servers`,
218
- options: [
219
- { title: "Delete every page", value: "delete", description: "all links stop working", danger: true, help: `Deletes the page on every bound server (${all.map((b) => hostOf(b.url || b.server)).join(", ")}) and clears the share flag.` },
220
- { title: "Leave all as-is (snapshots)", value: "leave", description: "stop updating; pages stay until their TTLs", help: `Stops mirroring everywhere without deleting anything — every page stays readable as a frozen snapshot until its TTL expires.` },
221
- { title: "Cancel", value: "cancel" },
222
- ],
223
- })
224
- if (!how || how === "cancel") return
225
- if (how === "leave") {
226
- await patchSessionBinding(host.client, root, (servers) => {
227
- for (const b of all) {
228
- delete servers[b.server]
229
- servers[b.server] = { optedOut: true }
230
- }
231
- })
232
- finish(host, { ok: true, message: `${all.length} server(s) left as-is — snapshots until their TTLs.` })
233
- return
234
- }
235
- const yes = await host.ui.showConfirm({
236
- title: "Stop sharing",
237
- message: `Delete the share pages on all ${all.length} servers? The links stop working for everyone.`,
238
- confirmLabel: "Stop all",
239
- danger: true,
240
- })
241
- if (!yes) return
242
- finish(host, await runUnshareFlow(host, sessionID()))
243
- return
244
- }
245
- if (choice === "more") {
246
- const pick = await host.ui.showMenu<string>({
247
- title: "Share to another server",
248
- options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
249
- })
250
- if (!pick || pick === "__cancel") return
251
- const { saveServerEntry } = await import("./share-flow.js")
252
- for (const s of serverEntries()) if (s.url !== pick) saveServerEntry(s.url, { active: undefined })
253
- saveServerEntry(pick, { active: true })
254
- finish(host, { ok: true, message: `${hostOf(pick)} activated — this session mirrors there within seconds (new sessions share to it too).` })
255
- return
256
- }
257
- if (choice === "manage") {
258
- const pick = await host.ui.showMenu<string>({
259
- title: "Manage server",
260
- options: [...all.map((b) => ({ title: hostOf(b.url || b.server), value: b.server, description: b.url })), { title: "Cancel", value: "__cancel" }],
261
- })
262
- if (!pick || pick === "__cancel") return
263
- const b = all.find((x) => x.server === pick)
264
- if (!b) return
265
- const action = await host.ui.showMenu<string>({
266
- title: `${hostOf(b.url || b.server)}`,
267
- hint: b.url,
268
- options: [
269
- {
270
- title: "Copy link",
271
- value: "copy",
272
- description: "copy the share URL",
273
- help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id}`,
274
- },
275
- {
276
- title: "Stop sharing here",
277
- value: "stop",
278
- description: "delete THIS server's page; other servers unaffected",
279
- help: `Deletes the page on ${hostOf(b.url || b.server)} and retires this server for the session (the engine stops mirroring here). Other servers keep streaming. Sharing here again later creates a new page.`,
280
- },
281
- {
282
- title: "Leave as-is (snapshot)",
283
- value: "leave",
284
- description: "stop updating it; the page stays until its TTL",
285
- help: `Stops mirroring to ${hostOf(b.url || b.server)} WITHOUT deleting anything — viewers keep reading the snapshot until the share's TTL expires. Useful for handing someone a frozen copy while you keep working.`,
286
- },
287
- { title: "Cancel", value: "cancel" },
288
- ],
289
- })
290
- if (!action || action === "cancel") return
291
- if (action === "copy") {
292
- const ok = await host.clipboard.write(b.url)
293
- finish(host, ok ? { ok: true, message: `Share URL copied: ${b.url}`, url: b.url } : { ok: false, message: `Clipboard failed — ${b.url}`, url: b.url })
294
- return
295
- }
296
- if (action === "stop") {
297
- const deleted = await workerDeleteShare(b.server || b.url.slice(0, b.url.indexOf("/s/")), b.id, b.secret)
298
- await patchSessionBinding(host.client, root, (servers) => {
299
- delete servers[b.server]
300
- servers[b.server] = { optedOut: true }
301
- })
302
- finish(host, { ok: true, message: deleted ? `Stopped sharing on ${hostOf(b.url || b.server)} — page deleted.` : `Stopped sharing on ${hostOf(b.url || b.server)} (page deletion follows via the sweeper).` })
303
- return
304
- }
305
- if (action === "leave") {
306
- await patchSessionBinding(host.client, root, (servers) => {
307
- delete servers[b.server]
308
- servers[b.server] = { optedOut: true }
309
- })
310
- finish(host, { ok: true, message: `${hostOf(b.url || b.server)} left as-is — a snapshot until its TTL.` })
311
- return
87
+ if (all.length === 0) return { all, goneShown }
312
88
  }
313
89
  }
90
+ return { all, goneShown }
314
91
  }
315
92
 
316
- /** Credential collision: the engine refused to auto-recreate. The user
317
- * chooses: copy debug info (for the hoster/issue), share on a new share,
318
- * or don't share. */
319
93
  async function showCollisionDialog(host: ShareHostApi, root: string, server: string, shareId: string, error: string) {
320
94
  const debugInfo = JSON.stringify({ server, shareId, error, sessionID: root, plugin: "openshare", time: new Date().toISOString() }, null, 2)
321
95
  const choice = await host.ui.showMenu<string>({
@@ -388,41 +162,28 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
388
162
  return
389
163
  }
390
164
  const root = await resolveRoot(host.client, sid)
391
- const bindings = await readBindings(host.client, root)
392
- const binding = bindings[0]
393
- dbgLog(`/share gate: sid=${sid} root=${root} bindings=${bindings.length}`)
165
+ // collision + gone sweep, then the gate. null = a dialog flow took
166
+ // over (collision / gone re-share) and already finished.
167
+ const swept = await sweepBindings(host, sid, root)
168
+ if (swept === null) return
169
+ const { all, goneShown } = swept
394
170
  // live ⇔ url && enabled !== false (all-mode bindings carry
395
- // enabled:true since the fix; legacy all-mode bindings without the
396
- // field are live too — only an explicit false is dormant)
397
- if (bindings.some((b) => b.url && b.enabled !== false)) {
398
- await showShareManager(host, sessionID)
399
- host.ui.dialog.clear()
400
- return
401
- }
402
- // not shared (or dormant): the normal flow — consent once, then share
403
- const result = await runShareFlow(host, sid)
404
- if (result.message === "consent required") {
405
- const yes = await host.ui.showConfirm({
406
- title: "Share Session",
407
- message: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
408
- confirmLabel: "Share",
409
- })
410
- if (!yes) return
411
- try {
412
- host.kv.set(CONSENT_KV_KEY, true)
413
- dbgLog("consent kv.set ok")
414
- } catch (e) {
415
- dbgLog(`consent kv.set THREW: ${(e as Error)?.message ?? e}`)
416
- }
417
- try {
418
- finish(host, await confirmShare(host, root))
419
- } catch (e) {
420
- dbgLog(`confirmShare rejected: ${(e as Error)?.stack ?? e}`)
421
- finish(host, { ok: false, message: "Share failed (see debug log)." })
422
- }
171
+ // enabled:true; legacy all-mode bindings without the field are live
172
+ // too — only an explicit false is dormant)
173
+ if (all.some((b) => b.url && b.enabled !== false)) {
174
+ // THE session menu — identical to /openshare → This session by
175
+ // construction (one surface, no duplicated manager). No
176
+ // dialog.clear() after: settings owns its chain end-to-end (the
177
+ // old fire-and-forget menu + immediate clear wiped it instantly —
178
+ // live-caught as "/share did nothing" on a shared session).
179
+ await settingsDialog(host, sid)
423
180
  return
424
181
  }
425
- finish(host, result)
182
+ // everything was just cleared by the gone modal and the user chose
183
+ // Close — leaving it unshared is the answer, do not offer a share
184
+ if (goneShown && all.length === 0) return
185
+ // not shared (or dormant): explicit consent, every time
186
+ finish(host, await shareWithConsent(host, sid))
426
187
  },
427
188
  }
428
189
  }