@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/src/dialogs.tsx
CHANGED
|
@@ -576,6 +576,32 @@ export function MenuDialog<Value>(props: {
|
|
|
576
576
|
)
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
+
/** Guard against stacked plugin dialogs. `dialog.replace` is documented as
|
|
580
|
+
* an atomic swap, but on some host builds a second replace while a dialog is
|
|
581
|
+
* still pending leaves BOTH renderers mounted - the reader sees two menus
|
|
582
|
+
* with two highlighted rows and keys go to a stale layer ("/share does
|
|
583
|
+
* nothing"). Cancelling the pending dialog and clearing before the next
|
|
584
|
+
* replace keeps exactly one dialog alive. The normal single-dialog path is
|
|
585
|
+
* untouched, so the esc-to-parent flicker fix stays intact. */
|
|
586
|
+
let pendingDialog: (() => void) | undefined
|
|
587
|
+
function claimDialog(api: TuiPluginApi): () => void {
|
|
588
|
+
if (pendingDialog) {
|
|
589
|
+
try {
|
|
590
|
+
pendingDialog()
|
|
591
|
+
} catch {}
|
|
592
|
+
try {
|
|
593
|
+
api.ui.dialog.clear()
|
|
594
|
+
} catch {}
|
|
595
|
+
}
|
|
596
|
+
let released = false
|
|
597
|
+
const release = () => {
|
|
598
|
+
if (released) return
|
|
599
|
+
released = true
|
|
600
|
+
if (pendingDialog === release) pendingDialog = undefined
|
|
601
|
+
}
|
|
602
|
+
pendingDialog = release
|
|
603
|
+
return release
|
|
604
|
+
}
|
|
579
605
|
/** showMenuOnce, family discipline with the flicker fix (2026-10-19):
|
|
580
606
|
* done() does NOT clear the host stack — the next dialog's replace() is an
|
|
581
607
|
* atomic swap, so clearing first paints one empty frame (the esc-to-parent
|
|
@@ -584,9 +610,11 @@ export function MenuDialog<Value>(props: {
|
|
|
584
610
|
export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
|
|
585
611
|
return new Promise((resolve) => {
|
|
586
612
|
let settled = false
|
|
613
|
+
const release = claimDialog(api)
|
|
587
614
|
const done = (value: Value | undefined, _clear = false) => {
|
|
588
615
|
if (settled) return
|
|
589
616
|
settled = true
|
|
617
|
+
release()
|
|
590
618
|
dbgLog(`menu(${props.title}) -> ${value === undefined ? "cancel" : String(value)}`)
|
|
591
619
|
resolve(value)
|
|
592
620
|
}
|
|
@@ -610,9 +638,11 @@ export type DialogAction = { title: string; value: string; key?: string; danger?
|
|
|
610
638
|
export function showInfoDialog(api: TuiPluginApi, props: { title: string; message: string; actions?: DialogAction[] }): Promise<string | undefined> {
|
|
611
639
|
return new Promise((resolve) => {
|
|
612
640
|
let settled = false
|
|
641
|
+
const release = claimDialog(api)
|
|
613
642
|
const done = (value: string | undefined) => {
|
|
614
643
|
if (settled) return
|
|
615
644
|
settled = true
|
|
645
|
+
release()
|
|
616
646
|
resolve(value)
|
|
617
647
|
}
|
|
618
648
|
api.ui.dialog.replace(
|
|
@@ -703,8 +733,17 @@ function InfoDialog(props: { api: TuiPluginApi; title: string; message: string;
|
|
|
703
733
|
<box flexDirection="row" gap={1}>
|
|
704
734
|
<For each={actions()}>
|
|
705
735
|
{(action) => (
|
|
706
|
-
<box
|
|
707
|
-
|
|
736
|
+
<box
|
|
737
|
+
paddingLeft={2}
|
|
738
|
+
paddingRight={2}
|
|
739
|
+
backgroundColor={action.key === "enter" ? (action.danger ? theme().error : theme().primary) : undefined}
|
|
740
|
+
border={action.key === "enter" ? undefined : true}
|
|
741
|
+
borderColor={action.danger ? theme().error : theme().textMuted}
|
|
742
|
+
onMouseUp={() => props.onDone(action.value)}
|
|
743
|
+
>
|
|
744
|
+
<text fg={action.key === "enter" ? theme().background : action.danger ? theme().error : theme().textMuted}>
|
|
745
|
+
<b>{action.title}</b>
|
|
746
|
+
</text>
|
|
708
747
|
</box>
|
|
709
748
|
)}
|
|
710
749
|
</For>
|
|
@@ -799,9 +838,11 @@ export function PromptDialog(props: {
|
|
|
799
838
|
export function showPromptOnce(api: TuiPluginApi, props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined> {
|
|
800
839
|
return new Promise((resolve) => {
|
|
801
840
|
let settled = false
|
|
841
|
+
const release = claimDialog(api)
|
|
802
842
|
const done = (value: string | undefined, _clear = false) => {
|
|
803
843
|
if (settled) return
|
|
804
844
|
settled = true
|
|
845
|
+
release()
|
|
805
846
|
dbgLog(`prompt(${props.title}) -> ${value === undefined ? "cancel" : `len=${value.length}`}`)
|
|
806
847
|
resolve(value)
|
|
807
848
|
}
|
|
@@ -829,9 +870,11 @@ export type SizePickerResult = { width: DialogWidth; heightPercent: number }
|
|
|
829
870
|
export function showSizePickerOnce(api: TuiPluginApi, current: SizePickerResult): Promise<SizePickerResult | undefined> {
|
|
830
871
|
return new Promise((resolve) => {
|
|
831
872
|
let settled = false
|
|
873
|
+
const release = claimDialog(api)
|
|
832
874
|
const done = (value: SizePickerResult | undefined) => {
|
|
833
875
|
if (settled) return
|
|
834
876
|
settled = true
|
|
877
|
+
release()
|
|
835
878
|
resolve(value)
|
|
836
879
|
}
|
|
837
880
|
api.ui.dialog.replace(
|
package/src/settings.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
saveServerEntry,
|
|
31
31
|
serverEntries,
|
|
32
32
|
setShareFlag,
|
|
33
|
+
shareWithConsent,
|
|
34
|
+
workerDeleteShare,
|
|
33
35
|
type AdminShare,
|
|
34
36
|
type AdminUser,
|
|
35
37
|
} from "./share-flow.js"
|
|
@@ -917,26 +919,68 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
917
919
|
|
|
918
920
|
const sessionRows = async (): Promise<ShareDialogSelectOption<string>[]> => {
|
|
919
921
|
if (!startAtSession) return []
|
|
920
|
-
const
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
922
|
+
const root = await resolveRoot(host.client, startAtSession)
|
|
923
|
+
const bindings = await readBindings(host.client, root)
|
|
924
|
+
const live = bindings.filter((b) => b.url && b.enabled !== false)
|
|
925
|
+
const rows: ShareDialogSelectOption<string>[] = []
|
|
926
|
+
if (live.length === 0) {
|
|
927
|
+
rows.push({
|
|
928
|
+
title: "Share this session",
|
|
924
929
|
value: "session:share",
|
|
925
|
-
description:
|
|
926
|
-
help:
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
930
|
+
description: "set the share flag; the server plugin creates the page",
|
|
931
|
+
help: "Publishes this session (and its subagent sessions) to the active server. The full history lands in the page — sharing mid-session is first-class, nothing is missed. You will be asked to confirm before anything is published.",
|
|
932
|
+
})
|
|
933
|
+
} else if (live.length === 1) {
|
|
934
|
+
const b = live[0]
|
|
935
|
+
rows.push({
|
|
936
|
+
title: "Copy share link",
|
|
937
|
+
value: "session:copy",
|
|
938
|
+
description: "copy the share URL to the clipboard",
|
|
939
|
+
help: `Copies the share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id ?? "?"}`,
|
|
940
|
+
})
|
|
941
|
+
} else {
|
|
942
|
+
for (const b of live) {
|
|
943
|
+
rows.push({
|
|
944
|
+
title: `Copy link — ${hostOf(b.url || b.server)}`,
|
|
945
|
+
value: `session:copy:${b.server}`,
|
|
946
|
+
description: b.url,
|
|
947
|
+
help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id}\n\nEach server carries an independent page of the same session.`,
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
rows.push({
|
|
951
|
+
title: "Copy all links",
|
|
952
|
+
value: "session:copyall",
|
|
953
|
+
description: `${live.length} servers`,
|
|
954
|
+
help: `Copies every bound server's share URL.\n\n${live.map((b) => `• ${b.url}`).join("\n")}`,
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
if (live.length) {
|
|
932
958
|
rows.push({
|
|
933
959
|
title: "Stop sharing",
|
|
934
960
|
value: "session:unshare",
|
|
935
|
-
description:
|
|
961
|
+
description: live.length > 1 ? `delete every page (${live.length} servers), or leave snapshots` : "delete the page, or leave a snapshot",
|
|
936
962
|
danger: true,
|
|
937
|
-
help: `Choose per run: DELETE every page server-side (links stop working — final "gone" state), or LEAVE AS-IS (stop updating; pages stay readable until their TTL — a frozen snapshot).\n\nURLs:\n${
|
|
963
|
+
help: `Choose per run: DELETE every page server-side (links stop working — final "gone" state), or LEAVE AS-IS (stop updating; pages stay readable until their TTL — a frozen snapshot).\n\nURLs:\n${live.map((b) => b.url).join("\n")}\n\nSharing again later always creates fresh page(s) with new URLs.`,
|
|
938
964
|
})
|
|
965
|
+
if (live.length > 1) {
|
|
966
|
+
rows.push({
|
|
967
|
+
title: "Stop on one server…",
|
|
968
|
+
value: "session:unshare:one",
|
|
969
|
+
description: "delete or snapshot a single server's page",
|
|
970
|
+
help: `Per-server stop: pick the server, then delete its page or leave it as a read-only snapshot. Other servers are unaffected.\n\nBound servers:\n${live.map((b) => `• ${b.server} — ${b.url}`).join("\n")}`,
|
|
971
|
+
})
|
|
972
|
+
const otherServers = serverEntries().filter((s) => !live.some((b) => b.server === s.url))
|
|
973
|
+
if (otherServers.length) {
|
|
974
|
+
rows.push({
|
|
975
|
+
title: "Share to another server…",
|
|
976
|
+
value: "session:more",
|
|
977
|
+
description: otherServers.map((s) => hostOf(s.url)).join(", "),
|
|
978
|
+
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")}`,
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
939
982
|
}
|
|
983
|
+
const binding = bindings[0]
|
|
940
984
|
rows.push({
|
|
941
985
|
title: "Session TTL override (days)",
|
|
942
986
|
value: "session:ttl",
|
|
@@ -1054,16 +1098,90 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1054
1098
|
|
|
1055
1099
|
if (choice.startsWith("session:") && startAtSession) {
|
|
1056
1100
|
const root = await resolveRoot(host.client, startAtSession)
|
|
1057
|
-
const
|
|
1101
|
+
const all = await readBindings(host.client, root)
|
|
1102
|
+
const live = all.filter((b) => b.url && b.enabled !== false)
|
|
1103
|
+
const current = all[0]
|
|
1058
1104
|
if (choice === "session:share") {
|
|
1059
1105
|
if (!isConfigured(cfg)) {
|
|
1060
1106
|
await showInfo(host.ui, { title: "Not configured", message: "Connect a server (Servers group) first, then share." })
|
|
1061
1107
|
return
|
|
1062
1108
|
}
|
|
1063
|
-
const result = await
|
|
1109
|
+
const result = await shareWithConsent(host, startAtSession)
|
|
1064
1110
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message, duration: result.ok ? 8000 : 15000 })
|
|
1065
1111
|
return
|
|
1066
1112
|
}
|
|
1113
|
+
if (choice === "session:copy" && live[0]) {
|
|
1114
|
+
const okw = await host.clipboard.write(live[0].url!)
|
|
1115
|
+
host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `Share URL copied: ${live[0].url}` : `Clipboard failed — ${live[0].url}` })
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
if (choice.startsWith("session:copy:") ) {
|
|
1119
|
+
const b = live.find((x) => `session:copy:${x.server}` === choice)
|
|
1120
|
+
if (b) {
|
|
1121
|
+
const okw = await host.clipboard.write(b.url!)
|
|
1122
|
+
host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `Share URL copied: ${b.url}` : `Clipboard failed — ${b.url}` })
|
|
1123
|
+
}
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
if (choice === "session:copyall") {
|
|
1127
|
+
const joined = live.map((b) => b.url).filter(Boolean).join("\n")
|
|
1128
|
+
const okw = await host.clipboard.write(joined)
|
|
1129
|
+
host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `${live.length} share URL(s) copied` : "Clipboard failed" })
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
if (choice === "session:unshare:one") {
|
|
1133
|
+
const pick = await showSelect(host.ui, {
|
|
1134
|
+
title: "Stop on which server",
|
|
1135
|
+
options: [...live.map((b) => ({ title: hostOf(b.url || b.server), value: b.server, description: b.url })), { title: "Cancel", value: "__cancel" }],
|
|
1136
|
+
})
|
|
1137
|
+
if (!pick || pick === "__cancel") return
|
|
1138
|
+
const target = live.find((b) => b.server === pick)
|
|
1139
|
+
if (!target) return
|
|
1140
|
+
const how = await showSelect(host.ui, {
|
|
1141
|
+
title: `Stop sharing — ${hostOf(target.url || target.server)}`,
|
|
1142
|
+
options: [
|
|
1143
|
+
{ title: "Delete the page", value: "delete", description: "this link stops working for everyone", danger: true },
|
|
1144
|
+
{ title: "Leave as-is (snapshot)", value: "leave", description: "stop updating; the page stays until its TTL" },
|
|
1145
|
+
{ title: "Cancel", value: "__cancel" },
|
|
1146
|
+
],
|
|
1147
|
+
})
|
|
1148
|
+
if (!how || how === "__cancel") return
|
|
1149
|
+
if (how === "leave") {
|
|
1150
|
+
await patchSessionBinding(host.client, root, (servers) => {
|
|
1151
|
+
delete servers[target.server]
|
|
1152
|
+
servers[target.server] = { optedOut: true }
|
|
1153
|
+
})
|
|
1154
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(target.url || target.server)} left as-is — a snapshot until its TTL.` })
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
const targetEntry = all.find((b) => b.server === target.server)
|
|
1158
|
+
if (!targetEntry?.secret) {
|
|
1159
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "No secret for this page — cannot delete it server-side; binding cleared." })
|
|
1160
|
+
await patchSessionBinding(host.client, root, (servers) => {
|
|
1161
|
+
delete servers[target.server]
|
|
1162
|
+
})
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
const gone = await workerDeleteShare(target.server, target.id!, targetEntry.secret)
|
|
1166
|
+
await patchSessionBinding(host.client, root, (servers) => {
|
|
1167
|
+
delete servers[target.server]
|
|
1168
|
+
})
|
|
1169
|
+
host.ui.toast({ variant: gone ? "success" : "warning", title: "OpenShare", message: gone ? `Page deleted on ${hostOf(target.url || target.server)}.` : `Could not delete the page (it may already be gone) — binding cleared.` })
|
|
1170
|
+
return
|
|
1171
|
+
}
|
|
1172
|
+
if (choice === "session:more") {
|
|
1173
|
+
const otherServers = serverEntries().filter((s) => !live.some((b) => b.server === s.url))
|
|
1174
|
+
if (!otherServers.length) return
|
|
1175
|
+
const pick = await showSelect(host.ui, {
|
|
1176
|
+
title: "Share to another server",
|
|
1177
|
+
options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
|
|
1178
|
+
})
|
|
1179
|
+
if (!pick || pick === "__cancel") return
|
|
1180
|
+
for (const s of serverEntries()) if (s.url !== pick) saveServerEntry(s.url, { active: undefined })
|
|
1181
|
+
saveServerEntry(pick, { active: true })
|
|
1182
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` })
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1067
1185
|
if (choice === "session:unshare") {
|
|
1068
1186
|
const how = await showSelect(host.ui, {
|
|
1069
1187
|
title: "Stop sharing",
|
package/src/share-flow.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
18
18
|
import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY, type ShareConfig, type ServerEntry } from "./config.js"
|
|
19
19
|
import { dbg, dbgLog } from "./sidebar.js"
|
|
20
20
|
import type { ClientApi, SessionInfo, SessionOpts } from "./core.js"
|
|
21
|
+
import { queueBindingWrite } from "./binding-queue.js"
|
|
21
22
|
import type { ShareHostApi } from "./tui-host.js"
|
|
22
23
|
|
|
23
24
|
export type SessionBinding = {
|
|
@@ -29,8 +30,6 @@ export type SessionBinding = {
|
|
|
29
30
|
opts?: SessionOpts
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export const CONSENT_KV_KEY = "openshare_consent"
|
|
33
|
-
|
|
34
33
|
export function pluginConfigPaths(baseDir: string = moduleDir()): string[] {
|
|
35
34
|
return configPaths(baseDir)
|
|
36
35
|
}
|
|
@@ -174,18 +173,23 @@ export async function patchSessionBinding(
|
|
|
174
173
|
sid: string,
|
|
175
174
|
mutator: (servers: Record<string, unknown>, binding: Record<string, unknown>) => void,
|
|
176
175
|
): Promise<boolean> {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
176
|
+
// the whole read-modify-write runs inside the shared per-session queue:
|
|
177
|
+
// the engine patches the same metadata (binding refresh) and interleaved
|
|
178
|
+
// RMWs silently lose one side's update (see binding-queue.ts)
|
|
179
|
+
return queueBindingWrite(sid, async () => {
|
|
180
|
+
const info = await getSession(client, sid)
|
|
181
|
+
if (!info) return false
|
|
182
|
+
const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
|
|
183
|
+
const raw = info.metadata?.[METADATA_KEY]
|
|
184
|
+
const binding: Record<string, unknown> =
|
|
185
|
+
raw && typeof raw === "object" ? { ...(raw as Record<string, unknown>) } : { v: 2, servers: {} }
|
|
186
|
+
const servers: Record<string, unknown> =
|
|
187
|
+
binding.servers && typeof binding.servers === "object" ? { ...(binding.servers as Record<string, unknown>) } : {}
|
|
188
|
+
mutator(servers, binding)
|
|
189
|
+
binding.servers = servers
|
|
190
|
+
metadata[METADATA_KEY] = binding
|
|
191
|
+
return patchMetadata(client, sid, metadata)
|
|
192
|
+
})
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
/** The display/primary binding: the active server's entry when bound
|
|
@@ -482,7 +486,20 @@ export async function waitForBinding(
|
|
|
482
486
|
|
|
483
487
|
export type FlowResult = { ok: boolean; message: string; url?: string; unconfigured?: boolean }
|
|
484
488
|
|
|
485
|
-
|
|
489
|
+
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
490
|
+
* directive — no remembered consent, no silent creates). */
|
|
491
|
+
export async function requestShareConsent(host: ShareHostApi): Promise<boolean> {
|
|
492
|
+
return host.ui.showConfirm({
|
|
493
|
+
title: "Share Session",
|
|
494
|
+
message: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
|
|
495
|
+
confirmLabel: "Share",
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** runShareFlow with the consent gate applied: share copies the link,
|
|
500
|
+
* unshared asks consent once, then creates. Every create path routes
|
|
501
|
+
* through here (/share, settings, gone-again). */
|
|
502
|
+
export async function shareWithConsent(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult> {
|
|
486
503
|
if (!sessionID) return { ok: false, message: "No active session to share." }
|
|
487
504
|
const cfg = currentConfig()
|
|
488
505
|
if (!isConfigured(cfg)) {
|
|
@@ -495,10 +512,7 @@ export async function runShareFlow(host: ShareHostApi, sessionID: string | undef
|
|
|
495
512
|
await host.clipboard.write(existing.url)
|
|
496
513
|
return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url }
|
|
497
514
|
}
|
|
498
|
-
if (!
|
|
499
|
-
// caller shows the consent dialog before invoking confirmShare()
|
|
500
|
-
return { ok: false, message: "consent required" }
|
|
501
|
-
}
|
|
515
|
+
if (!(await requestShareConsent(host))) return { ok: false, message: "Share cancelled." }
|
|
502
516
|
return confirmShare(host, root)
|
|
503
517
|
}
|
|
504
518
|
|
package/src/sidebar.tsx
CHANGED
|
@@ -16,24 +16,11 @@
|
|
|
16
16
|
// proven consumer returns box-rooted trees (opencode's own default
|
|
17
17
|
// children, magic-context's sidebar, the host's slot self-test); text
|
|
18
18
|
// nodes stay LEAVES here — string content only.
|
|
19
|
-
//
|
|
20
|
-
// TEMPORARY crash-hunt instrumentation (owner-approved 2026-10-19): every
|
|
21
|
-
// render logs its inputs (with TYPES), the computed locals, and a dump of
|
|
22
|
-
// the returned element tree to %TEMP%/openshare-tui-debug.log via
|
|
23
|
-
// synchronous appends, so a crash mid-render still leaves evidence. Any
|
|
24
|
-
// throw inside the renderer is logged with its stack, then rethrown.
|
|
25
|
-
// Remove once the live crash is cornered.
|
|
26
19
|
|
|
27
20
|
import type { JSX } from "@opentui/solid"
|
|
28
21
|
import { bindingsOf, bindingOf, verifyShare } from "./share-flow.js"
|
|
29
22
|
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
30
23
|
import type { Session } from "@opencode-ai/sdk/v2"
|
|
31
|
-
import { appendFileSync } from "node:fs"
|
|
32
|
-
import { tmpdir } from "node:os"
|
|
33
|
-
import { join } from "node:path"
|
|
34
|
-
|
|
35
|
-
const DBG_FILE = join(tmpdir(), "openshare-tui-debug.log")
|
|
36
|
-
|
|
37
24
|
/** value -> `kind:preview` so the log shows TYPES (the crash was a type bug) */
|
|
38
25
|
export function dbg(v: unknown, depth = 0): string {
|
|
39
26
|
const t = typeof v
|
|
@@ -52,30 +39,9 @@ export function dbg(v: unknown, depth = 0): string {
|
|
|
52
39
|
}
|
|
53
40
|
}
|
|
54
41
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (Array.isArray(el)) return `[${el.map((x) => dbgEl(x, depth)).join(",")}]`
|
|
59
|
-
if (typeof el === "function") return "fn-node"
|
|
60
|
-
if (depth >= 4) return "…"
|
|
61
|
-
try {
|
|
62
|
-
const rec = el as Record<string, unknown>
|
|
63
|
-
const t = dbg(rec.t ?? rec.type, 2)
|
|
64
|
-
const kids = rec.children
|
|
65
|
-
const props = Object.keys(rec).filter((k) => !["t", "type", "children"].includes(k))
|
|
66
|
-
return `<${t} ${props.join(" ")}>${kids === undefined ? "" : dbgEl(kids, depth + 1)}</${t}>`
|
|
67
|
-
} catch (e) {
|
|
68
|
-
return `el?-${dbg(e)}`
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function dbgLog(line: string): void {
|
|
73
|
-
try {
|
|
74
|
-
appendFileSync(DBG_FILE, `${new Date().toISOString()} ${line}\n`)
|
|
75
|
-
} catch {}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let renderSeq = 0
|
|
42
|
+
/** no-op: the TUI debug log file was a crash-hunt aid and must not write
|
|
43
|
+
* to temp any more (owner request 2026-10-21) */
|
|
44
|
+
export function dbgLog(_line: string): void {}
|
|
79
45
|
|
|
80
46
|
/** Share liveness cache for the chip: a binding can claim "shared" long
|
|
81
47
|
* after the page died server-side (purge/TTL/delete with no event ever
|
|
@@ -102,13 +68,10 @@ type SidebarTitleProps = {
|
|
|
102
68
|
}
|
|
103
69
|
|
|
104
70
|
export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: () => void }) {
|
|
105
|
-
dbgLog(`createSidebarSlot called; api.state=${!!api.state} api.theme=${!!api.theme} api.version=${dbg(api.app?.version)}`)
|
|
106
71
|
return {
|
|
107
72
|
slots: {
|
|
108
73
|
sidebar_title: (ctx: TuiSlotContext, props: SidebarTitleProps): JSX.Element => {
|
|
109
|
-
const seq = ++renderSeq
|
|
110
74
|
try {
|
|
111
|
-
dbgLog(`render#${seq} ctx=${dbg(ctx)} props=${dbg(props)}`)
|
|
112
75
|
const sid = typeof props?.session_id === "string" ? props.session_id : ""
|
|
113
76
|
const title = String(props?.title ?? "") || "Session"
|
|
114
77
|
const session = sid ? api.state.session.get(sid) : undefined
|
|
@@ -122,9 +85,7 @@ export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: (
|
|
|
122
85
|
const anyGone = verdicts.some((v) => v?.status === "gone")
|
|
123
86
|
const multi = liveCount > 1 ? `·${liveCount} ` : ""
|
|
124
87
|
const theme = ctx?.theme?.current ?? api.theme.current
|
|
125
|
-
|
|
126
|
-
`render#${seq} sid=${dbg(sid)} title=${dbg(title)} session=${session ? "found" : "MISSING"} binding=${binding ? `id=${dbg(binding.id)} enabled=${dbg(binding.enabled)}` : "none"} status=${dbg(status)} theme=${dbg(theme)}`,
|
|
127
|
-
)
|
|
88
|
+
|
|
128
89
|
// chip on its own line under the title (takes the blank line that
|
|
129
90
|
// separated the title block from the context blocks); clicking it
|
|
130
91
|
// copies the share link — proven pattern: onMouseDown on a box.
|
|
@@ -175,10 +136,8 @@ export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: (
|
|
|
175
136
|
) : null}
|
|
176
137
|
</box>
|
|
177
138
|
)
|
|
178
|
-
dbgLog(`render#${seq} RETURNS ${dbgEl(el)}`)
|
|
179
139
|
return el
|
|
180
140
|
} catch (error) {
|
|
181
|
-
dbgLog(`render#${seq} THREW ${(error as Error)?.stack ?? dbg(error)} — rethrowing`)
|
|
182
141
|
throw error
|
|
183
142
|
}
|
|
184
143
|
},
|