@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/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 { ClientApi, SessionInfo, 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
 
@@ -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 (owner-directed): the global
42
- * settings home by default, or an explicit existing file chosen in the
43
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
44
- * into the tracked adjacent template, live-caught). $comment and foreign
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
- "ttlDays",
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
- const activeUrl = urls.find((u) => merged[u].active) ?? urls[0]
104
- return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }))
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
- export async function requestShareConsent(host: ShareHostApi): Promise<boolean> {
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: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
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
- if (!isConfigured(cfg)) {
506
- return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true }
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 existing = await readBinding(host.client, root)
510
- if (existing?.url && existing.enabled !== false) {
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(existing.url)
513
- return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url }
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 (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