@mirrowel/opencode-openshare 0.2.1 → 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/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 (owner-directed): the global
48
- * settings home by default, or an explicit existing file chosen in the
49
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
50
- * into the tracked adjacent template, live-caught). $comment and foreign
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
- "ttlDays",
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; ttlDays?: number; auth: boolean }): Promise<boolean> {
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.ttlDays !== undefined) bits.push(`TTL: ${context.ttlDays}d`)
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
- if (
598
- !(await requestShareConsent(host, {
599
- server: server ? hostOf(server.url) : undefined,
600
- ttlDays: opts?.ttlDays ?? cfg.ttlDays,
601
- auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword),
602
- }))
603
- )
604
- return { ok: false, message: "Share cancelled." }
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 - the engine would
625
- // publish LATER with no further consent (live-caught). Disarm now;
626
- // re-running /share re-consents.
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 (live-caught 2x): (1) @opentui/core SlotRenderer is called
13
- // as (ctx, props) — CONTEXT FIRST. (2) The payload MUST be box-rooted:
14
- // a text root with text children is not hostable through the slot
15
- // machinery ("TextNodeRenderable only accepts strings…" on embed). Every
16
- // proven consumer returns box-rooted trees (opencode's own default
17
- // children, magic-context's sidebar, the host's slot self-test); text
18
- // nodes stay LEAVES here — string content only.
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; spreading it destroys them, which
46
- // broke session.update: live-caught "no client.session.update" after
47
- // the adapter was added). Wrap with passthrough Proxies instead:
48
- // - path args are translated id -> sessionID (the v2 SDK's URL
49
- // builder left "{sessionID}" literally in the URL for {path:{id}}
50
- // calls — server log: "Expected a string starting with ses, got
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 — and
143
- // flows legitimately clear after my components already settled+cleared
144
- // (the throw killed confirmShare before it started, live-caught via
145
- // the debug log). Idempotent wrapper for every call site.
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
- // receive no keys on opencode 1.19.x (live-caught + pty-reproduced).
190
- // These are component functions: flows invoke them inside their own
191
- // dialog.replace(() => ...) render thunk (the host renders the thunk
192
- // in a proper Solid root, so the hooks inside run with an owner).
193
- // DialogX bridge shims (v2 adapter / legacy call sites): route to the
194
- // ported promise menus. Nothing on v1 renders these through
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
- // TUI runtime (live-caught: the menu copy said "copied" but nothing
256
- // landed; the sidebar chip worked because it falls back to OSC52).
257
- // Belt-and-braces: try navigator, then ALWAYS emit the OSC52 escape
258
- // — harmless when the terminal ignores it, correct when it honors it.
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 (owner decision 2026-10-19: host
2
- // kv, not the config file — UI settings are per-host and synced by
3
- // opencode itself). dialog width maps to the host's fixed dialog widths;
4
- // height is a percent of terminal rows. v1 dialogs apply these directly
5
- // (dialogs.tsx); the settings dialog edits them through this module so the
6
- // flows stay host-agnostic.
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 — a refresh that runs while the
233
- // session is still dormant (pre-flag) would poison the cache without
234
- // emitting anything, and the flag-time refresh would then SKIP the
235
- // unchanged messages (live-caught in the v2 mock suite: the share's
236
- // first assistant content arrived only as the next change). The dedupe
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