@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/dist/ui-prefs.js 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
  export const UI_WIDTH_KV = "openshare.ui-width";
8
8
  export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent";
9
9
  export const HEIGHT_PERCENT_MIN = 25;
package/dist/v2-server.js CHANGED
@@ -177,12 +177,11 @@ export function createV2ServerSetup() {
177
177
  // the client adapter and feed them into the engine as the v1 events it
178
178
  // understands (session.updated / message.updated / message.part.updated).
179
179
  // The engine's keyed queue makes repeated re-syncs idempotent.
180
- // NOTE: no content-hash dedupe here — a refresh that runs while the
181
- // session is still dormant (pre-flag) would poison the cache without
182
- // emitting anything, and the flag-time refresh would then SKIP the
183
- // unchanged messages (live-caught in the v2 mock suite: the share's
184
- // first assistant content arrived only as the next change). The dedupe
185
- // belongs engine-side, where dormancy is known, if it's ever needed.
180
+ // NOTE: no content-hash dedupe here. A refresh running while the session
181
+ // is still dormant (pre-flag) would poison the cache without emitting
182
+ // anything, and the flag-time refresh would then SKIP the unchanged
183
+ // messages, so the share's first assistant content would only arrive on
184
+ // the next change. Dedupe belongs engine-side, where dormancy is known.
186
185
  const pendingRefresh = new Map();
187
186
  // queued inbox inputs per session: v2 persists the prompt in the inbox
188
187
  // (delivery "queue") while the current turn still runs — the message
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), openTasksInNewTab (task rows open subagent pages in a new tab), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
2
+ "$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
3
3
  "workerUrl": "",
4
4
  "createToken": "",
5
5
  "servers": {},
@@ -7,7 +7,6 @@
7
7
  "completedDelayMs": 5000,
8
8
  "modelLabel": "$Provider/$Model",
9
9
  "ttlDays": 90,
10
- "openTasksInNewTab": true,
11
10
  "authLogin": "",
12
11
  "authPassword": "",
13
12
  "spoolDir": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-openshare",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-dev.1",
4
4
  "type": "module",
5
5
  "description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
6
6
  "license": "MIT",
package/src/banner.txt CHANGED
@@ -19,7 +19,7 @@
19
19
  // > code defaults. Keys: servers (multi-server: url -> {token, code,
20
20
  // label, active, adminToken}), workerUrl, createToken (legacy pair),
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
- // completedDelayMs, modelLabel, ttlDays, openTasksInNewTab,
22
+ // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
24
  // announceStderr.
25
25
  //
@@ -5,8 +5,8 @@
5
5
  * the servers map) and the TUI (`patchSessionBinding` in share-flow.ts —
6
6
  * opt-outs, collision resolution). Their PATCHes interleave at the host:
7
7
  * writer A reads at T0, writer B reads at T1, A PATCHes at T2, B PATCHes
8
- * at T3 — A's change is silently lost (live-caught class: a TUI opt-out
9
- * erased by the engine's binding refresh).
8
+ * at T3, and A's change is silently lost (a TUI opt-out erased by the
9
+ * engine's binding refresh).
10
10
  *
11
11
  * Both route their whole read-modify-write through this per-session FIFO.
12
12
  * The queue never breaks on errors; entries drop when the process exits.
package/src/commands.ts CHANGED
@@ -5,14 +5,14 @@
5
5
  // auth — one surface, no duplicated manager); on an unshared session it
6
6
  // asks consent and creates the share. /openshare opens settings.
7
7
  //
8
- // LAYER SHAPE (live-caught 2026-10-19): the v1 host keymap gates layers
9
- // that declare `mode` on the mode stack (resting mode is "base"), so a
10
- // plugin layer MUST NOT pass mode ("global" made the whole layer
11
- // unreachable — no palette entries, no slash commands, no shadow) and
12
- // must not carry undefined-valued command fields. The registration shape
13
- // is exactly what the proven family plugins use: {commands, bindings},
14
- // commands with only defined fields, category "OpenShare" (own group,
15
- // deliberately NOT joined into the shared family palette category).
8
+ // LAYER SHAPE: the v1 host keymap gates layers that declare `mode` on the
9
+ // mode stack (resting mode is "base"), so a plugin layer MUST NOT pass mode
10
+ // ("global" made the whole layer unreachable: no palette entries, no slash
11
+ // commands, no shadow) and must not carry undefined-valued command fields.
12
+ // The registration shape is exactly what the proven family plugins use:
13
+ // {commands, bindings}, commands with only defined fields, category
14
+ // "OpenShare" (own group, deliberately NOT joined into the shared family
15
+ // palette category).
16
16
 
