@mirrowel/opencode-openshare 0.3.0-dev.13 → 0.3.0-dev.15

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/ci/openshare.js CHANGED
@@ -21,7 +21,9 @@
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
22
  // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
- // announceStderr.
24
+ // announceStderr ("all" | "status" | "url" | "off"; status keeps the
25
+ // status/banner lines and never prints the share URL - the public-CI
26
+ // shape; url prints announce lines only).
25
27
  //
26
28
  // src/config.ts
27
29
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -42,7 +44,7 @@ var DEFAULTS = {
42
44
  urlFile: "",
43
45
  urlFileFormat: "url",
44
46
  configSet: "",
45
- announceStderr: true
47
+ announceStderr: "all"
46
48
  };
47
49
  var CONFIG_NAME = "openshare.config.json";
48
50
  var LOCAL_NAME = "openshare.local.json";
@@ -225,6 +227,22 @@ function saveServerEntry(url, patch, target) {
225
227
  function saveServerToken(url, token, target) {
226
228
  return saveServerEntry(url, { token }, target);
227
229
  }
230
+ function normalizeAnnounce(value, fallback) {
231
+ if (value === true)
232
+ return "all";
233
+ if (value === false)
234
+ return "off";
235
+ if (typeof value === "string") {
236
+ const v = value.trim().toLowerCase();
237
+ if (v === "all" || v === "status" || v === "url" || v === "off")
238
+ return v;
239
+ if (/^(1|true|yes|on)$/.test(v))
240
+ return "all";
241
+ if (/^(0|false|no)$/.test(v))
242
+ return "off";
243
+ }
244
+ return fallback;
245
+ }
228
246
  function loadConfig(pluginDir, options) {
229
247
  const cfg = { ...DEFAULTS };
230
248
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
@@ -332,11 +350,12 @@ function loadConfig(pluginDir, options) {
332
350
  if (env.OPENSHARE_SPOOL_DIR)
333
351
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
334
352
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
335
- cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim());
353
+ cfg.announceStderr = env.OPENSHARE_ANNOUNCE_STDERR;
336
354
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual")
337
355
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
338
356
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
339
357
  cfg.urlFile = URL_FILE_NONE;
358
+ cfg.announceStderr = normalizeAnnounce(cfg.announceStderr, "all");
340
359
  if (cfg.workerUrl)
341
360
  cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
342
361
  if (cfg.servers) {
@@ -889,6 +908,12 @@ function createEngine(deps) {
889
908
  const mode = resolveShareMode(cfg, deps);
890
909
  if (mode === "manual")
891
910
  stderrInfoAllowed = false;
911
+ const wantAnnounce = () => cfg.announceStderr === "all" || cfg.announceStderr === "url";
912
+ const logStatus = (...a) => {
913
+ if (cfg.announceStderr !== "all" && cfg.announceStderr !== "status")
914
+ return;
915
+ log(...a);
916
+ };
892
917
  if (process.env.OPENSHARE_DEBUG_FILE) {
893
918
  try {
894
919
  writeFileSync2(process.env.OPENSHARE_DEBUG_FILE, JSON.stringify({
@@ -902,7 +927,7 @@ function createEngine(deps) {
902
927
  }
903
928
  const server = resolveServer(cfg);
904
929
  if (!server || !server.url) {
905
- log("disabled: no share server configured (workerUrl or a servers entry)");
930
+ logStatus("disabled: no share server configured (workerUrl or a servers entry)");
906
931
  return {
907
932
  mode,
908
933
  config: () => cfg,
@@ -912,7 +937,7 @@ function createEngine(deps) {
912
937
  }
913
938
  };
914
939
  }
915
- log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
940
+ logStatus(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
916
941
  const resolveTargets = () => {
917
942
  const merged = {};
918
943
  const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
@@ -920,7 +945,7 @@ function createEngine(deps) {
920
945
  if (cfg.workerUrl && (!hasServers || envWorker))
921
946
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
922
947
  if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
923
- log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
948
+ logStatus(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
924
949
  }
925
950
  if (cfg.servers)
926
951
  for (const [u, e] of Object.entries(cfg.servers))
@@ -993,12 +1018,12 @@ function createEngine(deps) {
993
1018
  if (adoptModel && doc.modelLabel === undefined) {
994
1019
  doc.modelLabel = wantModel;
995
1020
  dirty = true;
996
- log(`${url}: adopted modelLabel from the server config set`);
1021
+ logStatus(`${url}: adopted modelLabel from the server config set`);
997
1022
  }
998
1023
  if (adoptMode && doc.shareMode === undefined) {
999
1024
  doc.shareMode = wantMode;
1000
1025
  dirty = true;
1001
- log(`${url}: adopted shareMode from the server config set`);
1026
+ logStatus(`${url}: adopted shareMode from the server config set`);
1002
1027
  }
1003
1028
  if (dirty)
1004
1029
  writeFileSync2(home, JSON.stringify(doc, null, 2) + `
@@ -1043,7 +1068,7 @@ function createEngine(deps) {
1043
1068
  c.credential = b.token;
1044
1069
  c.error = "";
1045
1070
  const savedTo = saveServerToken(url, c.credential);
1046
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
1071
+ logStatus(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
1047
1072
  adoptServerSuggestions(url, c.credential);
1048
1073
  return c.credential;
1049
1074
  }
@@ -1190,7 +1215,7 @@ function createEngine(deps) {
1190
1215
  const after = targets.map((t) => t.url);
1191
1216
  const added = after.filter((u) => !before.has(u));
1192
1217
  if (added.length)
1193
- log(`fan-out set grew: ${added.join(", ")}`);
1218
+ logStatus(`fan-out set grew: ${added.join(", ")}`);
1194
1219
  }
1195
1220
  }
1196
1221
  };
@@ -1368,10 +1393,11 @@ function createEngine(deps) {
1368
1393
  const scheduleBindingWrite = (st) => {
1369
1394
  if (st.metaTimer)
1370
1395
  return;
1396
+ const debounce = Number(process.env.OPENSHARE_BINDING_WRITE_MS) > 0 ? Number(process.env.OPENSHARE_BINDING_WRITE_MS) : 30000;
1371
1397
  st.metaTimer = setTimeout(() => {
1372
1398
  st.metaTimer = null;
1373
1399
  chain = chain.then(() => writeBinding(st)).catch(() => {});
1374
- }, 30000);
1400
+ }, debounce);
1375
1401
  };
1376
1402
  const h8 = (url) => {
1377
1403
  let h = 5381;
@@ -1538,7 +1564,7 @@ function createEngine(deps) {
1538
1564
  return;
1539
1565
  r.announced = true;
1540
1566
  const url = `${r.url}/s/${r.shareId}`;
1541
- if (cfg.announceStderr)
1567
+ if (wantAnnounce())
1542
1568
  log(`live: ${url}`);
1543
1569
  if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
1544
1570
  const file = cfg.urlFile ? isAbsolute(cfg.urlFile) ? cfg.urlFile : join2(deps.directory ?? process.cwd(), cfg.urlFile) : join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
@@ -1552,7 +1578,7 @@ function createEngine(deps) {
1552
1578
  ` : url + `
1553
1579
  `;
1554
1580
  appendFileSync(file, line);
1555
- if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1581
+ if (wantAnnounce() && (file !== lastUrlFile || !urlFileAnnounced)) {
1556
1582
  urlFileAnnounced = true;
1557
1583
  lastUrlFile = file;
1558
1584
  log(`url file: ${file}`);
@@ -1604,7 +1630,7 @@ function createEngine(deps) {
1604
1630
  attach.ttlHours = st.opts.ttlHours;
1605
1631
  const sent = wsSend(r, { op: "init", attach });
1606
1632
  if (sent)
1607
- log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1633
+ logStatus(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1608
1634
  }
1609
1635
  };
1610
1636
  const deleteAbandoned = (r) => {
package/dist/config.d.ts CHANGED
@@ -36,9 +36,14 @@ export type ShareConfig = {
36
36
  * "json" (one JSON object per line: id, url, status, gated) for CI
37
37
  * consumers that want machine-readable lines without parsing URLs. */
38
38
  urlFileFormat: "url" | "json";
39
- /** false silences the `[openshare] live:` (and `url file:`) stderr
40
- * lines. The share itself is unaffected. */
41
- announceStderr: boolean;
39
+ /** Stderr ladder with two independently controllable line classes:
40
+ * announce lines (`live:`, `url file:`) and status lines (mode banner,
41
+ * enrollment, adoption notes). "all" (default) prints both; "status"
42
+ * prints status lines only - the share URL never touches stderr
43
+ * (public CI logs); "url" prints announce lines only (private runners
44
+ * scraping the URL); "off" prints neither. Error-class lines ALWAYS
45
+ * reach stderr. */
46
+ announceStderr: "all" | "status" | "url" | "off";
42
47
  /** Wave 3 redaction: user-supplied regex rules (client-side only)
43
48
  * applied beside the built-in detectors on UNGATED shares. */
44
49
  redact?: {
package/dist/config.js CHANGED
@@ -42,7 +42,7 @@ export const DEFAULTS = {
42
42
  urlFile: "",
43
43
  urlFileFormat: "url",
44
44
  configSet: "",
45
- announceStderr: true,
45
+ announceStderr: "all",
46
46
  };
47
47
  const CONFIG_NAME = "openshare.config.json";
48
48
  const LOCAL_NAME = "openshare.local.json";
@@ -331,6 +331,24 @@ export function deleteServerEntry(url, target) {
331
331
  return undefined;
332
332
  }
333
333
  }
334
+ /** Coerce an announceStderr value from any layer (enum string, legacy
335
+ * boolean, truthy/falsy word) to the 4-position ladder. */
336
+ function normalizeAnnounce(value, fallback) {
337
+ if (value === true)
338
+ return "all";
339
+ if (value === false)
340
+ return "off";
341
+ if (typeof value === "string") {
342
+ const v = value.trim().toLowerCase();
343
+ if (v === "all" || v === "status" || v === "url" || v === "off")
344
+ return v;
345
+ if (/^(1|true|yes|on)$/.test(v))
346
+ return "all";
347
+ if (/^(0|false|no)$/.test(v))
348
+ return "off";
349
+ }
350
+ return fallback;
351
+ }
334
352
  /** Load the effective config. `options` is the plugin-array options tuple
335
353
  * object (env still wins over it). Files MERGE lowest→highest priority
336
354
  * (later files override per key); empty strings in credential/path keys
@@ -460,12 +478,17 @@ export function loadConfig(pluginDir, options) {
460
478
  if (env.OPENSHARE_SPOOL_DIR)
461
479
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
462
480
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
463
- cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim());
481
+ cfg.announceStderr = env.OPENSHARE_ANNOUNCE_STDERR;
464
482
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual")
465
483
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
466
484
  // normalize the disable sentinel (case-insensitive from any source)
467
485
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
468
486
  cfg.urlFile = URL_FILE_NONE;
487
+ // announceStderr may arrive from ANY layer as the enum string or a
488
+ // legacy boolean/word (old saved configs carry true/false) - coerce the
489
+ // merged value once; unknown values fall back to the default ladder
490
+ // position
491
+ cfg.announceStderr = normalizeAnnounce(cfg.announceStderr, "all");
469
492
  // URL hygiene: a trailing slash on a server url becomes
470
493
  // wss://host//api/ws and https://host//s/<id> downstream - strip once,
471
494
  // at the source, so every consumer agrees
package/dist/core.js CHANGED
@@ -61,8 +61,11 @@ const DEFAULT_POLICY = {
61
61
  // Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
62
62
  // only. Informational lines (live:/url file:/mode banner, lifecycle
63
63
  // traces) are suppressed because the TUI surfaces the URL itself.
64
- // All-mode (CI/server-only) keeps its stderr contract (announce lines,
65
- // silenceable via announceStderr). Error-class lines always reach stderr.
64
+ // All-mode (CI/server-only) keeps its stderr contract, layered by the
65
+ // announceStderr ladder: "all" = announce + status lines, "status" =
66
+ // status lines only (the share URL never touches stderr), "url" =
67
+ // announce lines only, "off" = neither. Error-class lines always reach
68
+ // stderr.
66
69
  let stderrInfoAllowed = true;
67
70
  const log = (...a) => {
68
71
  if (!stderrInfoAllowed)
@@ -185,9 +188,19 @@ export function engineTargetUrls() {
185
188
  export function createEngine(deps) {
186
189
  let cfg = loadConfig(deps.pluginDir, deps.options);
187
190
  const mode = resolveShareMode(cfg, deps);
188
- // stderr = errors only when the TUI part exists (the TUI surfaces URLs)
191
+ // Stderr = errors only when the TUI part exists (the TUI surfaces URLs)
189
192
  if (mode === "manual")
190
193
  stderrInfoAllowed = false;
194
+ // The ladder controls two line classes independently: announce lines
195
+ // (live:, url file:) print at all/url; status lines (mode banner,
196
+ // enrollment, adoption notes) print at all/status. "status" is the
197
+ // public-CI shape - the share URL never reaches the log.
198
+ const wantAnnounce = () => cfg.announceStderr === "all" || cfg.announceStderr === "url";
199
+ const logStatus = (...a) => {
200
+ if (cfg.announceStderr !== "all" && cfg.announceStderr !== "status")
201
+ return;
202
+ log(...a);
203
+ };
191
204
  if (process.env.OPENSHARE_DEBUG_FILE) {
192
205
  // diagnostic ground truth for the suites (never set in production)
193
206
  try {
@@ -203,7 +216,7 @@ export function createEngine(deps) {
203
216
  }
204
217
  const server = resolveServer(cfg);
205
218
  if (!server || !server.url) {
206
- log("disabled: no share server configured (workerUrl or a servers entry)");
219
+ logStatus("disabled: no share server configured (workerUrl or a servers entry)");
207
220
  return {
208
221
  mode,
209
222
  config: () => cfg,
@@ -213,7 +226,7 @@ export function createEngine(deps) {
213
226
  },
214
227
  };
215
228
  }
216
- log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
229
+ logStatus(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
217
230
  const resolveTargets = () => {
218
231
  const merged = {};
219
232
  const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
@@ -224,7 +237,7 @@ export function createEngine(deps) {
224
237
  if (cfg.workerUrl && (!hasServers || envWorker))
225
238
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
226
239
  if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
227
- log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
240
+ logStatus(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
228
241
  }
229
242
  if (cfg.servers)
230
243
  for (const [u, e] of Object.entries(cfg.servers))
@@ -314,12 +327,12 @@ export function createEngine(deps) {
314
327
  if (adoptModel && doc.modelLabel === undefined) {
315
328
  doc.modelLabel = wantModel;
316
329
  dirty = true;
317
- log(`${url}: adopted modelLabel from the server config set`);
330
+ logStatus(`${url}: adopted modelLabel from the server config set`);
318
331
  }
319
332
  if (adoptMode && doc.shareMode === undefined) {
320
333
  doc.shareMode = wantMode;
321
334
  dirty = true;
322
- log(`${url}: adopted shareMode from the server config set`);
335
+ logStatus(`${url}: adopted shareMode from the server config set`);
323
336
  }
324
337
  if (dirty)
325
338
  writeFileSync(home, JSON.stringify(doc, null, 2) + "\n");
@@ -372,7 +385,7 @@ export function createEngine(deps) {
372
385
  c.credential = b.token;
373
386
  c.error = "";
374
387
  const savedTo = saveServerToken(url, c.credential);
375
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
388
+ logStatus(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
376
389
  void adoptServerSuggestions(url, c.credential);
377
390
  return c.credential;
378
391
  }
@@ -540,7 +553,7 @@ export function createEngine(deps) {
540
553
  const after = targets.map((t) => t.url);
541
554
  const added = after.filter((u) => !before.has(u));
542
555
  if (added.length)
543
- log(`fan-out set grew: ${added.join(", ")}`);
556
+ logStatus(`fan-out set grew: ${added.join(", ")}`);
544
557
  }
545
558
  }
546
559
  };
@@ -765,10 +778,14 @@ export function createEngine(deps) {
765
778
  const scheduleBindingWrite = (st) => {
766
779
  if (st.metaTimer)
767
780
  return;
781
+ // env knob exists for the mock suites: their two-remote scenarios
782
+ // assert the SETTLED binding, and waiting out the production 30s
783
+ // coalescing window per scenario would dominate suite time
784
+ const debounce = Number(process.env.OPENSHARE_BINDING_WRITE_MS) > 0 ? Number(process.env.OPENSHARE_BINDING_WRITE_MS) : 30000;
768
785
  st.metaTimer = setTimeout(() => {
769
786
  st.metaTimer = null;
770
787
  chain = chain.then(() => writeBinding(st)).catch(() => { });
771
- }, 30000);
788
+ }, debounce);
772
789
  };
773
790
  // ---- spool (temporary buffer for unacked frames) ----------------------
774
791
  // One spool per session per server: `<sid>.<h8(url)>.meta/queue`. The
@@ -1010,7 +1027,7 @@ export function createEngine(deps) {
1010
1027
  return;
1011
1028
  r.announced = true;
1012
1029
  const url = `${r.url}/s/${r.shareId}`;
1013
- if (cfg.announceStderr)
1030
+ if (wantAnnounce())
1014
1031
  log(`live: ${url}`);
1015
1032
  if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
1016
1033
  // default: the file lives where the session lives (its directory,
@@ -1038,7 +1055,7 @@ export function createEngine(deps) {
1038
1055
  }) + "\n"
1039
1056
  : url + "\n";
1040
1057
  appendFileSync(file, line);
1041
- if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1058
+ if (wantAnnounce() && (file !== lastUrlFile || !urlFileAnnounced)) {
1042
1059
  urlFileAnnounced = true;
1043
1060
  lastUrlFile = file;
1044
1061
  log(`url file: ${file}`);
@@ -1109,7 +1126,7 @@ export function createEngine(deps) {
1109
1126
  attach.ttlHours = st.opts.ttlHours;
1110
1127
  const sent = wsSend(r, { op: "init", attach });
1111
1128
  if (sent)
1112
- log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1129
+ logStatus(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1113
1130
  }
1114
1131
  };
1115
1132
  /** Best-effort cleanup of a share we are about to abandon (refused
package/dist/settings.js CHANGED
@@ -131,12 +131,12 @@ const LABELS = {
131
131
  },
132
132
  announceStderr: {
133
133
  label: "Announce on stderr",
134
- help: '"true"/"false": the [openshare] live: and url file: log lines. false silences them; the share itself is unaffected.',
134
+ help: '"all" | "status" | "url" | "off": which [openshare] info lines reach stderr.',
135
135
  kind: "text",
136
- options: ["true", "false"],
136
+ options: ["all", "status", "url", "off"],
137
137
  section: "Advanced",
138
- info: "Whether the informational [openshare] lines reach stderr in all-mode (CI logs). Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
139
- def: "true",
138
+ info: "The stderr ladder for informational [openshare] lines (all-mode/CI). all: announce lines (live:/url file:) plus status lines (mode banner, enrollment, adoption notes). status: status lines only - the share URL never touches stderr (public CI logs). url: announce lines only (private runners scraping the share URL). off: no info lines. Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
139
+ def: "all",
140
140
  },
141
141
  };
142
142
  /** [i] pane text for a config key row: long info + live value + source + default. */
@@ -156,8 +156,6 @@ function helpForKey(key, cfg, sources) {
156
156
  function displayValue(key, cfg) {
157
157
  if (key === "createToken" || key === "authPassword")
158
158
  return mask(String(cfg[key] ?? ""));
159
- if (key === "announceStderr")
160
- return cfg.announceStderr !== false ? "on" : "off";
161
159
  const value = cfg[key];
162
160
  return value === undefined || value === "" ? "(not set)" : String(value);
163
161
  }
@@ -183,8 +181,6 @@ async function editKey(host, cfg, key) {
183
181
  const next = { ...cfg };
184
182
  if (key === "shareMode")
185
183
  next[key] = picked === "auto" ? undefined : picked;
186
- else if (key === "announceStderr")
187
- next[key] = picked === "true";
188
184
  else
189
185
  next[key] = picked;
190
186
  return next;
package/dist/tui.js CHANGED
@@ -29,7 +29,7 @@ var DEFAULTS = {
29
29
  urlFile: "",
30
30
  urlFileFormat: "url",
31
31
  configSet: "",
32
- announceStderr: true
32
+ announceStderr: "all"
33
33
  };
34
34
  var CONFIG_NAME = "openshare.config.json";
35
35
  var LOCAL_NAME = "openshare.local.json";
@@ -260,6 +260,22 @@ function deleteServerEntry(url, target) {
260
260
  return;
261
261
  }
262
262
  }
263
+ function normalizeAnnounce(value, fallback) {
264
+ if (value === true)
265
+ return "all";
266
+ if (value === false)
267
+ return "off";
268
+ if (typeof value === "string") {
269
+ const v = value.trim().toLowerCase();
270
+ if (v === "all" || v === "status" || v === "url" || v === "off")
271
+ return v;
272
+ if (/^(1|true|yes|on)$/.test(v))
273
+ return "all";
274
+ if (/^(0|false|no)$/.test(v))
275
+ return "off";
276
+ }
277
+ return fallback;
278
+ }
263
279
  function loadConfig(pluginDir, options) {
264
280
  const cfg = { ...DEFAULTS };
265
281
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
@@ -367,11 +383,12 @@ function loadConfig(pluginDir, options) {
367
383
  if (env.OPENSHARE_SPOOL_DIR)
368
384
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
369
385
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
370
- cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim());
386
+ cfg.announceStderr = env.OPENSHARE_ANNOUNCE_STDERR;
371
387
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual")
372
388
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
373
389
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
374
390
  cfg.urlFile = URL_FILE_NONE;
391
+ cfg.announceStderr = normalizeAnnounce(cfg.announceStderr, "all");
375
392
  if (cfg.workerUrl)
376
393
  cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
377
394
  if (cfg.servers) {
@@ -1247,12 +1264,12 @@ Manual mode (TUI) never writes it \u2014 the link lives in the TUI.`,
1247
1264
  },
1248
1265
  announceStderr: {
1249
1266
  label: "Announce on stderr",
1250
- help: '"true"/"false": the [openshare] live: and url file: log lines. false silences them; the share itself is unaffected.',
1267
+ help: '"all" | "status" | "url" | "off": which [openshare] info lines reach stderr.',
1251
1268
  kind: "text",
1252
- options: ["true", "false"],
1269
+ options: ["all", "status", "url", "off"],
1253
1270
  section: "Advanced",
1254
- info: "Whether the informational [openshare] lines reach stderr in all-mode (CI logs). Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
1255
- def: "true"
1271
+ info: "The stderr ladder for informational [openshare] lines (all-mode/CI). all: announce lines (live:/url file:) plus status lines (mode banner, enrollment, adoption notes). status: status lines only - the share URL never touches stderr (public CI logs). url: announce lines only (private runners scraping the share URL). off: no info lines. Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
1272
+ def: "all"
1256
1273
  }
1257
1274
  };
1258
1275
  function helpForKey(key, cfg, sources) {
@@ -1272,8 +1289,6 @@ function helpForKey(key, cfg, sources) {
1272
1289
  function displayValue(key, cfg) {
1273
1290
  if (key === "createToken" || key === "authPassword")
1274
1291
  return mask(String(cfg[key] ?? ""));
1275
- if (key === "announceStderr")
1276
- return cfg.announceStderr !== false ? "on" : "off";
1277
1292
  const value = cfg[key];
1278
1293
  return value === undefined || value === "" ? "(not set)" : String(value);
1279
1294
  }
@@ -1297,8 +1312,6 @@ async function editKey(host, cfg, key) {
1297
1312
  const next = { ...cfg };
1298
1313
  if (key === "shareMode")
1299
1314
  next[key] = picked === "auto" ? undefined : picked;
1300
- else if (key === "announceStderr")
1301
- next[key] = picked === "true";
1302
1315
  else
1303
1316
  next[key] = picked;
1304
1317
  return next;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-openshare",
3
- "version": "0.3.0-dev.13",
3
+ "version": "0.3.0-dev.15",
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
@@ -21,5 +21,7 @@
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
22
  // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
- // announceStderr.
24
+ // announceStderr ("all" | "status" | "url" | "off"; status keeps the
25
+ // status/banner lines and never prints the share URL - the public-CI
26
+ // shape; url prints announce lines only).
25
27
  //
package/src/config.ts CHANGED
@@ -62,9 +62,14 @@ export type ShareConfig = {
62
62
  * "json" (one JSON object per line: id, url, status, gated) for CI
63
63
  * consumers that want machine-readable lines without parsing URLs. */
64
64
  urlFileFormat: "url" | "json"
65
- /** false silences the `[openshare] live:` (and `url file:`) stderr
66
- * lines. The share itself is unaffected. */
67
- announceStderr: boolean
65
+ /** Stderr ladder with two independently controllable line classes:
66
+ * announce lines (`live:`, `url file:`) and status lines (mode banner,
67
+ * enrollment, adoption notes). "all" (default) prints both; "status"
68
+ * prints status lines only - the share URL never touches stderr
69
+ * (public CI logs); "url" prints announce lines only (private runners
70
+ * scraping the URL); "off" prints neither. Error-class lines ALWAYS
71
+ * reach stderr. */
72
+ announceStderr: "all" | "status" | "url" | "off"
68
73
  /** Wave 3 redaction: user-supplied regex rules (client-side only)
69
74
  * applied beside the built-in detectors on UNGATED shares. */
70
75
  redact?: { patterns?: string[] }
@@ -103,7 +108,7 @@ export const DEFAULTS: ShareConfig = {
103
108
  urlFile: "",
104
109
  urlFileFormat: "url",
105
110
  configSet: "",
106
- announceStderr: true,
111
+ announceStderr: "all",
107
112
  }
108
113
 
109
114
  const CONFIG_NAME = "openshare.config.json"
@@ -369,6 +374,20 @@ export function deleteServerEntry(url: string, target?: string): string | undefi
369
374
  }
370
375
  }
371
376
 
377
+ /** Coerce an announceStderr value from any layer (enum string, legacy
378
+ * boolean, truthy/falsy word) to the 4-position ladder. */
379
+ function normalizeAnnounce(value: unknown, fallback: "all" | "status" | "url" | "off"): "all" | "status" | "url" | "off" {
380
+ if (value === true) return "all"
381
+ if (value === false) return "off"
382
+ if (typeof value === "string") {
383
+ const v = value.trim().toLowerCase()
384
+ if (v === "all" || v === "status" || v === "url" || v === "off") return v
385
+ if (/^(1|true|yes|on)$/.test(v)) return "all"
386
+ if (/^(0|false|no)$/.test(v)) return "off"
387
+ }
388
+ return fallback
389
+ }
390
+
372
391
  /** Load the effective config. `options` is the plugin-array options tuple
373
392
  * object (env still wins over it). Files MERGE lowest→highest priority
374
393
  * (later files override per key); empty strings in credential/path keys
@@ -473,12 +492,18 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
473
492
  if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url") cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT
474
493
  if (env.OPENSHARE_CONFIG_SET) cfg.configSet = env.OPENSHARE_CONFIG_SET
475
494
  if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
476
- if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
495
+ if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = env.OPENSHARE_ANNOUNCE_STDERR as ShareConfig["announceStderr"]
477
496
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
478
497
 
479
498
  // normalize the disable sentinel (case-insensitive from any source)
480
499
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
481
500
 
501
+ // announceStderr may arrive from ANY layer as the enum string or a
502
+ // legacy boolean/word (old saved configs carry true/false) - coerce the
503
+ // merged value once; unknown values fall back to the default ladder
504
+ // position
505
+ cfg.announceStderr = normalizeAnnounce(cfg.announceStderr, "all")
506
+
482
507
  // URL hygiene: a trailing slash on a server url becomes
483
508
  // wss://host//api/ws and https://host//s/<id> downstream - strip once,
484
509
  // at the source, so every consumer agrees
package/src/core.ts CHANGED
@@ -233,8 +233,11 @@ export type Engine = {
233
233
  // Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
234
234
  // only. Informational lines (live:/url file:/mode banner, lifecycle
235
235
  // traces) are suppressed because the TUI surfaces the URL itself.
236
- // All-mode (CI/server-only) keeps its stderr contract (announce lines,
237
- // silenceable via announceStderr). Error-class lines always reach stderr.
236
+ // All-mode (CI/server-only) keeps its stderr contract, layered by the
237
+ // announceStderr ladder: "all" = announce + status lines, "status" =
238
+ // status lines only (the share URL never touches stderr), "url" =
239
+ // announce lines only, "off" = neither. Error-class lines always reach
240
+ // stderr.
238
241
  let stderrInfoAllowed = true
239
242
 
240
243
  const log = (...a: unknown[]) => {
@@ -351,8 +354,17 @@ export function engineTargetUrls(): string[] {
351
354
  export function createEngine(deps: EngineDeps): Engine {
352
355
  let cfg = loadConfig(deps.pluginDir, deps.options)
353
356
  const mode: ShareMode = resolveShareMode(cfg, deps)
354
- // stderr = errors only when the TUI part exists (the TUI surfaces URLs)
357
+ // Stderr = errors only when the TUI part exists (the TUI surfaces URLs)
355
358
  if (mode === "manual") stderrInfoAllowed = false
359
+ // The ladder controls two line classes independently: announce lines
360
+ // (live:, url file:) print at all/url; status lines (mode banner,
361
+ // enrollment, adoption notes) print at all/status. "status" is the
362
+ // public-CI shape - the share URL never reaches the log.
363
+ const wantAnnounce = () => cfg.announceStderr === "all" || cfg.announceStderr === "url"
364
+ const logStatus = (...a: unknown[]) => {
365
+ if (cfg.announceStderr !== "all" && cfg.announceStderr !== "status") return
366
+ log(...a)
367
+ }
356
368
  if (process.env.OPENSHARE_DEBUG_FILE) {
357
369
  // diagnostic ground truth for the suites (never set in production)
358
370
  try {
@@ -374,7 +386,7 @@ export function createEngine(deps: EngineDeps): Engine {
374
386
  }
375
387
  const server = resolveServer(cfg)
376
388
  if (!server || !server.url) {
377
- log("disabled: no share server configured (workerUrl or a servers entry)")
389
+ logStatus("disabled: no share server configured (workerUrl or a servers entry)")
378
390
  return {
379
391
  mode,
380
392
  config: () => cfg,
@@ -384,7 +396,7 @@ export function createEngine(deps: EngineDeps): Engine {
384
396
  },
385
397
  }
386
398
  }
387
- log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`)
399
+ logStatus(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`)
388
400
 
389
401
  // ---- share servers (the fan-out set) -----------------------------------
390
402
  // Every entry flagged active:true shares new sessions; when none is
@@ -401,7 +413,7 @@ export function createEngine(deps: EngineDeps): Engine {
401
413
  const envWorker = !!process.env.OPENSHARE_WORKER_URL
402
414
  if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
403
415
  if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
404
- log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
416
+ logStatus(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
405
417
  }
406
418
  if (cfg.servers) for (const [u, e] of Object.entries(cfg.servers)) merged[u] = { ...merged[u], ...e }
407
419
  const urls = Object.keys(merged)
@@ -480,8 +492,8 @@ export function createEngine(deps: EngineDeps): Engine {
480
492
  if (!doc || typeof doc !== "object") doc = {}
481
493
  } catch {}
482
494
  let dirty = false
483
- if (adoptModel && doc.modelLabel === undefined) { doc.modelLabel = wantModel; dirty = true; log(`${url}: adopted modelLabel from the server config set`) }
484
- if (adoptMode && doc.shareMode === undefined) { doc.shareMode = wantMode; dirty = true; log(`${url}: adopted shareMode from the server config set`) }
495
+ if (adoptModel && doc.modelLabel === undefined) { doc.modelLabel = wantModel; dirty = true; logStatus(`${url}: adopted modelLabel from the server config set`) }
496
+ if (adoptMode && doc.shareMode === undefined) { doc.shareMode = wantMode; dirty = true; logStatus(`${url}: adopted shareMode from the server config set`) }
485
497
  if (dirty) writeFileSync(home, JSON.stringify(doc, null, 2) + "\n")
486
498
  } catch {
487
499
  // suggestions are best-effort; a failed fetch never blocks sharing
@@ -530,7 +542,7 @@ export function createEngine(deps: EngineDeps): Engine {
530
542
  c.credential = b.token
531
543
  c.error = ""
532
544
  const savedTo = saveServerToken(url, c.credential)
533
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
545
+ logStatus(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
534
546
  void adoptServerSuggestions(url, c.credential)
535
547
  return c.credential
536
548
  }
@@ -700,7 +712,7 @@ export function createEngine(deps: EngineDeps): Engine {
700
712
  if (debugEvents) {
701
713
  const after = targets.map((t) => t.url)
702
714
  const added = after.filter((u) => !before.has(u))
703
- if (added.length) log(`fan-out set grew: ${added.join(", ")}`)
715
+ if (added.length) logStatus(`fan-out set grew: ${added.join(", ")}`)
704
716
  }
705
717
  }
706
718
  }
@@ -913,10 +925,14 @@ export function createEngine(deps: EngineDeps): Engine {
913
925
 
914
926
  const scheduleBindingWrite = (st: SessionState) => {
915
927
  if (st.metaTimer) return
928
+ // env knob exists for the mock suites: their two-remote scenarios
929
+ // assert the SETTLED binding, and waiting out the production 30s
930
+ // coalescing window per scenario would dominate suite time
931
+ const debounce = Number(process.env.OPENSHARE_BINDING_WRITE_MS) > 0 ? Number(process.env.OPENSHARE_BINDING_WRITE_MS) : 30000
916
932
  st.metaTimer = setTimeout(() => {
917
933
  st.metaTimer = null
918
934
  chain = chain.then(() => writeBinding(st)).catch(() => {})
919
- }, 30000)
935
+ }, debounce)
920
936
  }
921
937
 
922
938
  // ---- spool (temporary buffer for unacked frames) ----------------------
@@ -1142,7 +1158,7 @@ export function createEngine(deps: EngineDeps): Engine {
1142
1158
  if (r.announced) return
1143
1159
  r.announced = true
1144
1160
  const url = `${r.url}/s/${r.shareId}`
1145
- if (cfg.announceStderr) log(`live: ${url}`)
1161
+ if (wantAnnounce()) log(`live: ${url}`)
1146
1162
  if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
1147
1163
  // default: the file lives where the session lives (its directory,
1148
1164
  // falling back to the process cwd) — never a fixed global path.
@@ -1170,7 +1186,7 @@ export function createEngine(deps: EngineDeps): Engine {
1170
1186
  }) + "\n"
1171
1187
  : url + "\n"
1172
1188
  appendFileSync(file, line)
1173
- if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1189
+ if (wantAnnounce() && (file !== lastUrlFile || !urlFileAnnounced)) {
1174
1190
  urlFileAnnounced = true
1175
1191
  lastUrlFile = file
1176
1192
  log(`url file: ${file}`)
@@ -1237,7 +1253,7 @@ export function createEngine(deps: EngineDeps): Engine {
1237
1253
  const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
1238
1254
  if (st.opts?.ttlHours) attach.ttlHours = st.opts.ttlHours
1239
1255
  const sent = wsSend(r, { op: "init", attach })
1240
- if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
1256
+ if (sent) logStatus(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
1241
1257
  }
1242
1258
  }
1243
1259
 
package/src/settings.ts CHANGED
@@ -191,12 +191,12 @@ const LABELS: Record<string, LabelMeta> = {
191
191
  },
192
192
  announceStderr: {
193
193
  label: "Announce on stderr",
194
- help: '"true"/"false": the [openshare] live: and url file: log lines. false silences them; the share itself is unaffected.',
194
+ help: '"all" | "status" | "url" | "off": which [openshare] info lines reach stderr.',
195
195
  kind: "text",
196
- options: ["true", "false"],
196
+ options: ["all", "status", "url", "off"],
197
197
  section: "Advanced",
198
- info: "Whether the informational [openshare] lines reach stderr in all-mode (CI logs). Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
199
- def: "true",
198
+ info: "The stderr ladder for informational [openshare] lines (all-mode/CI). all: announce lines (live:/url file:) plus status lines (mode banner, enrollment, adoption notes). status: status lines only - the share URL never touches stderr (public CI logs). url: announce lines only (private runners scraping the share URL). off: no info lines. Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
199
+ def: "all",
200
200
  },
201
201
  }
202
202
 
@@ -214,7 +214,6 @@ function helpForKey(key: string, cfg: ShareConfig, sources: Record<string, strin
214
214
 
215
215
  function displayValue(key: string, cfg: ShareConfig): string {
216
216
  if (key === "createToken" || key === "authPassword") return mask(String(cfg[key] ?? ""))
217
- if (key === "announceStderr") return cfg.announceStderr !== false ? "on" : "off"
218
217
  const value = (cfg as unknown as Record<string, unknown>)[key]
219
218
  return value === undefined || value === "" ? "(not set)" : String(value)
220
219
  }
@@ -238,7 +237,6 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
238
237
  if (picked === undefined) return undefined
239
238
  const next = { ...cfg } as unknown as Record<string, unknown>
240
239
  if (key === "shareMode") next[key] = picked === "auto" ? undefined : picked
241
- else if (key === "announceStderr") next[key] = picked === "true"
242
240
  else next[key] = picked
243
241
  return next as ShareConfig
244
242
  }