@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -26
- package/ci/README.md +30 -14
- package/ci/openshare.js +440 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +34 -14
- package/dist/config.js +108 -33
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +12 -1
- package/dist/core.js +359 -138
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +10 -7
- package/dist/settings.js +136 -54
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +251 -59
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +112 -29
- package/src/contract.ts +5 -5
- package/src/core.ts +339 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +132 -52
- package/src/share-flow.ts +76 -20
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/dist/tui.js
CHANGED
|
@@ -22,11 +22,13 @@ var DEFAULTS = {
|
|
|
22
22
|
debounceMs: 1e4,
|
|
23
23
|
completedDelayMs: 5000,
|
|
24
24
|
modelLabel: "$Provider/$Model",
|
|
25
|
-
|
|
25
|
+
ttlHours: 90 * 24,
|
|
26
26
|
authLogin: "",
|
|
27
27
|
authPassword: "",
|
|
28
28
|
spoolDir: "",
|
|
29
29
|
urlFile: "",
|
|
30
|
+
urlFileFormat: "url",
|
|
31
|
+
configSet: "",
|
|
30
32
|
announceStderr: true
|
|
31
33
|
};
|
|
32
34
|
var CONFIG_NAME = "openshare.config.json";
|
|
@@ -97,10 +99,13 @@ function configSourceMap(pluginDir, env = process.env) {
|
|
|
97
99
|
debounceMs: env.OPENSHARE_DEBOUNCE_MS,
|
|
98
100
|
completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
|
|
99
101
|
modelLabel: env.OPENSHARE_MODEL_LABEL,
|
|
102
|
+
ttlHours: env.OPENSHARE_TTL_HOURS,
|
|
100
103
|
ttlDays: env.OPENSHARE_TTL_DAYS,
|
|
101
104
|
authLogin: env.OPENSHARE_AUTH_LOGIN,
|
|
102
105
|
authPassword: env.OPENSHARE_AUTH_PASSWORD,
|
|
103
106
|
urlFile: env.OPENSHARE_URL_FILE,
|
|
107
|
+
urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
|
|
108
|
+
configSet: env.OPENSHARE_CONFIG_SET,
|
|
104
109
|
spoolDir: env.OPENSHARE_SPOOL_DIR,
|
|
105
110
|
announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
|
|
106
111
|
shareMode: env.OPENSHARE_SHARE_MODE
|
|
@@ -109,6 +114,24 @@ function configSourceMap(pluginDir, env = process.env) {
|
|
|
109
114
|
if (value !== undefined && value !== "")
|
|
110
115
|
sources[key] = "environment";
|
|
111
116
|
}
|
|
117
|
+
if (sources.ttlDays && !sources.ttlHours)
|
|
118
|
+
sources.ttlHours = sources.ttlDays;
|
|
119
|
+
delete sources.ttlDays;
|
|
120
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON);
|
|
123
|
+
if (raw && typeof raw === "object") {
|
|
124
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
125
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
126
|
+
continue;
|
|
127
|
+
if (sources[key] === "environment")
|
|
128
|
+
continue;
|
|
129
|
+
if (key === "servers" || known.has(key))
|
|
130
|
+
sources[key] = "OPENSHARE_CONFIG_JSON";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
112
135
|
for (const path of configPaths(pluginDir, env)) {
|
|
113
136
|
let raw;
|
|
114
137
|
try {
|
|
@@ -162,6 +185,8 @@ function normalizeServers(raw) {
|
|
|
162
185
|
entry.adminToken = e.adminToken;
|
|
163
186
|
if (e.active === true)
|
|
164
187
|
entry.active = true;
|
|
188
|
+
if (e.anonymous === true)
|
|
189
|
+
entry.anonymous = true;
|
|
165
190
|
out[url] = entry;
|
|
166
191
|
}
|
|
167
192
|
return Object.keys(out).length ? out : undefined;
|
|
@@ -231,26 +256,53 @@ function loadConfig(pluginDir, options) {
|
|
|
231
256
|
const cfg = { ...DEFAULTS };
|
|
232
257
|
const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
|
|
233
258
|
const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
259
|
+
const applyObject = (raw) => {
|
|
260
|
+
if (!raw || typeof raw !== "object")
|
|
261
|
+
return;
|
|
262
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
263
|
+
if (key === "servers") {
|
|
264
|
+
const servers = normalizeServers(value);
|
|
265
|
+
if (servers)
|
|
266
|
+
cfg.servers = mergeServerGroups(cfg.servers, servers);
|
|
238
267
|
continue;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
continue;
|
|
268
|
+
}
|
|
269
|
+
if (key === "redact") {
|
|
270
|
+
if (value && typeof value === "object" && Array.isArray(value.patterns)) {
|
|
271
|
+
const pats = value.patterns.filter((x) => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50);
|
|
272
|
+
cfg.redact = { patterns: pats };
|
|
245
273
|
}
|
|
246
|
-
|
|
247
|
-
continue;
|
|
248
|
-
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
249
|
-
continue;
|
|
250
|
-
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
251
|
-
continue;
|
|
252
|
-
cfg[key] = value;
|
|
274
|
+
continue;
|
|
253
275
|
}
|
|
276
|
+
if (key === "meta") {
|
|
277
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
278
|
+
const clean = {};
|
|
279
|
+
for (const [mk, mv] of Object.entries(value)) {
|
|
280
|
+
if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64)
|
|
281
|
+
clean[mk] = mv;
|
|
282
|
+
}
|
|
283
|
+
if (Object.keys(clean).length)
|
|
284
|
+
cfg.meta = { ...cfg.meta, ...clean };
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (key === "ttlDays") {
|
|
289
|
+
const d = Math.floor(Number(value));
|
|
290
|
+
if (Number.isFinite(d) && d > 0)
|
|
291
|
+
cfg.ttlHours = d * 24;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (!known.has(key))
|
|
295
|
+
continue;
|
|
296
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
297
|
+
continue;
|
|
298
|
+
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
299
|
+
continue;
|
|
300
|
+
cfg[key] = value;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
for (const path of [...configPaths(pluginDir)].reverse()) {
|
|
304
|
+
try {
|
|
305
|
+
applyObject(JSON.parse(readFileSync(path, "utf8")));
|
|
254
306
|
} catch (e) {
|
|
255
307
|
if (existsSync(path))
|
|
256
308
|
console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
|
|
@@ -273,6 +325,13 @@ function loadConfig(pluginDir, options) {
|
|
|
273
325
|
cfg.shareMode = mode;
|
|
274
326
|
}
|
|
275
327
|
const env = process.env;
|
|
328
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
329
|
+
try {
|
|
330
|
+
applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON));
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", e?.message ?? e);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
276
335
|
if (env.OPENSHARE_WORKER_URL)
|
|
277
336
|
cfg.workerUrl = env.OPENSHARE_WORKER_URL;
|
|
278
337
|
if (env.OPENSHARE_CREATE_TOKEN)
|
|
@@ -283,14 +342,20 @@ function loadConfig(pluginDir, options) {
|
|
|
283
342
|
cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
|
|
284
343
|
if (env.OPENSHARE_MODEL_LABEL !== undefined)
|
|
285
344
|
cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
|
|
345
|
+
if (env.OPENSHARE_TTL_HOURS)
|
|
346
|
+
cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
|
|
286
347
|
if (env.OPENSHARE_TTL_DAYS)
|
|
287
|
-
cfg.
|
|
348
|
+
cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
|
|
288
349
|
if (env.OPENSHARE_AUTH_LOGIN !== undefined)
|
|
289
350
|
cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
|
|
290
351
|
if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
|
|
291
352
|
cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
|
|
292
353
|
if (env.OPENSHARE_URL_FILE !== undefined)
|
|
293
354
|
cfg.urlFile = env.OPENSHARE_URL_FILE;
|
|
355
|
+
if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url")
|
|
356
|
+
cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT;
|
|
357
|
+
if (env.OPENSHARE_CONFIG_SET)
|
|
358
|
+
cfg.configSet = env.OPENSHARE_CONFIG_SET;
|
|
294
359
|
if (env.OPENSHARE_SPOOL_DIR)
|
|
295
360
|
cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
|
|
296
361
|
if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
|
|
@@ -444,8 +509,18 @@ function queueBindingWrite(sid, fn) {
|
|
|
444
509
|
return run;
|
|
445
510
|
}
|
|
446
511
|
|
|
512
|
+
// src/redact.ts
|
|
513
|
+
var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
|
|
514
|
+
|
|
447
515
|
// src/core.ts
|
|
448
516
|
var liveTargets = [];
|
|
517
|
+
var statsProvider = null;
|
|
518
|
+
var redactionLedger = new Map;
|
|
519
|
+
function sessionStatsFor(sid) {
|
|
520
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
|
|
521
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
|
|
522
|
+
return { ...counts, redaction: led };
|
|
523
|
+
}
|
|
449
524
|
function engineTargetUrls() {
|
|
450
525
|
return liveTargets;
|
|
451
526
|
}
|
|
@@ -474,7 +549,7 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
474
549
|
"debounceMs",
|
|
475
550
|
"completedDelayMs",
|
|
476
551
|
"modelLabel",
|
|
477
|
-
"
|
|
552
|
+
"ttlHours",
|
|
478
553
|
"authLogin",
|
|
479
554
|
"authPassword",
|
|
480
555
|
"spoolDir",
|
|
@@ -562,6 +637,23 @@ function bindingsOf(info) {
|
|
|
562
637
|
async function readBindings(client, sid) {
|
|
563
638
|
return bindingsOf(await getSession(client, sid));
|
|
564
639
|
}
|
|
640
|
+
function fmtHours(hours) {
|
|
641
|
+
if (!Number.isFinite(hours) || hours <= 0)
|
|
642
|
+
return "?";
|
|
643
|
+
if (hours % 24 === 0)
|
|
644
|
+
return `${Math.floor(hours / 24)}d`;
|
|
645
|
+
return `${Math.floor(hours)}h`;
|
|
646
|
+
}
|
|
647
|
+
function parseTtlHours(raw) {
|
|
648
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
|
|
649
|
+
if (!m)
|
|
650
|
+
return;
|
|
651
|
+
const v = Number(m[1]);
|
|
652
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
653
|
+
return;
|
|
654
|
+
const unit = (m[2] ?? "h").toLowerCase();
|
|
655
|
+
return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
|
|
656
|
+
}
|
|
565
657
|
function hostOf(url) {
|
|
566
658
|
try {
|
|
567
659
|
return new URL(url).host;
|
|
@@ -683,6 +775,27 @@ async function workerDeleteShare(workerUrl, id, secret) {
|
|
|
683
775
|
return false;
|
|
684
776
|
}
|
|
685
777
|
}
|
|
778
|
+
async function patchShareOpts(workerUrl, id, opts, secret) {
|
|
779
|
+
const body = { secret };
|
|
780
|
+
if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
|
|
781
|
+
body.authLogin = opts.authLogin;
|
|
782
|
+
body.authPassword = opts.authPassword;
|
|
783
|
+
}
|
|
784
|
+
if (opts.ttlHours !== undefined)
|
|
785
|
+
body.ttlHours = opts.ttlHours;
|
|
786
|
+
try {
|
|
787
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
788
|
+
method: "PATCH",
|
|
789
|
+
headers: { "content-type": "application/json" },
|
|
790
|
+
body: JSON.stringify(body),
|
|
791
|
+
signal: AbortSignal.timeout(8000)
|
|
792
|
+
});
|
|
793
|
+
const json = await res.json().catch(() => ({}));
|
|
794
|
+
return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
|
|
795
|
+
} catch (e) {
|
|
796
|
+
return { ok: false, error: e?.message || String(e) };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
686
799
|
async function probeWorker(workerUrl) {
|
|
687
800
|
if (!workerUrl)
|
|
688
801
|
return { ok: false, detail: "workerUrl is empty" };
|
|
@@ -866,8 +979,10 @@ async function requestShareConsent(host, context) {
|
|
|
866
979
|
const bits = [];
|
|
867
980
|
if (context.server)
|
|
868
981
|
bits.push(`server: ${context.server}`);
|
|
869
|
-
if (context.
|
|
870
|
-
bits.push(`TTL: ${context.
|
|
982
|
+
if (context.ttlHours !== undefined)
|
|
983
|
+
bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
|
|
984
|
+
if (context.redaction && context.redaction.hits > 0)
|
|
985
|
+
bits.push(`redaction: ${context.redaction.hits} hit(s)`);
|
|
871
986
|
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
872
987
|
lines.push(bits.join(" - "));
|
|
873
988
|
}
|
|
@@ -895,12 +1010,18 @@ async function shareWithConsent(host, sessionID) {
|
|
|
895
1010
|
}
|
|
896
1011
|
const opts = all[0]?.opts;
|
|
897
1012
|
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1013
|
+
{
|
|
1014
|
+
const stats = sessionStatsFor(root);
|
|
1015
|
+
const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
|
|
1016
|
+
if (!await requestShareConsent(host, {
|
|
1017
|
+
server: server ? hostOf(server.url) : undefined,
|
|
1018
|
+
ttlHours: opts?.ttlHours ?? cfg.ttlHours,
|
|
1019
|
+
auth: gated,
|
|
1020
|
+
counts: { messages: stats.messages, toolCalls: stats.toolCalls },
|
|
1021
|
+
redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) }
|
|
1022
|
+
}))
|
|
1023
|
+
return { ok: false, message: "Share cancelled." };
|
|
1024
|
+
}
|
|
904
1025
|
return confirmShare(host, root);
|
|
905
1026
|
}
|
|
906
1027
|
async function confirmShare(host, rootSessionID) {
|
|
@@ -1070,15 +1191,15 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
|
|
|
1070
1191
|
info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
|
|
1071
1192
|
def: '"$Provider/$Model"'
|
|
1072
1193
|
},
|
|
1073
|
-
|
|
1074
|
-
label: "TTL (
|
|
1075
|
-
help: "Share lifetime
|
|
1194
|
+
ttlHours: {
|
|
1195
|
+
label: "TTL (hours)",
|
|
1196
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
1076
1197
|
kind: "number",
|
|
1077
1198
|
section: "Sharing",
|
|
1078
|
-
info: `How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's
|
|
1199
|
+
info: `How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.
|
|
1079
1200
|
|
|
1080
|
-
A per-session override lives in the This-session section; it is sent at create/attach time.`,
|
|
1081
|
-
def: "worker cap (
|
|
1201
|
+
A per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.`,
|
|
1202
|
+
def: "worker cap (2160h = 90d on the reference deployment)"
|
|
1082
1203
|
},
|
|
1083
1204
|
authLogin: {
|
|
1084
1205
|
label: "Read auth: login",
|
|
@@ -1203,26 +1324,74 @@ async function connectServerFlow(host) {
|
|
|
1203
1324
|
const url = normServerUrl(raw);
|
|
1204
1325
|
if (!/^https?:\/\//.test(url))
|
|
1205
1326
|
return { ok: false, message: "URL must start with http:// or https://" };
|
|
1206
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
1327
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
|
|
1207
1328
|
const probe = await probeAuthMode(url);
|
|
1208
1329
|
if (!probe.ok)
|
|
1209
1330
|
return { ok: false, message: `${url}: ${probe.detail}` };
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1331
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
|
|
1332
|
+
const deactivateOthers = () => {
|
|
1333
|
+
for (const other of serverEntries()) {
|
|
1334
|
+
if (other.url !== url)
|
|
1335
|
+
saveServerEntry(other.url, { active: undefined });
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
if (probe.mode === "public") {
|
|
1339
|
+
const picked = await showSelect(host.ui, {
|
|
1340
|
+
title: `${url} - account?`,
|
|
1341
|
+
options: [
|
|
1342
|
+
{
|
|
1343
|
+
value: "onboard",
|
|
1344
|
+
title: "Onboard (get an account)",
|
|
1345
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
1346
|
+
help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required."
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
value: "anon",
|
|
1350
|
+
title: "Stay anonymous",
|
|
1351
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
1352
|
+
help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now."
|
|
1353
|
+
}
|
|
1354
|
+
]
|
|
1355
|
+
});
|
|
1356
|
+
if (picked === "anon") {
|
|
1357
|
+
saveServerEntry(url, { ...existing.token ? {} : { anonymous: true }, label: existing.label, active: true });
|
|
1358
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
|
|
1359
|
+
}
|
|
1360
|
+
if (picked === undefined || picked === null)
|
|
1361
|
+
return { ok: false, message: "Cancelled." };
|
|
1362
|
+
let code = existing.code ?? "";
|
|
1363
|
+
if (!code) {
|
|
1364
|
+
const free = await enrollAtServer(url, "");
|
|
1365
|
+
if (free.ok) {
|
|
1366
|
+
deactivateOthers();
|
|
1367
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
|
|
1368
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
|
|
1369
|
+
}
|
|
1370
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1371
|
+
if (typed === undefined)
|
|
1372
|
+
return { ok: false, message: "Cancelled - this server needs a code to onboard." };
|
|
1373
|
+
code = typed.trim();
|
|
1374
|
+
}
|
|
1375
|
+
const res = await enrollAtServer(url, code);
|
|
1376
|
+
if (!res.ok)
|
|
1377
|
+
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
1378
|
+
deactivateOthers();
|
|
1379
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
|
|
1380
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
1381
|
+
}
|
|
1382
|
+
let code = existing.code ?? "";
|
|
1383
|
+
if (!code) {
|
|
1384
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1213
1385
|
if (typed === undefined)
|
|
1214
|
-
return { ok: false, message: "Cancelled
|
|
1386
|
+
return { ok: false, message: "Cancelled - private servers need a code." };
|
|
1215
1387
|
code = typed.trim();
|
|
1216
1388
|
}
|
|
1217
1389
|
const res = await enrollAtServer(url, code);
|
|
1218
1390
|
if (!res.ok)
|
|
1219
1391
|
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
1220
|
-
|
|
1221
|
-
if (other.url !== url)
|
|
1222
|
-
saveServerEntry(other.url, { active: undefined });
|
|
1223
|
-
}
|
|
1392
|
+
deactivateOthers();
|
|
1224
1393
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
|
|
1225
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
1394
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
1226
1395
|
}
|
|
1227
1396
|
async function serverMenu(host, url) {
|
|
1228
1397
|
while (true) {
|
|
@@ -1231,7 +1400,7 @@ async function serverMenu(host, url) {
|
|
|
1231
1400
|
if (!mine)
|
|
1232
1401
|
return;
|
|
1233
1402
|
const holdsRoot = !!mine.entry.token && mine.entry.token === (mine.entry.adminToken ?? adminTokenFor(url));
|
|
1234
|
-
const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : "no token (public server,
|
|
1403
|
+
const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : mine.entry.anonymous ? "anonymous (declined onboarding)" : "no token (public server, shares anonymously)";
|
|
1235
1404
|
const options = [
|
|
1236
1405
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} \xB7 ${cred}${mine.active ? " \xB7 ACTIVE" : ""}`, disabled: true }
|
|
1237
1406
|
];
|
|
@@ -1311,8 +1480,18 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1311
1480
|
continue;
|
|
1312
1481
|
}
|
|
1313
1482
|
let code = mine.entry.code ?? "";
|
|
1314
|
-
if (
|
|
1315
|
-
const
|
|
1483
|
+
if (!code) {
|
|
1484
|
+
const free = await enrollAtServer(url, "");
|
|
1485
|
+
if (free.ok) {
|
|
1486
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
|
|
1487
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
1491
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1316
1495
|
if (typed === undefined)
|
|
1317
1496
|
continue;
|
|
1318
1497
|
code = typed.trim();
|
|
@@ -1321,7 +1500,7 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1321
1500
|
}
|
|
1322
1501
|
const res = await enrollAtServer(url, code);
|
|
1323
1502
|
if (res.ok) {
|
|
1324
|
-
saveServerEntry(url, { token: res.token, label: res.label });
|
|
1503
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
|
|
1325
1504
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
|
|
1326
1505
|
} else {
|
|
1327
1506
|
await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
|
|
@@ -1984,10 +2163,10 @@ The clamp note appears when the server or your token's policy reduced a requeste
|
|
|
1984
2163
|
rows.push({
|
|
1985
2164
|
title: "Session TTL override (days)",
|
|
1986
2165
|
value: "session:ttl",
|
|
1987
|
-
description: binding?.opts?.
|
|
1988
|
-
help: `Per-session share lifetime. Overrides the config
|
|
2166
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
2167
|
+
help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.
|
|
1989
2168
|
|
|
1990
|
-
Current: ${binding?.opts?.
|
|
2169
|
+
Current: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none \u2014 config default)"}`
|
|
1991
2170
|
});
|
|
1992
2171
|
rows.push({
|
|
1993
2172
|
title: "Session read auth",
|
|
@@ -2218,26 +2397,32 @@ Every link stops working for everyone. This cannot be undone.`,
|
|
|
2218
2397
|
return;
|
|
2219
2398
|
}
|
|
2220
2399
|
if (choice === "session:ttl") {
|
|
2221
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
2400
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
|
|
2222
2401
|
if (raw === undefined)
|
|
2223
2402
|
return;
|
|
2224
2403
|
const opts = { ...current?.opts ?? {} };
|
|
2225
2404
|
if (raw === "")
|
|
2226
|
-
delete opts.
|
|
2405
|
+
delete opts.ttlHours;
|
|
2227
2406
|
else {
|
|
2228
|
-
const
|
|
2229
|
-
if (
|
|
2230
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
2407
|
+
const hours = parseTtlHours(raw);
|
|
2408
|
+
if (hours === undefined) {
|
|
2409
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
|
|
2231
2410
|
return;
|
|
2232
2411
|
}
|
|
2233
|
-
opts.
|
|
2412
|
+
opts.ttlHours = hours;
|
|
2234
2413
|
}
|
|
2235
2414
|
await setShareOpts(host.client, root, opts);
|
|
2236
2415
|
const liveNow = live.length > 0;
|
|
2416
|
+
if (liveNow) {
|
|
2417
|
+
const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })));
|
|
2418
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
2419
|
+
if (failed)
|
|
2420
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
|
|
2421
|
+
}
|
|
2237
2422
|
host.ui.toast({
|
|
2238
2423
|
variant: "success",
|
|
2239
2424
|
title: "OpenShare",
|
|
2240
|
-
message: liveNow ? `Session TTL: ${opts.
|
|
2425
|
+
message: liveNow ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.` : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`
|
|
2241
2426
|
});
|
|
2242
2427
|
return;
|
|
2243
2428
|
}
|
|
@@ -2262,6 +2447,13 @@ Every link stops working for everyone. This cannot be undone.`,
|
|
|
2262
2447
|
}
|
|
2263
2448
|
await setShareOpts(host.client, root, opts);
|
|
2264
2449
|
const liveNow2 = live.length > 0;
|
|
2450
|
+
if (liveNow2) {
|
|
2451
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
|
|
2452
|
+
const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })));
|
|
2453
|
+
const failed2 = results2.filter((r) => !r.ok).length;
|
|
2454
|
+
if (failed2)
|
|
2455
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` });
|
|
2456
|
+
}
|
|
2265
2457
|
host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` });
|
|
2266
2458
|
return;
|
|
2267
2459
|
}
|
|
@@ -2312,7 +2504,7 @@ Every ACTIVE server receives new sessions (fan-out).`
|
|
|
2312
2504
|
});
|
|
2313
2505
|
groups.push({
|
|
2314
2506
|
id: "sharing",
|
|
2315
|
-
summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${cfg.
|
|
2507
|
+
summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
2316
2508
|
help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.
|
|
2317
2509
|
|
|
2318
2510
|
Cadence values are clamped by the server's policy window.`
|
package/dist/ui-prefs.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Host-agnostic dialog size preferences
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Host-agnostic dialog size preferences, stored in host kv rather than the
|
|
2
|
+
// config file because UI settings are per-host and synced by opencode
|
|
3
|
+
// itself. Dialog width maps to the host's fixed dialog widths; height is a
|
|
4
|
+
// percent of terminal rows. v1 dialogs apply these directly (dialogs.tsx);
|
|
5
|
+
// the settings dialog edits them through this module so the flows stay
|
|
6
|
+
// host-agnostic.
|
|
7
7
|
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
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirrowel/opencode-openshare",
|
|
3
|
-
"version": "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/binding-queue.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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,
|