17
17
  import {
18
18
  clearGoneBindingEntries,
@@ -27,6 +27,7 @@ import {
27
27
  verifyShare,
28
28
  } from "./share-flow.js"
29
29
  import { onboardingDialog, settingsDialog } from "./settings.js"
30
+ import { engineTargetUrls } from "./core.js"
30
31
  import { dbgLog } from "./sidebar.js"
31
32
  import type { ShareHostApi, ShareHostLayerCommand } from "./tui-host.js"
32
33
 
@@ -152,7 +153,9 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
152
153
  dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`)
153
154
  host.ui.dialog.clear()
154
155
  const cfg = currentConfig()
155
- if (!isConfigured(cfg)) {
156
+ // engine-aware gate: project-level plugin options can configure
157
+ // targets the TUI's file chain cannot see - trust the live engine
158
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
156
159
  void onboardingDialog(host)
157
160
  return
158
161
  }
@@ -171,12 +174,12 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
171
174
  // enabled:true; legacy all-mode bindings without the field are live
172
175
  // too — only an explicit false is dormant)
173
176
  if (all.some((b) => b.url && b.enabled !== false)) {
174
- // THE session menu — identical to /openshare → This session by
175
- // construction (one surface, no duplicated manager). No
176
- // dialog.clear() after: settings owns its chain end-to-end (the
177
- // old fire-and-forget menu + immediate clear wiped it instantly —
178
- // live-caught as "/share did nothing" on a shared session).
179
- await settingsDialog(host, sid)
177
+ // THE session menu - identical to /openshare This session by
178
+ // construction (one surface, no duplicated manager), opened
179
+ // DIRECTLY (owner: "/share is supposed to be the same menu, not
180
+ // the settings root one hop away"). No dialog.clear() after:
181
+ // settings owns its chain end-to-end.
182
+ await settingsDialog(host, sid, "session")
180
183
  return
181
184
  }
182
185
  // everything was just cleared by the gone modal and the user chose
package/src/config.ts CHANGED
@@ -37,6 +37,9 @@ export type ServerEntry = {
37
37
  label?: string
38
38
  active?: boolean
39
39
  adminToken?: string
40
+ /** The user DECLINED onboarding for this server (asked once, remembered):
41
+ * shares go out anonymously (public servers only). Cleared by enrolling. */
42
+ anonymous?: boolean
40
43
  }
41
44
 
42
45
  export type ShareConfig = {
@@ -48,15 +51,31 @@ export type ShareConfig = {
48
51
  debounceMs: number
49
52
  completedDelayMs: number
50
53
  modelLabel: string
51
- ttlDays: number
52
- openTasksInNewTab: boolean
54
+ /** Default share lifetime in HOURS (Wave 3; default 2160 = 90d).
55
+ * Legacy ttlDays config/env is accepted and converted x24. */
56
+ ttlHours: number
53
57
  authLogin: string
54
58
  authPassword: string
55
59
  spoolDir: string
56
60
  urlFile: string
61
+ /** urlFile line format: "url" (default, one bare URL per line) or
62
+ * "json" (one JSON object per line: id, url, status, gated) for CI
63
+ * consumers that want machine-readable lines without parsing URLs. */
64
+ urlFileFormat: "url" | "json"
57
65
  /** false silences the `[openshare] live:` (and `url file:`) stderr
58
66
  * lines. The share itself is unaffected. */
59
67
  announceStderr: boolean
68
+ /** Wave 3 redaction: user-supplied regex rules (client-side only)
69
+ * applied beside the built-in detectors on UNGATED shares. */
70
+ redact?: { patterns?: string[] }
71
+ /** B4: which named config set on the server token this agent uses
72
+ * (unset = the token's first/default set). Rides the create frame. */
73
+ configSet?: string
74
+ /** Free-form string metadata stamped onto every share at create
75
+ * (labels for team directories/admin listings). Values may reference
76
+ * env vars as ${NAME} - interpolated at send time; unresolved refs
77
+ * stay literal so misconfiguration is visible. */
78
+ meta?: Record<string, string>
60
79
  shareMode?: ShareMode
61
80
  }
62
81
 
@@ -77,12 +96,13 @@ export const DEFAULTS: ShareConfig = {
77
96
  // pending flip, so CI runs still land their final status.
78
97
  completedDelayMs: 5000,
79
98
  modelLabel: "$Provider/$Model",
80
- ttlDays: 90,
81
- openTasksInNewTab: true,
99
+ ttlHours: 90 * 24,
82
100
  authLogin: "",
83
101
  authPassword: "",
84
102
  spoolDir: "",
85
103
  urlFile: "",
104
+ urlFileFormat: "url",
105
+ configSet: "",
86
106
  announceStderr: true,
87
107
  }
88
108
 
@@ -144,9 +164,8 @@ export type WriteCandidate = { path: string; label: string; always?: boolean }
144
164
 
145
165
  /** Write-target candidates for the TUI picker: the global settings home
146
166
  * (always writable) plus every OTHER file in the read chain that already
147
- * exists on disk (deploy-time overrides are never created from the TUI —
148
- * owner rule). Order: global first (default), then the rest by read
149
- * precedence (highest first). */
167
+ * exists on disk, since the TUI never creates deploy-time overrides. Order:
168
+ * global first (default), then the rest by read precedence (highest first). */
150
169
  export function writeCandidates(pluginDir: string, env: NodeJS.ProcessEnv = process.env): WriteCandidate[] {
151
170
  const candidates: WriteCandidate[] = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }]
152
171
  const labels: Array<[string, string]> = [
@@ -180,10 +199,13 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
180
199
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
181
200
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
182
201
  modelLabel: env.OPENSHARE_MODEL_LABEL,
202
+ ttlHours: env.OPENSHARE_TTL_HOURS,
183
203
  ttlDays: env.OPENSHARE_TTL_DAYS,
184
204
  authLogin: env.OPENSHARE_AUTH_LOGIN,
185
205
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
186
206
  urlFile: env.OPENSHARE_URL_FILE,
207
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
208
+ configSet: env.OPENSHARE_CONFIG_SET,
187
209
  spoolDir: env.OPENSHARE_SPOOL_DIR,
188
210
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
189
211
  shareMode: env.OPENSHARE_SHARE_MODE,
@@ -191,6 +213,23 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
191
213
  for (const [key, value] of Object.entries(envKeys)) {
192
214
  if (value !== undefined && value !== "") sources[key] = "environment"
193
215
  }
216
+ // legacy days-shaped env feeds the same knob as ttlHours
217
+ if (sources.ttlDays && !sources.ttlHours) sources.ttlHours = sources.ttlDays
218
+ delete sources.ttlDays
219
+ // OPENSHARE_CONFIG_JSON sits between env and the files: it loses only
220
+ // to a specific env var, so it overrides any file's key
221
+ if (env.OPENSHARE_CONFIG_JSON) {
222
+ try {
223
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON)
224
+ if (raw && typeof raw === "object") {
225
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
226
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
227
+ if (sources[key] === "environment") continue
228
+ if (key === "servers" || known.has(key)) sources[key] = "OPENSHARE_CONFIG_JSON"
229
+ }
230
+ }
231
+ } catch {}
232
+ }
194
233
  // configPaths() is ordered lowest-priority-first? No: index 0 is the
195
234
  // ADJACENT file (highest). Merge order reverses it; attribution walks
196
235
  // it as-is so the first hit is the winning (highest) source.
@@ -240,6 +279,7 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
240
279
  if (typeof e.label === "string" && e.label) entry.label = e.label
241
280
  if (typeof e.adminToken === "string" && e.adminToken) entry.adminToken = e.adminToken
242
281
  if (e.active === true) entry.active = true
282
+ if (e.anonymous === true) entry.anonymous = true
243
283
  out[url] = entry
244
284
  }
245
285
  return Object.keys(out).length ? out : undefined
@@ -247,9 +287,8 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
247
287
 
248
288
  /** Field-level merge of servers groups: entries only carry present fields
249
289
  * (normalizeServers), so a static `code` in plugin options and a persisted
250
- * `token` in the settings home combine instead of shadowing — per-URL
251
- * wholesale replacement would re-enroll on every boot (live-caught in
252
- * suite design review). */
290
+ * `token` in the settings home combine instead of shadowing. Per-URL
291
+ * wholesale replacement would re-enroll on every boot. */
253
292
  export function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry> {
254
293
  const merged: Record<string, ServerEntry> = { ...(base ?? {}) }
255
294
  for (const [url, entry] of Object.entries(add)) merged[url] = { ...(merged[url] ?? {}), ...entry }
@@ -257,10 +296,10 @@ export function mergeServerGroups(base: Record<string, ServerEntry> | undefined,
257
296
  }
258
297
 
259
298
  /** The server new sessions share to: the `active` entry (or the first).
260
- * Legacy workerUrl/createToken merge in as the implicit first entry — with
261
- * the root token ALSO filed as adminToken (the createToken IS root; filing
262
- * it only as `token` made the TUI offer user-actions like rotate to a root
263
- * holder — live-caught "can either be admin or user but not both"). */
299
+ * Legacy workerUrl/createToken merge in as the implicit first entry, with
300
+ * the root token ALSO filed as adminToken: the createToken IS root, and
301
+ * filing it only as `token` offers user-actions like rotate to a root
302
+ * holder, while an identity is either admin or user, not both. */
264
303
  export function resolveServer(cfg: ShareConfig): { url: string; token: string; entry: ServerEntry } | undefined {
265
304
  const servers: Record<string, ServerEntry> = {}
266
305
  if (cfg.workerUrl) servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true }
@@ -330,25 +369,58 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
330
369
 
331
370
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
332
371
  const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode"])
372
+ const applyObject = (raw: unknown) => {
373
+ if (!raw || typeof raw !== "object") return
374
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
375
+ if (key === "servers") {
376
+ const servers = normalizeServers(value)
377
+ if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
378
+ continue
379
+ }
380
+ if (key === "redact") {
381
+ if (value && typeof value === "object" && Array.isArray((value as { patterns?: unknown }).patterns)) {
382
+ const pats = (value as { patterns: unknown[] }).patterns.filter((x): x is string => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50)
383
+ cfg.redact = { patterns: pats }
384
+ }
385
+ continue
386
+ }
387
+ if (key === "meta") {
388
+ // string-to-string only: meta rides the create frame verbatim
389
+ if (value && typeof value === "object" && !Array.isArray(value)) {
390
+ const clean: Record<string, string> = {}
391
+ for (const [mk, mv] of Object.entries(value as Record<string, unknown>)) {
392
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64) clean[mk] = mv
393
+ }
394
+ if (Object.keys(clean).length) cfg.meta = { ...cfg.meta, ...clean }
395
+ }
396
+ continue
397
+ }
398
+ if (key === "ttlDays") {
399
+ // legacy days-shaped file/options value converts to hours once
400
+ const d = Math.floor(Number(value))
401
+ if (Number.isFinite(d) && d > 0) cfg.ttlHours = d * 24
402
+ continue
403
+ }
404
+ if (!known.has(key)) continue
405
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
406
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
407
+ ;(cfg as Record<string, unknown>)[key] = value
408
+ }
409
+ }
410
+
333
411
  for (const path of [...configPaths(pluginDir)].reverse()) {
334
412
  try {
335
413
  // strict JSON: the $comment key (what our templates use) is valid
336
414
  // JSON; comment-stripping is deliberately avoided because a naive
337
415
  // stripper eats "//" inside workerUrl values
338
- const raw = JSON.parse(readFileSync(path, "utf8"))
339
- if (!raw || typeof raw !== "object") continue
340
- for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
341
- if (key === "servers") {
342
- const servers = normalizeServers(value)
343
- if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
344
- continue
345
- }
346
- if (!known.has(key)) continue
347
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
348
- if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
349
- ;(cfg as Record<string, unknown>)[key] = value
350
- }
351
- } catch {}
416
+ applyObject(JSON.parse(readFileSync(path, "utf8")))
417
+ } catch (e) {
418
+ // a malformed config file is a real setup error the user must hear
419
+ // about - silence here looks like "no share server configured".
420
+ // (Absent files are normal chain candidates - only broken content
421
+ // is worth a line.)
422
+ if (existsSync(path)) console.error(`[openshare] config file ${path} could not be parsed - ignored:`, (e as Error)?.message ?? e)
423
+ }
352
424
  }
353
425
 
354
426
  if (options && typeof options === "object") {
@@ -366,15 +438,30 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
366
438
  }
367
439
 
368
440
  const env = process.env
441
+ // OPENSHARE_CONFIG_JSON: a complete config object in one env var. Sits
442
+ // between the file/options layers and the per-key OPENSHARE_* env (a
443
+ // specific env var still wins) - CI sets one variable instead of nine,
444
+ // and secrets never touch a file. Malformed JSON is a loud error, not
445
+ // silence (same rule as files).
446
+ if (env.OPENSHARE_CONFIG_JSON) {
447
+ try {
448
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON))
449
+ } catch (e) {
450
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", (e as Error)?.message ?? e)
451
+ }
452
+ }
369
453
  if (env.OPENSHARE_WORKER_URL) cfg.workerUrl = env.OPENSHARE_WORKER_URL
370
454
  if (env.OPENSHARE_CREATE_TOKEN) cfg.createToken = env.OPENSHARE_CREATE_TOKEN
371
455
  if (env.OPENSHARE_DEBOUNCE_MS) cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs)
372
456
  if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined) cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs)
373
457
  if (env.OPENSHARE_MODEL_LABEL !== undefined) cfg.modelLabel = env.OPENSHARE_MODEL_LABEL
374
- if (env.OPENSHARE_TTL_DAYS) cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays)
458
+ if (env.OPENSHARE_TTL_HOURS) cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours)
459
+ if (env.OPENSHARE_TTL_DAYS) cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24))
375
460
  if (env.OPENSHARE_AUTH_LOGIN !== undefined) cfg.authLogin = env.OPENSHARE_AUTH_LOGIN
376
461
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined) cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD
377
462
  if (env.OPENSHARE_URL_FILE !== undefined) cfg.urlFile = env.OPENSHARE_URL_FILE
463
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url") cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT
464
+ if (env.OPENSHARE_CONFIG_SET) cfg.configSet = env.OPENSHARE_CONFIG_SET
378
465
  if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
379
466
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
380
467
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
@@ -382,6 +469,16 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
382
469
  // normalize the disable sentinel (case-insensitive from any source)
383
470
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
384
471
 
472
+ // URL hygiene: a trailing slash on a server url becomes
473
+ // wss://host//api/ws and https://host//s/<id> downstream - strip once,
474
+ // at the source, so every consumer agrees
475
+ if (cfg.workerUrl) cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "")
476
+ if (cfg.servers) {
477
+ const fixed: Record<string, ServerEntry> = {}
478
+ for (const [u, e] of Object.entries(cfg.servers)) fixed[u.trim().replace(/\/+$/, "")] = e
479
+ cfg.servers = fixed
480
+ }
481
+
385
482
  return cfg
386
483
  }
387
484
 
@@ -415,9 +512,9 @@ function specPath(spec: string): string | undefined {
415
512
 
416
513
  /** Does this plugin spec (tui.json entry) point at the openshare package?
417
514
  * Accepts the bare npm name, name@version, and the /tui (or any) subpath
418
- * form — older docs shipped the subpath spec and presence detection must
419
- * never silently miss it (all-mode would keep auto-sharing while the user
420
- * believes they installed manual mode — live-caught in review). */
515
+ * form. Older docs shipped the subpath spec, and presence detection must
516
+ * never silently miss it: all-mode would keep auto-sharing while the user
517
+ * believes they installed manual mode. */
421
518
  export function isOpenShareSpec(spec: unknown): spec is string {
422
519
  if (typeof spec !== "string" || spec.length === 0) return false
423
520
  if (spec === NPM_NAME) return true
package/src/contract.ts CHANGED
@@ -21,9 +21,9 @@
21
21
  export const CONTRACT_VERSION = 2
22
22
 
23
23
  /** Message tombstone item. sessionID MUST ride along: viewers key
24
- * messages as message/<sessionID>/<id> — an unstamped del misses the
25
- * key and undone turns resurrect on the live page (live-caught
26
- * 2026-10-21, refresh-correct because the worker keys differently). */
24
+ * messages as message/<sessionID>/<id>, so an unstamped del misses the key
25
+ * and undone turns resurrect on the live page (a refresh hides them because
26
+ * the worker keys items differently). */
27
27
  export function messageTombstone(sessionID: string, messageID: string) {
28
28
  return { type: "message", del: true, sessionID, data: { id: messageID } } as const
29
29
  }
@@ -313,8 +313,8 @@ export function normalizeV1Diff(diff: unknown): unknown {
313
313
 
314
314
  /** v1 hosts paginate messages with an opaque cursor: base64url of
315
315
  * {id, time} where time = the page tail's time_created (message-v2.ts
316
- * cursor codec). A bare message id is rejected with 400 — that silently
317
- * truncated every v1 backfill to one page (live-caught). */
316
+ * cursor codec). A bare message id is rejected with 400, which silently
317
+ * truncates a v1 backfill to one page. */
318
318
  export function cursorFor(id: string, time: number): string {
319
319
  return Buffer.from(JSON.stringify({ id, time })).toString("base64url")
320
320
  }