@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.10
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 +74 -28
- package/ci/README.md +30 -14
- package/ci/openshare.js +475 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +37 -14
- package/dist/config.js +119 -33
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +12 -1
- package/dist/core.js +409 -138
- 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 +10 -7
- package/dist/settings.js +138 -56
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +260 -60
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +2 -4
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +124 -31
- package/src/contract.ts +5 -5
- package/src/core.ts +384 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +134 -54
- package/src/share-flow.ts +76 -20
- 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 { engineTargetUrls, type ClientApi, type SessionInfo, type SessionOpts } from "./core.js"
|
|
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
|
|
|
@@ -44,11 +44,10 @@ export function currentConfig(baseDir: string = moduleDir()): ShareConfig {
|
|
|
44
44
|
return loadConfig(baseDir)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** Writes the config file. Target rule
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* 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. */
|
|
52
51
|
export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string = moduleDir()): { path: string } | undefined {
|
|
53
52
|
const path = target ?? configHomePath()
|
|
54
53
|
if (!path) return undefined
|
|
@@ -71,7 +70,7 @@ export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string =
|
|
|
71
70
|
"debounceMs",
|
|
72
71
|
"completedDelayMs",
|
|
73
72
|
"modelLabel",
|
|
74
|
-
"
|
|
73
|
+
"ttlHours",
|
|
75
74
|
"authLogin",
|
|
76
75
|
"authPassword",
|
|
77
76
|
"spoolDir",
|
|
@@ -171,6 +170,24 @@ export async function readBindings(client: ClientApi | undefined, sid: string):
|
|
|
171
170
|
return bindingsOf(await getSession(client, sid))
|
|
172
171
|
}
|
|
173
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
|
+
|
|
174
191
|
export function hostOf(url: string): string {
|
|
175
192
|
try {
|
|
176
193
|
return new URL(url).host
|
|
@@ -333,6 +350,36 @@ export async function workerDeleteShare(workerUrl: string, id: string, secret: s
|
|
|
333
350
|
}
|
|
334
351
|
}
|
|
335
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
|
+
|
|
336
383
|
/** Health probe for the settings dialog's connection test. */
|
|
337
384
|
export async function probeWorker(workerUrl: string): Promise<{ ok: boolean; detail: string }> {
|
|
338
385
|
if (!workerUrl) return { ok: false, detail: "workerUrl is empty" }
|
|
@@ -554,12 +601,13 @@ export type FlowResult = { ok: boolean; message: string; url?: string; unconfigu
|
|
|
554
601
|
* directive — no remembered consent, no silent creates). The dialog
|
|
555
602
|
* carries the live context (server, TTL, gate state) so the decision is
|
|
556
603
|
* informed, not ceremonial. */
|
|
557
|
-
export async function requestShareConsent(host: ShareHostApi, context?: { server?: string;
|
|
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> {
|
|
558
605
|
const lines: string[] = ["Publish this session (and its subagent sessions) to your openshare worker?"]
|
|
559
606
|
if (context) {
|
|
560
607
|
const bits: string[] = []
|
|
561
608
|
if (context.server) bits.push(`server: ${context.server}`)
|
|
562
|
-
if (context.
|
|
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)`)
|
|
563
611
|
bits.push(`read auth: ${context.auth ? "on" : "off"}`)
|
|
564
612
|
lines.push(bits.join(" - "))
|
|
565
613
|
}
|
|
@@ -594,14 +642,22 @@ export async function shareWithConsent(host: ShareHostApi, sessionID: string | u
|
|
|
594
642
|
}
|
|
595
643
|
const opts = all[0]?.opts
|
|
596
644
|
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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." }
|
|
660
|
+
}
|
|
605
661
|
return confirmShare(host, root)
|
|
606
662
|
}
|
|
607
663
|
|
|
@@ -621,9 +677,9 @@ export async function confirmShare(host: ShareHostApi, rootSessionID: string): P
|
|
|
621
677
|
const binding = await waitForBinding(host.client, rootSessionID)
|
|
622
678
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`)
|
|
623
679
|
if (!binding?.url) {
|
|
624
|
-
// a failed create must NOT leave the flag armed
|
|
625
|
-
// publish LATER with no further consent
|
|
626
|
-
//
|
|
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.
|
|
627
683
|
await clearShareFlag(host.client, rootSessionID)
|
|
628
684
|
const fresh = await readBinding(host.client, rootSessionID)
|
|
629
685
|
const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : ""
|
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
|