@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.1
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 +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/src/share-flow.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
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
|
-
import type
|
|
20
|
+
import { engineTargetUrls, sessionStatsFor, type ClientApi, type SessionInfo, type SessionOpts } from "./core.js"
|
|
21
21
|
import { queueBindingWrite } from "./binding-queue.js"
|
|
22
22
|
import type { ShareHostApi } from "./tui-host.js"
|
|
23
23
|
|
|
@@ -28,6 +28,12 @@ export type SessionBinding = {
|
|
|
28
28
|
url: string
|
|
29
29
|
enabled?: boolean
|
|
30
30
|
opts?: SessionOpts
|
|
31
|
+
/** per-server "leave as-is" marker: a snapshot, not a live mirror */
|
|
32
|
+
optedOut?: boolean
|
|
33
|
+
/** engine-recorded last create/attach refusal (quota, frozen, ...) */
|
|
34
|
+
lastError?: string
|
|
35
|
+
/** server policy clamped the requested TTL */
|
|
36
|
+
ttlClamped?: { requested: number; effective: number }
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export function pluginConfigPaths(baseDir: string = moduleDir()): string[] {
|
|
@@ -38,11 +44,10 @@ export function currentConfig(baseDir: string = moduleDir()): ShareConfig {
|
|
|
38
44
|
return loadConfig(baseDir)
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
/** Writes the config file. Target rule
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* keys preserved. */
|
|
47
|
+
/** Writes the config file. Target rule: the global settings home by
|
|
48
|
+
* default, or an explicit existing file chosen in the TUI, never "first
|
|
49
|
+
* existing in the read chain" (that writes TUI edits into the tracked
|
|
50
|
+
* adjacent template). $comment and foreign keys preserved. */
|
|
46
51
|
export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string = moduleDir()): { path: string } | undefined {
|
|
47
52
|
const path = target ?? configHomePath()
|
|
48
53
|
if (!path) return undefined
|
|
@@ -65,8 +70,7 @@ export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string =
|
|
|
65
70
|
"debounceMs",
|
|
66
71
|
"completedDelayMs",
|
|
67
72
|
"modelLabel",
|
|
68
|
-
"
|
|
69
|
-
"openTasksInNewTab",
|
|
73
|
+
"ttlHours",
|
|
70
74
|
"authLogin",
|
|
71
75
|
"authPassword",
|
|
72
76
|
"spoolDir",
|
|
@@ -100,8 +104,10 @@ export function serverEntries(cfg: ShareConfig = currentConfig()): Array<{ url:
|
|
|
100
104
|
if (cfg.workerUrl) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
|
|
101
105
|
if (cfg.servers) for (const [url, entry] of Object.entries(cfg.servers)) merged[url] = { ...merged[url], ...entry }
|
|
102
106
|
const urls = Object.keys(merged)
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
// EVERY explicit active is active (the engine fans out to all of them);
|
|
108
|
+
// only when none is marked does the first entry act as the default
|
|
109
|
+
const anyActive = urls.some((u) => merged[u].active)
|
|
110
|
+
return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }))
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
export { saveServerEntry, deleteServerEntry }
|
|
@@ -115,6 +121,8 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
|
|
|
115
121
|
const record = raw as Record<string, unknown>
|
|
116
122
|
const opts = (record.opts as SessionOpts | undefined) ?? undefined
|
|
117
123
|
const enabled = record.enabled === true ? true : undefined
|
|
124
|
+
const lastError = typeof record.lastError === "string" ? record.lastError : undefined
|
|
125
|
+
const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? (record.ttlClamped as { requested: number; effective: number }) : undefined
|
|
118
126
|
const out: Array<SessionBinding & { server: string; collided?: string }> = []
|
|
119
127
|
if (record.servers && typeof record.servers === "object") {
|
|
120
128
|
for (const [server, entry] of Object.entries(record.servers as Record<string, unknown>)) {
|
|
@@ -131,6 +139,9 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
|
|
|
131
139
|
opts,
|
|
132
140
|
server,
|
|
133
141
|
collided,
|
|
142
|
+
optedOut: e.optedOut === true ? true : undefined,
|
|
143
|
+
lastError,
|
|
144
|
+
ttlClamped,
|
|
134
145
|
})
|
|
135
146
|
}
|
|
136
147
|
return out
|
|
@@ -147,6 +158,8 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
|
|
|
147
158
|
opts,
|
|
148
159
|
server: "",
|
|
149
160
|
collided,
|
|
161
|
+
lastError,
|
|
162
|
+
ttlClamped,
|
|
150
163
|
})
|
|
151
164
|
}
|
|
152
165
|
return out
|
|
@@ -157,6 +170,24 @@ export async function readBindings(client: ClientApi | undefined, sid: string):
|
|
|
157
170
|
return bindingsOf(await getSession(client, sid))
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
|
|
174
|
+
* these same shapes back). */
|
|
175
|
+
export function fmtHours(hours: number): string {
|
|
176
|
+
if (!Number.isFinite(hours) || hours <= 0) return "?"
|
|
177
|
+
if (hours % 24 === 0) return `${Math.floor(hours / 24)}d`
|
|
178
|
+
return `${Math.floor(hours)}h`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
|
|
182
|
+
export function parseTtlHours(raw: string): number | undefined {
|
|
183
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "")
|
|
184
|
+
if (!m) return undefined
|
|
185
|
+
const v = Number(m[1])
|
|
186
|
+
if (!Number.isFinite(v) || v <= 0) return undefined
|
|
187
|
+
const unit = (m[2] ?? "h").toLowerCase()
|
|
188
|
+
return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v))
|
|
189
|
+
}
|
|
190
|
+
|
|
160
191
|
export function hostOf(url: string): string {
|
|
161
192
|
try {
|
|
162
193
|
return new URL(url).host
|
|
@@ -250,7 +281,8 @@ export async function readBinding(client: ClientApi | undefined, sid: string): P
|
|
|
250
281
|
|
|
251
282
|
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
252
283
|
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
253
|
-
* to manage; the flag just opts the session in.
|
|
284
|
+
* to manage; the flag just opts the session in. CONSENT ONLY: every call
|
|
285
|
+
* here means the user agreed to publish. */
|
|
254
286
|
export async function setShareFlag(
|
|
255
287
|
client: ClientApi | undefined,
|
|
256
288
|
sid: string,
|
|
@@ -270,6 +302,30 @@ export async function setShareFlag(
|
|
|
270
302
|
return patchMetadata(client, sid, metadata)
|
|
271
303
|
}
|
|
272
304
|
|
|
305
|
+
/** Persists per-session opts WITHOUT touching the enabled state. On an
|
|
306
|
+
* unshared session the opts sit dormant until a consented create consumes
|
|
307
|
+
* them; on a live share the engine picks them up and re-syncs the server
|
|
308
|
+
* (auth/TTL apply immediately). This must never publish anything - the
|
|
309
|
+
* TTL/auth editors use it, and consent is the only path to enabled. */
|
|
310
|
+
export async function setShareOpts(
|
|
311
|
+
client: ClientApi | undefined,
|
|
312
|
+
sid: string,
|
|
313
|
+
opts: SessionOpts,
|
|
314
|
+
): Promise<boolean> {
|
|
315
|
+
const info = await getSession(client, sid)
|
|
316
|
+
if (!info) return false
|
|
317
|
+
const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
|
|
318
|
+
const existing = info.metadata?.[METADATA_KEY]
|
|
319
|
+
const value: Record<string, unknown> =
|
|
320
|
+
existing && typeof existing === "object"
|
|
321
|
+
? { ...(existing as Record<string, unknown>) }
|
|
322
|
+
: { v: 2, servers: {} }
|
|
323
|
+
if (Object.keys(opts).length) value.opts = opts
|
|
324
|
+
else delete value.opts
|
|
325
|
+
metadata[METADATA_KEY] = value
|
|
326
|
+
return patchMetadata(client, sid, metadata)
|
|
327
|
+
}
|
|
328
|
+
|
|
273
329
|
export async function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean> {
|
|
274
330
|
const info = await getSession(client, sid)
|
|
275
331
|
if (!info) return false
|
|
@@ -294,6 +350,36 @@ export async function workerDeleteShare(workerUrl: string, id: string, secret: s
|
|
|
294
350
|
}
|
|
295
351
|
}
|
|
296
352
|
|
|
353
|
+
/** PATCH a live share's settings over REST (#13): immediate effect with
|
|
354
|
+
* no socket required (the engine's attach-sync remains the structural
|
|
355
|
+
* path - this is the same edit pushed now, per server). Auth sends as
|
|
356
|
+
* the both-or-none pair or not at all; ttlHours only when explicit. */
|
|
357
|
+
export async function patchShareOpts(
|
|
358
|
+
workerUrl: string,
|
|
359
|
+
id: string,
|
|
360
|
+
opts: { authLogin?: string; authPassword?: string; ttlHours?: number },
|
|
361
|
+
secret: string,
|
|
362
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
363
|
+
const body: Record<string, unknown> = { secret }
|
|
364
|
+
if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
|
|
365
|
+
body.authLogin = opts.authLogin
|
|
366
|
+
body.authPassword = opts.authPassword
|
|
367
|
+
}
|
|
368
|
+
if (opts.ttlHours !== undefined) body.ttlHours = opts.ttlHours
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
371
|
+
method: "PATCH",
|
|
372
|
+
headers: { "content-type": "application/json" },
|
|
373
|
+
body: JSON.stringify(body),
|
|
374
|
+
signal: AbortSignal.timeout(8000),
|
|
375
|
+
})
|
|
376
|
+
const json = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
|
377
|
+
return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` }
|
|
378
|
+
} catch (e) {
|
|
379
|
+
return { ok: false, error: (e as Error)?.message || String(e) }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
297
383
|
/** Health probe for the settings dialog's connection test. */
|
|
298
384
|
export async function probeWorker(workerUrl: string): Promise<{ ok: boolean; detail: string }> {
|
|
299
385
|
if (!workerUrl) return { ok: false, detail: "workerUrl is empty" }
|
|
@@ -456,6 +542,31 @@ export function adminClient(workerUrl: string, token: string) {
|
|
|
456
542
|
}
|
|
457
543
|
}
|
|
458
544
|
|
|
545
|
+
/** Self-service client for the /api/me surface (the entry's user osk_
|
|
546
|
+
* token - NOT the root token). Answers "what are my limits, how many
|
|
547
|
+
* shares do I have, when does my token expire". */
|
|
548
|
+
export function meClient(workerUrl: string, token: string) {
|
|
549
|
+
const base = normServerUrl(workerUrl)
|
|
550
|
+
const call = async <T>(method: string, path: string): Promise<{ ok: boolean; status: number; data?: T; error?: string }> => {
|
|
551
|
+
try {
|
|
552
|
+
const res = await fetch(`${base}${path}`, {
|
|
553
|
+
method,
|
|
554
|
+
headers: { authorization: `Bearer ${token}` },
|
|
555
|
+
signal: AbortSignal.timeout(12000),
|
|
556
|
+
})
|
|
557
|
+
const parsed = (await res.json().catch(() => ({}))) as T & { ok?: boolean; error?: string }
|
|
558
|
+
if (!res.ok) return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` }
|
|
559
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error }
|
|
560
|
+
} catch (error) {
|
|
561
|
+
return { ok: false, status: 0, error: (error as Error).message }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
me: () => call<{ userId: string; label: string; token: { id: string; label: string; limits: Record<string, number>; expiresAt: number }; settings: Record<string, unknown> }>("GET", "/api/me"),
|
|
566
|
+
myShares: () => call<{ shares: Array<{ id: string; sessionID: string; createdAt: number; status: string }> }>("GET", "/api/me/shares"),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
459
570
|
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
460
571
|
* legacy createToken (when it points at this server), or prompt. */
|
|
461
572
|
export function adminTokenFor(url: string, cfg: ShareConfig = currentConfig()): string {
|
|
@@ -487,11 +598,23 @@ export async function waitForBinding(
|
|
|
487
598
|
export type FlowResult = { ok: boolean; message: string; url?: string; unconfigured?: boolean }
|
|
488
599
|
|
|
489
600
|
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
490
|
-
* directive — no remembered consent, no silent creates).
|
|
491
|
-
|
|
601
|
+
* directive — no remembered consent, no silent creates). The dialog
|
|
602
|
+
* carries the live context (server, TTL, gate state) so the decision is
|
|
603
|
+
* informed, not ceremonial. */
|
|
604
|
+
export async function requestShareConsent(host: ShareHostApi, context?: { server?: string; ttlHours?: number; auth: boolean; counts?: { messages: number; toolCalls: number }; redaction?: { hits: number; samples: string[] } }): Promise<boolean> {
|
|
605
|
+
const lines: string[] = ["Publish this session (and its subagent sessions) to your openshare worker?"]
|
|
606
|
+
if (context) {
|
|
607
|
+
const bits: string[] = []
|
|
608
|
+
if (context.server) bits.push(`server: ${context.server}`)
|
|
609
|
+
if (context.ttlHours !== undefined) bits.push(`TTL: ${fmtHours(context.ttlHours)}`)
|
|
610
|
+
if (context.redaction && context.redaction.hits > 0) bits.push(`redaction: ${context.redaction.hits} hit(s)`)
|
|
611
|
+
bits.push(`read auth: ${context.auth ? "on" : "off"}`)
|
|
612
|
+
lines.push(bits.join(" - "))
|
|
613
|
+
}
|
|
614
|
+
lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).")
|
|
492
615
|
return host.ui.showConfirm({
|
|
493
616
|
title: "Share Session",
|
|
494
|
-
message: "
|
|
617
|
+
message: lines.join("\n"),
|
|
495
618
|
confirmLabel: "Share",
|
|
496
619
|
})
|
|
497
620
|
}
|
|
@@ -502,17 +625,39 @@ export async function requestShareConsent(host: ShareHostApi): Promise<boolean>
|
|
|
502
625
|
export async function shareWithConsent(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult> {
|
|
503
626
|
if (!sessionID) return { ok: false, message: "No active session to share." }
|
|
504
627
|
const cfg = currentConfig()
|
|
505
|
-
|
|
506
|
-
|
|
628
|
+
// engine-aware: plugin-options-layer config can target servers the file
|
|
629
|
+
// chain cannot see (TUI blindness would push onboarding at a configured user)
|
|
630
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
631
|
+
return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true }
|
|
507
632
|
}
|
|
508
633
|
const root = await resolveRoot(host.client, sessionID)
|
|
509
|
-
const
|
|
510
|
-
|
|
634
|
+
const all = await readBindings(host.client, root)
|
|
635
|
+
// fully opted-out (leave-as-is everywhere) is NOT live: consent again
|
|
636
|
+
// instead of silently copying a snapshot link
|
|
637
|
+
const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut)
|
|
638
|
+
if (live) {
|
|
511
639
|
// already sharing: copy the link
|
|
512
|
-
await host.clipboard.write(
|
|
513
|
-
return { ok: true, message: `Share URL copied to clipboard: ${
|
|
640
|
+
await host.clipboard.write(live.url)
|
|
641
|
+
return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url }
|
|
642
|
+
}
|
|
643
|
+
const opts = all[0]?.opts
|
|
644
|
+
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined)
|
|
645
|
+
{
|
|
646
|
+
const stats = sessionStatsFor(root)
|
|
647
|
+
const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword)
|
|
648
|
+
if (
|
|
649
|
+
!(await requestShareConsent(host, {
|
|
650
|
+
server: server ? hostOf(server.url) : undefined,
|
|
651
|
+
ttlHours: opts?.ttlHours ?? cfg.ttlHours,
|
|
652
|
+
auth: gated,
|
|
653
|
+
counts: { messages: stats.messages, toolCalls: stats.toolCalls },
|
|
654
|
+
// redaction only runs on ungated shares (a gated share is
|
|
655
|
+
// private from casual observers; the ledger reflects that)
|
|
656
|
+
redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) },
|
|
657
|
+
}))
|
|
658
|
+
)
|
|
659
|
+
return { ok: false, message: "Share cancelled." }
|
|
514
660
|
}
|
|
515
|
-
if (!(await requestShareConsent(host))) return { ok: false, message: "Share cancelled." }
|
|
516
661
|
return confirmShare(host, root)
|
|
517
662
|
}
|
|
518
663
|
|
|
@@ -522,13 +667,25 @@ export async function confirmShare(host: ShareHostApi, rootSessionID: string): P
|
|
|
522
667
|
const set = await setShareFlag(host.client, rootSessionID)
|
|
523
668
|
dbgLog(`confirmShare: setShareFlag -> ${set}`)
|
|
524
669
|
if (!set) return { ok: false, message: "Could not write the share flag into session metadata." }
|
|
670
|
+
// fresh consent: clear any leave-as-is markers so the engine re-arms
|
|
671
|
+
// (re-attaching the same pages wherever the secrets still exist)
|
|
672
|
+
await patchSessionBinding(host.client, rootSessionID, (servers) => {
|
|
673
|
+
for (const e of Object.values(servers)) {
|
|
674
|
+
if (e && typeof e === "object") delete (e as Record<string, unknown>).optedOut
|
|
675
|
+
}
|
|
676
|
+
})
|
|
525
677
|
const binding = await waitForBinding(host.client, rootSessionID)
|
|
526
678
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`)
|
|
527
679
|
if (!binding?.url) {
|
|
680
|
+
// a failed create must NOT leave the flag armed: the engine would
|
|
681
|
+
// publish LATER with no further consent. Disarm now; re-running
|
|
682
|
+
// /share re-consents.
|
|
683
|
+
await clearShareFlag(host.client, rootSessionID)
|
|
684
|
+
const fresh = await readBinding(host.client, rootSessionID)
|
|
685
|
+
const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : ""
|
|
528
686
|
return {
|
|
529
687
|
ok: false,
|
|
530
|
-
message:
|
|
531
|
-
"Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
|
|
688
|
+
message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}\nCheck the [openshare] stderr lines for details.`,
|
|
532
689
|
}
|
|
533
690
|
}
|
|
534
691
|
await host.clipboard.write(binding.url)
|
package/src/sidebar.tsx
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
// v1-only: the v2 TUI plugin context has no slot registry. The v2 adapter
|
|
10
10
|
// simply skips this module.
|
|
11
11
|
//
|
|
12
|
-
// SLOT CONTRACT
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
12
|
+
// SLOT CONTRACT: (1) @opentui/core SlotRenderer is called as (ctx, props),
|
|
13
|
+
// CONTEXT FIRST. (2) The payload MUST be box-rooted: a text root with text
|
|
14
|
+
// children is not hostable through the slot machinery
|
|
15
|
+
// ("TextNodeRenderable only accepts strings..." on embed). Every proven
|
|
16
|
+
// consumer returns box-rooted trees (opencode's own default children,
|
|
17
|
+
// magic-context's sidebar, the host's slot self-test); text nodes stay
|
|
18
|
+
// LEAVES here, string content only.
|
|
19
19
|
|
|
20
20
|
import type { JSX } from "@opentui/solid"
|
|
21
21
|
import { bindingsOf, bindingOf, verifyShare } from "./share-flow.js"
|
package/src/tui.tsx
CHANGED
|
@@ -42,13 +42,12 @@ export function buildV1HostApi(api: TuiPluginApi): ShareHostApi {
|
|
|
42
42
|
hostVersion: 1,
|
|
43
43
|
app: { version: api.app?.version },
|
|
44
44
|
// 1.19.x hands plugins the v2 SDK client — a LAZY PROXY (methods
|
|
45
|
-
// materialize on property access
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// %7BsessionID%7D" — so every session.get/update failed silently),
|
|
45
|
+
// materialize on property access, and spreading it destroys them, which
|
|
46
|
+
// breaks session.update). Wrap with passthrough Proxies instead:
|
|
47
|
+
// - path args are translated id -> sessionID (the v2 SDK's URL builder
|
|
48
|
+
// left "{sessionID}" literally in the URL for {path:{id}} calls, so
|
|
49
|
+
// every session.get/update failed silently with
|
|
50
|
+
// "Expected a string starting with ses, got %7BsessionID%7D"),
|
|
52
51
|
// - session.get fills metadata from the state store when the v2
|
|
53
52
|
// response lacks it,
|
|
54
53
|
// - everything else reaches the original proxy untouched.
|
|
@@ -139,10 +138,10 @@ export function buildV1HostApi(api: TuiPluginApi): ShareHostApi {
|
|
|
139
138
|
theme: api.theme,
|
|
140
139
|
ui: {
|
|
141
140
|
toast: (input) => api.ui.toast(input),
|
|
142
|
-
// dialog.clear() THROWS on an already-empty stack on 1.19.x
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
141
|
+
// dialog.clear() THROWS on an already-empty stack on 1.19.x, and flows
|
|
142
|
+
// legitimately clear after the components already settled and cleared;
|
|
143
|
+
// the throw kills confirmShare before it starts. Idempotent wrapper for
|
|
144
|
+
// every call site.
|
|
146
145
|
dialog: new Proxy({} as ShareHostApi["ui"]["dialog"], {
|
|
147
146
|
get: (_t, key: string) => {
|
|
148
147
|
if (key === "clear")
|
|
@@ -185,14 +184,13 @@ export function buildV1HostApi(api: TuiPluginApi): ShareHostApi {
|
|
|
185
184
|
},
|
|
186
185
|
// CUSTOM interactive dialogs (family MenuDialog pattern: own
|
|
187
186
|
// priority-10000 key layer + mode push + input shield). The host's
|
|
188
|
-
// ui.DialogX components under plugin dialog.replace render but
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// dialog.replace anymore — flows use showMenu/showConfirm directly.
|
|
187
|
+
// ui.DialogX components under plugin dialog.replace render but receive
|
|
188
|
+
// no keys on opencode 1.19.x. These are component functions: flows
|
|
189
|
+
// invoke them inside their own dialog.replace(() => ...) render thunk,
|
|
190
|
+
// and the host renders the thunk in a proper Solid root so the hooks
|
|
191
|
+
// inside run with an owner. DialogX bridge shims (v2 adapter / legacy
|
|
192
|
+
// call sites) route to the ported promise menus; no v1 flow renders
|
|
193
|
+
// through dialog.replace anymore, flows use showMenu/showConfirm.
|
|
196
194
|
DialogSelect: (props) => {
|
|
197
195
|
void showMenuOnce(api, { title: props.title, options: toMenuOptions(props.options), current: props.current, hint: props.placeholder }).then((value) => {
|
|
198
196
|
if (value === undefined) (props as { onCancel?: () => void }).onCancel?.()
|
|
@@ -251,11 +249,11 @@ export function buildV1HostApi(api: TuiPluginApi): ShareHostApi {
|
|
|
251
249
|
return route.name === "session" ? (route.params?.sessionID as string | undefined) : undefined
|
|
252
250
|
},
|
|
253
251
|
clipboard: {
|
|
254
|
-
// navigator.clipboard.writeText resolves as a silent no-op in the
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
252
|
+
// navigator.clipboard.writeText resolves as a silent no-op in the TUI
|
|
253
|
+
// runtime: the menu reports "copied" and nothing lands (the sidebar chip
|
|
254
|
+
// works because it falls back to OSC52). Belt-and-braces: try navigator,
|
|
255
|
+
// then ALWAYS emit the OSC52 escape, which is harmless when the terminal
|
|
256
|
+
// ignores it and correct when it honors it.
|
|
259
257
|
async write(text) {
|
|
260
258
|
let ok = false
|
|
261
259
|
try {
|
package/src/ui-prefs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Host-agnostic dialog size preferences
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Host-agnostic dialog size preferences, stored in host kv rather than the
|
|
2
|
+
// config file because UI settings are per-host and synced by opencode
|
|
3
|
+
// itself. Dialog width maps to the host's fixed dialog widths; height is a
|
|
4
|
+
// percent of terminal rows. v1 dialogs apply these directly (dialogs.tsx);
|
|
5
|
+
// the settings dialog edits them through this module so the flows stay
|
|
6
|
+
// host-agnostic.
|
|
7
7
|
|
|
8
8
|
export const UI_WIDTH_KV = "openshare.ui-width"
|
|
9
9
|
export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent"
|
package/src/v2-server.ts
CHANGED
|
@@ -229,12 +229,11 @@ export function createV2ServerSetup() {
|
|
|
229
229
|
// the client adapter and feed them into the engine as the v1 events it
|
|
230
230
|
// understands (session.updated / message.updated / message.part.updated).
|
|
231
231
|
// The engine's keyed queue makes repeated re-syncs idempotent.
|
|
232
|
-
// NOTE: no content-hash dedupe here
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// belongs engine-side, where dormancy is known, if it's ever needed.
|
|
232
|
+
// NOTE: no content-hash dedupe here. A refresh running while the session
|
|
233
|
+
// is still dormant (pre-flag) would poison the cache without emitting
|
|
234
|
+
// anything, and the flag-time refresh would then SKIP the unchanged
|
|
235
|
+
// messages, so the share's first assistant content would only arrive on
|
|
236
|
+
// the next change. Dedupe belongs engine-side, where dormancy is known.
|
|
238
237
|
const pendingRefresh = new Map<string, ReturnType<typeof setTimeout>>()
|
|
239
238
|
// queued inbox inputs per session: v2 persists the prompt in the inbox
|
|
240
239
|
// (delivery "queue") while the current turn still runs — the message
|