@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.10
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 +74 -28
- package/ci/README.md +30 -14
- package/ci/openshare.js +475 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +37 -14
- package/dist/config.js +119 -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 +409 -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 +138 -56
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +260 -60
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +2 -4
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +124 -31
- package/src/contract.ts +5 -5
- package/src/core.ts +384 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +134 -54
- 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/core.js
CHANGED
|
@@ -36,17 +36,19 @@
|
|
|
36
36
|
// Session metadata shape (metadata.openshare):
|
|
37
37
|
// - all-mode binding: { id, secret, rev, url }
|
|
38
38
|
// - manual-mode: { id, secret, rev, url, enabled: true, opts? }
|
|
39
|
-
// opts: {
|
|
39
|
+
// opts: { ttlHours?, authLogin?, authPassword?, modelLabel? } optional
|
|
40
40
|
// per-session overrides (capture at activation; modelLabel applies
|
|
41
41
|
// immediately, the rest at create/attach).
|
|
42
42
|
// - The plugin strips this key from everything it syncs — the write
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { homedir } from "node:os";
|
|
45
46
|
import { createHash } from "node:crypto";
|
|
46
47
|
import { isAbsolute, join } from "node:path";
|
|
47
|
-
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
48
|
+
import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
48
49
|
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
49
50
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
51
|
+
import { activeRules, redactItem } from "./redact.js";
|
|
50
52
|
const DEFAULT_POLICY = {
|
|
51
53
|
mode: "persistent",
|
|
52
54
|
turnGraceMs: 120000,
|
|
@@ -56,9 +58,9 @@ const DEFAULT_POLICY = {
|
|
|
56
58
|
debounceMaxMs: Infinity,
|
|
57
59
|
completedDelayMaxMs: Infinity,
|
|
58
60
|
};
|
|
59
|
-
// Stderr policy
|
|
60
|
-
//
|
|
61
|
-
//
|
|
61
|
+
// Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
|
|
62
|
+
// only. Informational lines (live:/url file:/mode banner, lifecycle
|
|
63
|
+
// traces) are suppressed because the TUI surfaces the URL itself.
|
|
62
64
|
// All-mode (CI/server-only) keeps its stderr contract (announce lines,
|
|
63
65
|
// silenceable via announceStderr). Error-class lines always reach stderr.
|
|
64
66
|
let stderrInfoAllowed = true;
|
|
@@ -117,6 +119,22 @@ function redact(info) {
|
|
|
117
119
|
delete metadata[METADATA_KEY];
|
|
118
120
|
return { ...info, metadata };
|
|
119
121
|
}
|
|
122
|
+
/** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
|
|
123
|
+
* session item. Viewers recover them from the envelope's private block
|
|
124
|
+
* (gated+authenticated shares only); anonymous shares never store them. */
|
|
125
|
+
function stripLocalFacts(info) {
|
|
126
|
+
if (!info || typeof info !== "object")
|
|
127
|
+
return info;
|
|
128
|
+
const out = { ...info };
|
|
129
|
+
let changed = false;
|
|
130
|
+
for (const k of ["directory", "worktree"]) {
|
|
131
|
+
if (out[k] !== undefined) {
|
|
132
|
+
delete out[k];
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return changed ? out : info;
|
|
137
|
+
}
|
|
120
138
|
function shareFlag(info) {
|
|
121
139
|
const raw = info?.metadata?.[METADATA_KEY];
|
|
122
140
|
if (!raw || typeof raw !== "object")
|
|
@@ -128,8 +146,11 @@ function shareFlag(info) {
|
|
|
128
146
|
const opts = {};
|
|
129
147
|
if (optsRaw && typeof optsRaw === "object") {
|
|
130
148
|
const o = optsRaw;
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
// legacy days-shaped opts migrate x24 (old bindings)
|
|
150
|
+
if (typeof o.ttlHours === "number" && o.ttlHours > 0)
|
|
151
|
+
opts.ttlHours = Math.floor(o.ttlHours);
|
|
152
|
+
else if (typeof o.ttlDays === "number" && o.ttlDays > 0)
|
|
153
|
+
opts.ttlHours = Math.floor(o.ttlDays * 24);
|
|
133
154
|
if (typeof o.authLogin === "string")
|
|
134
155
|
opts.authLogin = o.authLogin;
|
|
135
156
|
if (typeof o.authPassword === "string")
|
|
@@ -144,6 +165,20 @@ function shareFlag(info) {
|
|
|
144
165
|
* with what the engine actually targets - file config alone can miss
|
|
145
166
|
* plugin-options-layer config the TUI cannot see). */
|
|
146
167
|
let liveTargets = [];
|
|
168
|
+
// the live engine registers its counter here (the TUI imports the
|
|
169
|
+
// accessor without holding the engine instance)
|
|
170
|
+
let statsProvider = null;
|
|
171
|
+
// Wave 3 preflight ledger: per-session redaction hits (count + first
|
|
172
|
+
// samples, verbatim, so false positives are visible in the consent
|
|
173
|
+
// preview before anything is published)
|
|
174
|
+
const redactionLedger = new Map();
|
|
175
|
+
/** Preflight stats for the consent dialog: message/tool counts plus the
|
|
176
|
+
* redaction ledger (what would be stripped before publishing). */
|
|
177
|
+
export function sessionStatsFor(sid) {
|
|
178
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
|
|
179
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
|
|
180
|
+
return { ...counts, redaction: led };
|
|
181
|
+
}
|
|
147
182
|
export function engineTargetUrls() {
|
|
148
183
|
return liveTargets;
|
|
149
184
|
}
|
|
@@ -182,9 +217,9 @@ export function createEngine(deps) {
|
|
|
182
217
|
const resolveTargets = () => {
|
|
183
218
|
const merged = {};
|
|
184
219
|
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
|
|
185
|
-
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers
|
|
186
|
-
//
|
|
187
|
-
//
|
|
220
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
|
|
221
|
+
// would silently dual-home the session; an ENV workerUrl is the
|
|
222
|
+
// highest-priority layer and always joins.
|
|
188
223
|
const envWorker = !!process.env.OPENSHARE_WORKER_URL;
|
|
189
224
|
if (cfg.workerUrl && (!hasServers || envWorker))
|
|
190
225
|
merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
|
|
@@ -232,42 +267,116 @@ export function createEngine(deps) {
|
|
|
232
267
|
};
|
|
233
268
|
seedCredentials();
|
|
234
269
|
const ENROLL_RETRY_MS = 60000;
|
|
270
|
+
// B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
|
|
271
|
+
// server per boot, adopt ONLY keys with no explicit local source, and
|
|
272
|
+
// persist the adoption to the settings home (owner: no repeated CF
|
|
273
|
+
// fetches). One flag per URL for the whole process lifetime.
|
|
274
|
+
const setsFetched = new Set();
|
|
275
|
+
const adoptServerSuggestions = async (url, token) => {
|
|
276
|
+
if (setsFetched.has(url))
|
|
277
|
+
return;
|
|
278
|
+
setsFetched.add(url);
|
|
279
|
+
try {
|
|
280
|
+
const r = await fetch(`${url}/api/token/sets`, {
|
|
281
|
+
headers: { authorization: `Bearer ${token}` },
|
|
282
|
+
signal: AbortSignal.timeout(8000),
|
|
283
|
+
});
|
|
284
|
+
const body = r.ok ? (await r.json().catch(() => null)) : null;
|
|
285
|
+
const sets = body?.sets ?? {};
|
|
286
|
+
const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0];
|
|
287
|
+
if (!pick)
|
|
288
|
+
return;
|
|
289
|
+
const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel;
|
|
290
|
+
const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined;
|
|
291
|
+
// adopt only what is UNSPECIFIED locally: modelLabel sits at the
|
|
292
|
+
// default template, shareMode is unset and env carry no override
|
|
293
|
+
const src = configSourceMap(deps.pluginDir ?? "");
|
|
294
|
+
const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel;
|
|
295
|
+
const modeUnset = cfg.shareMode === undefined && !src.shareMode;
|
|
296
|
+
const adoptModel = wantModel && modelUnset;
|
|
297
|
+
const adoptMode = wantMode && modeUnset;
|
|
298
|
+
if (!adoptModel && !adoptMode)
|
|
299
|
+
return;
|
|
300
|
+
// surgical write into the settings home: read, patch just these
|
|
301
|
+
// keys when they are absent, write back (never clobber explicit
|
|
302
|
+
// values that appeared since load)
|
|
303
|
+
const home = configHomePath();
|
|
304
|
+
if (!home)
|
|
305
|
+
return;
|
|
306
|
+
let doc = {};
|
|
307
|
+
try {
|
|
308
|
+
doc = JSON.parse(readFileSync(home, "utf8"));
|
|
309
|
+
if (!doc || typeof doc !== "object")
|
|
310
|
+
doc = {};
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
let dirty = false;
|
|
314
|
+
if (adoptModel && doc.modelLabel === undefined) {
|
|
315
|
+
doc.modelLabel = wantModel;
|
|
316
|
+
dirty = true;
|
|
317
|
+
log(`${url}: adopted modelLabel from the server config set`);
|
|
318
|
+
}
|
|
319
|
+
if (adoptMode && doc.shareMode === undefined) {
|
|
320
|
+
doc.shareMode = wantMode;
|
|
321
|
+
dirty = true;
|
|
322
|
+
log(`${url}: adopted shareMode from the server config set`);
|
|
323
|
+
}
|
|
324
|
+
if (dirty)
|
|
325
|
+
writeFileSync(home, JSON.stringify(doc, null, 2) + "\n");
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// suggestions are best-effort; a failed fetch never blocks sharing
|
|
329
|
+
}
|
|
330
|
+
};
|
|
235
331
|
const ensureCredential = async (url) => {
|
|
236
332
|
const c = credOf(url);
|
|
237
333
|
const target = targets.find((t) => t.url === url);
|
|
238
|
-
if (c.credential)
|
|
334
|
+
if (c.credential) {
|
|
335
|
+
void adoptServerSuggestions(url, c.credential);
|
|
239
336
|
return c.credential;
|
|
337
|
+
}
|
|
240
338
|
if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
|
|
241
339
|
return "";
|
|
242
340
|
c.lastEnrollAt = Date.now();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (b?.ok && typeof b.token === "string") {
|
|
257
|
-
c.credential = b.token;
|
|
258
|
-
c.error = "";
|
|
259
|
-
const savedTo = saveServerToken(url, c.credential);
|
|
260
|
-
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
|
|
261
|
-
return c.credential;
|
|
341
|
+
// A configured code IS expressed onboarding intent (headless-safe: CI
|
|
342
|
+
// carries it precisely to enroll). Without one, never auto-enroll:
|
|
343
|
+
// public servers share anonymously (the ask-once TUI flow offers an
|
|
344
|
+
// account), private ones refuse until the user connects via
|
|
345
|
+
// /openshare.
|
|
346
|
+
const code = target?.entry.code ?? "";
|
|
347
|
+
if (!code) {
|
|
348
|
+
try {
|
|
349
|
+
const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
|
|
350
|
+
const hb = h.ok ? (await h.json().catch(() => null)) : null;
|
|
351
|
+
if (hb?.auth?.mode === "public") {
|
|
352
|
+
c.error = ""; // anonymous standing - tokenless create works
|
|
353
|
+
return "";
|
|
262
354
|
}
|
|
263
|
-
c.error =
|
|
355
|
+
c.error = "private server - onboard via /openshare (needs an access code)";
|
|
264
356
|
}
|
|
265
|
-
|
|
266
|
-
c.error =
|
|
357
|
+
catch (e) {
|
|
358
|
+
c.error = `server unreachable: ${e?.message || e}`;
|
|
267
359
|
}
|
|
268
|
-
|
|
269
|
-
|
|
360
|
+
logError(`no credential for ${url}: ${c.error}`);
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const r = await fetch(`${url}/api/enroll`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: { "content-type": "application/json" },
|
|
367
|
+
body: JSON.stringify({ code }),
|
|
368
|
+
signal: AbortSignal.timeout(10000),
|
|
369
|
+
});
|
|
370
|
+
const b = r.ok ? (await r.json().catch(() => null)) : null;
|
|
371
|
+
if (b?.ok && typeof b.token === "string") {
|
|
372
|
+
c.credential = b.token;
|
|
373
|
+
c.error = "";
|
|
374
|
+
const savedTo = saveServerToken(url, c.credential);
|
|
375
|
+
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
|
|
376
|
+
void adoptServerSuggestions(url, c.credential);
|
|
377
|
+
return c.credential;
|
|
270
378
|
}
|
|
379
|
+
c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
|
|
271
380
|
}
|
|
272
381
|
catch (e) {
|
|
273
382
|
c.error = `enrollment failed: ${e?.message || e}`;
|
|
@@ -638,10 +747,10 @@ export function createEngine(deps) {
|
|
|
638
747
|
for (const url of st.optOuts)
|
|
639
748
|
if (!servers[url])
|
|
640
749
|
servers[url] = { optedOut: true };
|
|
641
|
-
// A binding in session metadata means opted-in
|
|
642
|
-
// modes
|
|
643
|
-
// gate fall through to the consent dialog on an already-shared
|
|
644
|
-
// live is
|
|
750
|
+
// A binding in session metadata means opted-in, so enabled: true is
|
|
751
|
+
// written in BOTH modes. All-mode bindings without `enabled` made the
|
|
752
|
+
// TUI gate fall through to the consent dialog on an already-shared
|
|
753
|
+
// session; live is uniformly "url && enabled !== false".
|
|
645
754
|
const value = { v: 2, servers, enabled: true };
|
|
646
755
|
if (mode === "manual" && st.opts)
|
|
647
756
|
value.opts = st.opts;
|
|
@@ -698,9 +807,9 @@ export function createEngine(deps) {
|
|
|
698
807
|
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
|
|
699
808
|
}
|
|
700
809
|
catch (e) {
|
|
701
|
-
// the frame lives ONLY in memory now
|
|
702
|
-
// spoolLoad
|
|
703
|
-
//
|
|
810
|
+
// the frame lives ONLY in memory now, so flag the remote: the next
|
|
811
|
+
// spoolLoad must NOT replace in-memory pending with the shorter disk
|
|
812
|
+
// queue (silent loss otherwise).
|
|
704
813
|
r.spoolAppendFailed = true;
|
|
705
814
|
logError("spool append failed", e?.message || e);
|
|
706
815
|
}
|
|
@@ -723,10 +832,10 @@ export function createEngine(deps) {
|
|
|
723
832
|
};
|
|
724
833
|
const spoolLoad = (st, r) => {
|
|
725
834
|
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
726
|
-
// file is normal (
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
835
|
+
// file is normal (written only after the first successful handshake)
|
|
836
|
+
// and must NOT touch pending. A blanket catch here wipes in-memory
|
|
837
|
+
// pending AND skips the queue read, so everything queued before the
|
|
838
|
+
// first successful connect is lost.
|
|
730
839
|
try {
|
|
731
840
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
|
|
732
841
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -916,7 +1025,19 @@ export function createEngine(deps) {
|
|
|
916
1025
|
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
917
1026
|
: join(deps.directory ?? process.cwd(), "openshare.urls.txt");
|
|
918
1027
|
try {
|
|
919
|
-
|
|
1028
|
+
// "json" format: one machine-readable object per line (CI parses
|
|
1029
|
+
// fields instead of URLs). Status is the engine's current signal
|
|
1030
|
+
// at announce time; the completed flip updates the SHARE, not
|
|
1031
|
+
// this file line.
|
|
1032
|
+
const line = cfg.urlFileFormat === "json"
|
|
1033
|
+
? JSON.stringify({
|
|
1034
|
+
id: r.shareId,
|
|
1035
|
+
url,
|
|
1036
|
+
status: st.lastSentStatus ?? "waiting",
|
|
1037
|
+
gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
|
|
1038
|
+
}) + "\n"
|
|
1039
|
+
: url + "\n";
|
|
1040
|
+
appendFileSync(file, line);
|
|
920
1041
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
921
1042
|
urlFileAnnounced = true;
|
|
922
1043
|
lastUrlFile = file;
|
|
@@ -948,11 +1069,28 @@ export function createEngine(deps) {
|
|
|
948
1069
|
}
|
|
949
1070
|
return { authLogin: login, authPassword: password };
|
|
950
1071
|
};
|
|
951
|
-
const ttlFor = (st) => st.opts?.
|
|
1072
|
+
const ttlFor = (st) => st.opts?.ttlHours ?? cfg.ttlHours;
|
|
1073
|
+
// free-form labels for team directories/admin listings: ${NAME} refs
|
|
1074
|
+
// resolve from the process env at send time; unresolved refs stay
|
|
1075
|
+
// literal (visible beats silently-empty). Cached env lookup, max 20
|
|
1076
|
+
// keys/500 chars per value (config loader caps the same).
|
|
1077
|
+
const metaFor = () => {
|
|
1078
|
+
if (!cfg.meta || typeof cfg.meta !== "object")
|
|
1079
|
+
return undefined;
|
|
1080
|
+
const out = {};
|
|
1081
|
+
let n = 0;
|
|
1082
|
+
for (const [k, v] of Object.entries(cfg.meta)) {
|
|
1083
|
+
if (n >= 20)
|
|
1084
|
+
break;
|
|
1085
|
+
out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m);
|
|
1086
|
+
n++;
|
|
1087
|
+
}
|
|
1088
|
+
return Object.keys(out).length ? out : undefined;
|
|
1089
|
+
};
|
|
952
1090
|
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
953
1091
|
* live sockets, re-send the attach frame over each open socket: the
|
|
954
1092
|
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
955
|
-
* tokens on change) and now also honors an explicit
|
|
1093
|
+
* tokens on change) and now also honors an explicit ttlHours override -
|
|
956
1094
|
* so settings heal immediately instead of waiting for a reconnect.
|
|
957
1095
|
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
958
1096
|
* steady state) and revalidates the token. */
|
|
@@ -967,11 +1105,11 @@ export function createEngine(deps) {
|
|
|
967
1105
|
if (!r.shareId || !r.secret || !r.wsReady)
|
|
968
1106
|
continue;
|
|
969
1107
|
const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
|
|
970
|
-
if (st.opts?.
|
|
971
|
-
attach.
|
|
1108
|
+
if (st.opts?.ttlHours)
|
|
1109
|
+
attach.ttlHours = st.opts.ttlHours;
|
|
972
1110
|
const sent = wsSend(r, { op: "init", attach });
|
|
973
1111
|
if (sent)
|
|
974
|
-
log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.
|
|
1112
|
+
log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
|
|
975
1113
|
}
|
|
976
1114
|
};
|
|
977
1115
|
/** Best-effort cleanup of a share we are about to abandon (refused
|
|
@@ -991,9 +1129,9 @@ export function createEngine(deps) {
|
|
|
991
1129
|
};
|
|
992
1130
|
/** Single-flight connect queuing: multiple paths (activation, event
|
|
993
1131
|
* reconnect, backfill chain) may want a socket before any connect has
|
|
994
|
-
* STARTED
|
|
995
|
-
* and double connects
|
|
996
|
-
*
|
|
1132
|
+
* STARTED. The connecting flag alone guards execution, not the queue,
|
|
1133
|
+
* and double connects create duplicate shares and init storms (one
|
|
1134
|
+
* create per socket). */
|
|
997
1135
|
const queueConnect = (st, r) => {
|
|
998
1136
|
if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
|
|
999
1137
|
return;
|
|
@@ -1111,12 +1249,12 @@ export function createEngine(deps) {
|
|
|
1111
1249
|
const connect = async (st, r) => {
|
|
1112
1250
|
if (st.closed || r.connecting || r.authBlocked || r.collided)
|
|
1113
1251
|
return;
|
|
1114
|
-
// "connecting" spans the WHOLE handshake: socket open
|
|
1115
|
-
// is only
|
|
1116
|
-
// event arriving mid-handshake must NEVER spawn a second socket
|
|
1117
|
-
// old connect() returned after merely SENDING init
|
|
1252
|
+
// "connecting" spans the WHOLE handshake: socket open plus init ack.
|
|
1253
|
+
// It is cleared only on init-ok, socket death or the init timeout. An
|
|
1254
|
+
// event arriving mid-handshake must NEVER spawn a second socket: the
|
|
1255
|
+
// old connect() returned after merely SENDING init, a second socket
|
|
1118
1256
|
// raced it, and the loser's late init-ok poisoned the winner's state
|
|
1119
|
-
//
|
|
1257
|
+
// (duplicate shares, mismatch storms, empty pages).
|
|
1120
1258
|
r.connecting = true;
|
|
1121
1259
|
r.socketSeq += 1;
|
|
1122
1260
|
const seq = r.socketSeq;
|
|
@@ -1133,9 +1271,9 @@ export function createEngine(deps) {
|
|
|
1133
1271
|
spoolLoad(st, r);
|
|
1134
1272
|
askPolicy(r.url);
|
|
1135
1273
|
// was this socket's init an ATTACH (resume) or a CREATE? A create
|
|
1136
|
-
// ack legitimately starts at rev 0
|
|
1137
|
-
// attaches only
|
|
1138
|
-
//
|
|
1274
|
+
// ack legitimately starts at rev 0, so the mismatch guard is for
|
|
1275
|
+
// attaches only. Applying it to a create spawns a fresh husk share
|
|
1276
|
+
// with the content dropped.
|
|
1139
1277
|
r.initWasAttach = !!(r.shareId && r.secret);
|
|
1140
1278
|
if (debugEvents)
|
|
1141
1279
|
log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
|
|
@@ -1229,7 +1367,7 @@ export function createEngine(deps) {
|
|
|
1229
1367
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
|
|
1230
1368
|
}
|
|
1231
1369
|
else {
|
|
1232
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1370
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1233
1371
|
}
|
|
1234
1372
|
// handshake deadline: init sent but no ack (silent worker death)
|
|
1235
1373
|
// — close, let the close listener reconnect us
|
|
@@ -1418,9 +1556,9 @@ export function createEngine(deps) {
|
|
|
1418
1556
|
return;
|
|
1419
1557
|
}
|
|
1420
1558
|
// share swept / removed / revoked: start fresh, never merge into a
|
|
1421
|
-
// bad base (mismatch protocol). Content REPLAYS to the new share
|
|
1422
|
-
// pending is never dropped on a reset
|
|
1423
|
-
// husk
|
|
1559
|
+
// bad base (mismatch protocol). Content REPLAYS to the new share,
|
|
1560
|
+
// and pending is never dropped on a reset: dropping it leaves the
|
|
1561
|
+
// recreate as a husk with the title and zero messages.
|
|
1424
1562
|
if (/limit|too many|rate/i.test(err)) {
|
|
1425
1563
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1426
1564
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
@@ -1445,19 +1583,19 @@ export function createEngine(deps) {
|
|
|
1445
1583
|
r.shareId = null;
|
|
1446
1584
|
r.secret = null;
|
|
1447
1585
|
r.ackedRev = 0;
|
|
1448
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1586
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1449
1587
|
return;
|
|
1450
1588
|
}
|
|
1451
1589
|
if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
|
|
1452
|
-
// worker lost data behind our back (ATTACH only
|
|
1453
|
-
// legitimately starts at rev 0
|
|
1454
|
-
//
|
|
1590
|
+
// worker lost data behind our back (ATTACH only: a create ack
|
|
1591
|
+
// legitimately starts at rev 0, and misreading that as data loss
|
|
1592
|
+
// spawns a fresh husk)
|
|
1455
1593
|
logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
|
|
1456
1594
|
deleteAbandoned(r);
|
|
1457
1595
|
r.shareId = null;
|
|
1458
1596
|
r.secret = null;
|
|
1459
1597
|
r.ackedRev = 0;
|
|
1460
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1598
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1461
1599
|
return;
|
|
1462
1600
|
}
|
|
1463
1601
|
const wasFanoutCreate = !r.initWasAttach && st.everConnected;
|
|
@@ -1479,11 +1617,10 @@ export function createEngine(deps) {
|
|
|
1479
1617
|
clearTimeout(r.initTimer);
|
|
1480
1618
|
r.initTimer = null;
|
|
1481
1619
|
}
|
|
1482
|
-
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
// still ready, no reconnect pending) or a second acked sync.
|
|
1620
|
+
// backoff reset is DEFERRED until the socket proves stable: a server
|
|
1621
|
+
// that accepts init then immediately drops would otherwise reconnect
|
|
1622
|
+
// once per second per session-server pair. Stability means 30s alive
|
|
1623
|
+
// (socket still ready, no reconnect pending) or a second acked sync.
|
|
1487
1624
|
if (r.backoffResetTimer)
|
|
1488
1625
|
clearTimeout(r.backoffResetTimer);
|
|
1489
1626
|
r.backoffResetTimer = setTimeout(() => {
|
|
@@ -1499,9 +1636,10 @@ export function createEngine(deps) {
|
|
|
1499
1636
|
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1500
1637
|
// the binding once, and a successful handshake clears any stale
|
|
1501
1638
|
// refusal reason the TUI may be showing
|
|
1502
|
-
|
|
1639
|
+
const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined);
|
|
1640
|
+
if (typeof ackTtl === "number") {
|
|
1503
1641
|
const wanted = ttlFor(st);
|
|
1504
|
-
const clamp = wanted != null &&
|
|
1642
|
+
const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null;
|
|
1505
1643
|
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1506
1644
|
st.ttlClamped = clamp;
|
|
1507
1645
|
scheduleBindingWrite(st);
|
|
@@ -1511,6 +1649,40 @@ export function createEngine(deps) {
|
|
|
1511
1649
|
st.lastError = null;
|
|
1512
1650
|
scheduleBindingWrite(st);
|
|
1513
1651
|
}
|
|
1652
|
+
// ack-level gate assurance (B2): we asked for a gate (both fields
|
|
1653
|
+
// set) but the server reports none - the page is actually public.
|
|
1654
|
+
// Once per remote; the next init-ok re-checks after a fix.
|
|
1655
|
+
const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== "";
|
|
1656
|
+
if (wantedGate && frame.auth === false && !r.warnedNoGate) {
|
|
1657
|
+
r.warnedNoGate = true;
|
|
1658
|
+
logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`);
|
|
1659
|
+
}
|
|
1660
|
+
else if (frame.auth === true) {
|
|
1661
|
+
r.warnedNoGate = false;
|
|
1662
|
+
}
|
|
1663
|
+
// Wave 3 pmeta: machine-local facts (directory/worktree) ride their
|
|
1664
|
+
// OWN tiny sync once per init-ok, only to remotes we hold a
|
|
1665
|
+
// credential for - anonymous (ownerless) shares never store them.
|
|
1666
|
+
// Server-side they land in the private block, readable with share
|
|
1667
|
+
// auth. Idempotent upsert; rides no shared frame so fan-out
|
|
1668
|
+
// remotes with mixed identity each get exactly their due.
|
|
1669
|
+
if (credOf(r.url).credential) {
|
|
1670
|
+
const priv = {};
|
|
1671
|
+
const dir = (deps.directory ?? "");
|
|
1672
|
+
if (dir)
|
|
1673
|
+
priv.directory = dir;
|
|
1674
|
+
if (deps.worktree)
|
|
1675
|
+
priv.worktree = String(deps.worktree);
|
|
1676
|
+
// the sharer's home lets the server mask absolute paths as ~/...
|
|
1677
|
+
try {
|
|
1678
|
+
priv.home = homedir();
|
|
1679
|
+
}
|
|
1680
|
+
catch { }
|
|
1681
|
+
if (Object.keys(priv).length) {
|
|
1682
|
+
const seq = ++nextSeq;
|
|
1683
|
+
wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq });
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1514
1686
|
announceUrl(st, r);
|
|
1515
1687
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1516
1688
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -1530,12 +1702,12 @@ export function createEngine(deps) {
|
|
|
1530
1702
|
}
|
|
1531
1703
|
if (wasFanoutCreate) {
|
|
1532
1704
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1533
|
-
// post-mismatch recreate): its fresh share needs the full history
|
|
1534
|
-
// backfill is keyed-idempotent
|
|
1535
|
-
// The enqueue dedupe must step aside: those hashes
|
|
1536
|
-
// entered the machinery", but the NEW remote
|
|
1537
|
-
// of it
|
|
1538
|
-
//
|
|
1705
|
+
// post-mismatch recreate): its fresh share needs the full history,
|
|
1706
|
+
// and backfill is keyed-idempotent while other servers simply
|
|
1707
|
+
// re-upsert. The enqueue dedupe must step aside here: those hashes
|
|
1708
|
+
// say "already entered the machinery", but the NEW remote received
|
|
1709
|
+
// none of it, so a deduped join backfills nothing and the joined
|
|
1710
|
+
// share stays empty.
|
|
1539
1711
|
st.sentHash.clear();
|
|
1540
1712
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
|
|
1541
1713
|
}
|
|
@@ -1643,8 +1815,8 @@ export function createEngine(deps) {
|
|
|
1643
1815
|
return { items, status: st.status };
|
|
1644
1816
|
};
|
|
1645
1817
|
// returns a promise when the frame goes out via HTTP (the dispose path
|
|
1646
|
-
// awaits it
|
|
1647
|
-
// final status never lands,
|
|
1818
|
+
// awaits it: an unawaited fetch dies with the exiting process and the
|
|
1819
|
+
// final status never lands, leaving the share stuck on "live")
|
|
1648
1820
|
const flush = (st) => {
|
|
1649
1821
|
if (st.closed)
|
|
1650
1822
|
return;
|
|
@@ -1654,8 +1826,8 @@ export function createEngine(deps) {
|
|
|
1654
1826
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1655
1827
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1656
1828
|
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1657
|
-
// chunk-frames so a partial
|
|
1658
|
-
//
|
|
1829
|
+
// chunk-frames, so a partial ack can never erase unsent chunks (a
|
|
1830
|
+
// chunk-1 ack followed by socket death used to drop chunks 2..N).
|
|
1659
1831
|
const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
|
|
1660
1832
|
let httpWork;
|
|
1661
1833
|
for (const r of st.remotes.values()) {
|
|
@@ -1681,8 +1853,8 @@ export function createEngine(deps) {
|
|
|
1681
1853
|
}
|
|
1682
1854
|
// never send syncs before the init ack: the worker's per-socket
|
|
1683
1855
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1684
|
-
// sync dies as "unknown op"
|
|
1685
|
-
//
|
|
1856
|
+
// sync dies as "unknown op", losing whole turns on post-drop
|
|
1857
|
+
// reconnects. The init-ok handler flushes pending itself.
|
|
1686
1858
|
if (r.ws && !r.wsReady)
|
|
1687
1859
|
continue;
|
|
1688
1860
|
if (r.wsReady) {
|
|
@@ -1722,13 +1894,12 @@ export function createEngine(deps) {
|
|
|
1722
1894
|
const flushViaHttp = async (st, r) => {
|
|
1723
1895
|
if (!r.shareId || !r.pending.length)
|
|
1724
1896
|
return;
|
|
1725
|
-
// ALL pending frames, merged (keyed upserts
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
// server row-write + a rebroadcast frame.
|
|
1897
|
+
// ALL pending frames, merged (keyed upserts, later wins). Sending only
|
|
1898
|
+
// the last frame dropped every earlier batch's items whenever the HTTP
|
|
1899
|
+
// fallback carried more than one, losing content on reconnect paths.
|
|
1900
|
+
// DEDUPE BY KEY before chunking: a streaming message appearing in k
|
|
1901
|
+
// pending frames shipped k copies (up to 25x on a replay burst), and
|
|
1902
|
+
// every duplicate cost a server row-write plus a rebroadcast frame.
|
|
1732
1903
|
const byKey = new Map();
|
|
1733
1904
|
const unkeyed = [];
|
|
1734
1905
|
let status;
|
|
@@ -1800,16 +1971,24 @@ export function createEngine(deps) {
|
|
|
1800
1971
|
.catch((e) => logError("flush error", e?.message || e));
|
|
1801
1972
|
}, wait);
|
|
1802
1973
|
};
|
|
1974
|
+
const homeDir = (() => { try {
|
|
1975
|
+
return homedir();
|
|
1976
|
+
}
|
|
1977
|
+
catch {
|
|
1978
|
+
return "";
|
|
1979
|
+
} })();
|
|
1980
|
+
const userPatterns = cfg.redact?.patterns;
|
|
1981
|
+
const redactionRules = activeRules(userPatterns);
|
|
1803
1982
|
const enqueue = (st, entry) => {
|
|
1804
1983
|
if (!st || !entry)
|
|
1805
1984
|
return;
|
|
1806
1985
|
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1807
|
-
// either entered the queue already or been flushed into every
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
// dormant and
|
|
1986
|
+
// either entered the queue already or been flushed into every remote's
|
|
1987
|
+
// pending/spool, and the per-remote delivery machinery owns it from
|
|
1988
|
+
// there. Re-sending it bumps rev, rewrites storage and re-folds every
|
|
1989
|
+
// viewer for nothing. This is the engine-side home dedupe belongs in:
|
|
1990
|
+
// an adapter-side cache can be poisoned while the session is still
|
|
1991
|
+
// dormant, and then the flag-time content is skipped.
|
|
1813
1992
|
let h;
|
|
1814
1993
|
try {
|
|
1815
1994
|
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
|
|
@@ -1824,6 +2003,26 @@ export function createEngine(deps) {
|
|
|
1824
2003
|
}
|
|
1825
2004
|
if (h)
|
|
1826
2005
|
st.sentHash.set(entry.key, h);
|
|
2006
|
+
// Wave 3 trust pass: redaction on UNGATED shares only (a gated share
|
|
2007
|
+
// is private from casual observers already; the server owner sees
|
|
2008
|
+
// content either way until encryption lands). The light path
|
|
2009
|
+
// transform applies regardless. Zero-copy when nothing matches.
|
|
2010
|
+
{
|
|
2011
|
+
const gate = authFrame(st);
|
|
2012
|
+
const gated = gate.authLogin !== "" && gate.authPassword !== "";
|
|
2013
|
+
const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated });
|
|
2014
|
+
if (pass.item !== entry.item)
|
|
2015
|
+
entry = { ...entry, item: pass.item };
|
|
2016
|
+
if (pass.hits.length) {
|
|
2017
|
+
const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] };
|
|
2018
|
+
led.count += pass.hits.length;
|
|
2019
|
+
for (const hit of pass.hits) {
|
|
2020
|
+
if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind))
|
|
2021
|
+
led.samples.push(hit);
|
|
2022
|
+
}
|
|
2023
|
+
redactionLedger.set(st.sid, led);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1827
2026
|
st.queue.set(entry.key, entry.item);
|
|
1828
2027
|
indexMessage(entry);
|
|
1829
2028
|
if (debugEvents)
|
|
@@ -1832,12 +2031,30 @@ export function createEngine(deps) {
|
|
|
1832
2031
|
ensureConnected(st);
|
|
1833
2032
|
scheduleFlush(st);
|
|
1834
2033
|
};
|
|
2034
|
+
// Wave 3 preflight counts: the module-level accessor delegates here
|
|
2035
|
+
statsProvider = (sid) => {
|
|
2036
|
+
const st = sessions.get(sid);
|
|
2037
|
+
let messages = 0;
|
|
2038
|
+
let toolCalls = 0;
|
|
2039
|
+
if (st) {
|
|
2040
|
+
for (const [, merged] of st.merged) {
|
|
2041
|
+
messages++;
|
|
2042
|
+
const content = merged.content;
|
|
2043
|
+
if (Array.isArray(content))
|
|
2044
|
+
toolCalls += content.filter((c) => !!c && c.type === "tool").length;
|
|
2045
|
+
}
|
|
2046
|
+
for (const it of st.queue.values()) {
|
|
2047
|
+
if (it?.type === "message")
|
|
2048
|
+
messages++;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
return { messages, toolCalls };
|
|
2052
|
+
};
|
|
1835
2053
|
const msgIndex = new Map(); // `${sid}:${messageID}`
|
|
1836
2054
|
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1837
|
-
* undone message
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
* deleted turn server-side). */
|
|
2055
|
+
* undone message. dropMergedMessage removes the merged entry but msgIndex
|
|
2056
|
+
* keeps the corpse, so a neighboring-segment change recomputes its
|
|
2057
|
+
* stamps and reEnqueueMessage resurrects the deleted turn server-side. */
|
|
1841
2058
|
const tombstoned = new Set();
|
|
1842
2059
|
const indexMessage = (entry) => {
|
|
1843
2060
|
if (entry.item.type !== "message")
|
|
@@ -1901,11 +2118,11 @@ export function createEngine(deps) {
|
|
|
1901
2118
|
const agent = segmentAgent(list, users[k]);
|
|
1902
2119
|
const d = list[users[k]].data;
|
|
1903
2120
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
|
|
1904
|
-
// only touch metadata when the stamp actually CHANGES
|
|
1905
|
-
// `d.metadata ??= {}`
|
|
2121
|
+
// only touch metadata when the stamp actually CHANGES: a blanket
|
|
2122
|
+
// `d.metadata ??= {}` pollutes every user message (including the
|
|
1906
2123
|
// host's live objects on v2 pass-through) with an empty metadata
|
|
1907
|
-
// object, which re-
|
|
1908
|
-
// per feed
|
|
2124
|
+
// object, which re-emits the message once per refresh and churns
|
|
2125
|
+
// rev per feed.
|
|
1909
2126
|
if (want !== d.metadata?.agentSwitch) {
|
|
1910
2127
|
if (want) {
|
|
1911
2128
|
const meta = (d.metadata ??= {});
|
|
@@ -1979,8 +2196,8 @@ export function createEngine(deps) {
|
|
|
1979
2196
|
}
|
|
1980
2197
|
};
|
|
1981
2198
|
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1982
|
-
* call touched)
|
|
1983
|
-
*
|
|
2199
|
+
* call touched). The viewer highlights from this stamp and never
|
|
2200
|
+
* guesses; its extension map only covers old shares without one. */
|
|
1984
2201
|
const LANG_BY_EXT = {
|
|
1985
2202
|
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1986
2203
|
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
@@ -1992,6 +2209,44 @@ export function createEngine(deps) {
|
|
|
1992
2209
|
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
1993
2210
|
};
|
|
1994
2211
|
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
|
|
2212
|
+
/** A read that returns lines from inside an html file's <script>/<style>
|
|
2213
|
+
* block gets tokenized as html by the viewer, and a snippet with no tag
|
|
2214
|
+
* context tokenizes as untyped soup (one default span per line). The
|
|
2215
|
+
* viewer only sees the snippet, so the stamp carries the language that is
|
|
2216
|
+
* true for the RETURNED RANGE: no tags plus code shape means the range sat
|
|
2217
|
+
* inside an embedded script or style block. */
|
|
2218
|
+
const refineEmbeddedLanguage = (meta, text) => {
|
|
2219
|
+
if (meta.language !== "html")
|
|
2220
|
+
return false;
|
|
2221
|
+
const head = text.slice(0, 4000);
|
|
2222
|
+
if (/<[a-zA-Z][^>\n]*>/.test(head))
|
|
2223
|
+
return false;
|
|
2224
|
+
let refined;
|
|
2225
|
+
if (/^[ \t]*[.#][A-Za-z][\w-]*[^\n{]*\{/m.test(head) && !/\b(function|=>|return)\b/.test(head)) {
|
|
2226
|
+
refined = "css";
|
|
2227
|
+
}
|
|
2228
|
+
else if (/\b(function|const|let|var|return|if|for|while|=>|addEventListener|document\.|window\.)\b/.test(head) || /[;{}]\s*$/m.test(head)) {
|
|
2229
|
+
refined = "javascript";
|
|
2230
|
+
}
|
|
2231
|
+
if (!refined || meta.language === refined)
|
|
2232
|
+
return false;
|
|
2233
|
+
meta.language = refined;
|
|
2234
|
+
return true;
|
|
2235
|
+
};
|
|
2236
|
+
/** Sniff the CODE CONTENT of a unified patch: drop everything before the
|
|
2237
|
+
* first hunk header and strip each line's diff marker, so the same
|
|
2238
|
+
* tag/shape checks as reads run over clean source lines. */
|
|
2239
|
+
const refinePatchLanguage = (meta, patchText) => {
|
|
2240
|
+
const at = patchText.indexOf("@@");
|
|
2241
|
+
if (at < 0)
|
|
2242
|
+
return false;
|
|
2243
|
+
const body = patchText
|
|
2244
|
+
.slice(at)
|
|
2245
|
+
.split("\n")
|
|
2246
|
+
.map((l) => (/^[ +\\]/.test(l) ? l.slice(1) : l.startsWith("-") ? l.slice(1) : ""))
|
|
2247
|
+
.join("\n");
|
|
2248
|
+
return refineEmbeddedLanguage(meta, body);
|
|
2249
|
+
};
|
|
1995
2250
|
const stampFileLanguages = () => {
|
|
1996
2251
|
for (const m of msgIndex.values()) {
|
|
1997
2252
|
if (m.role !== "assistant")
|
|
@@ -2009,6 +2264,10 @@ export function createEngine(deps) {
|
|
|
2009
2264
|
const meta = state?.metadata;
|
|
2010
2265
|
if (!meta)
|
|
2011
2266
|
continue;
|
|
2267
|
+
// paths stay absolute here: the server rewrites them against the
|
|
2268
|
+
// session's working folder on read (wireItems), so old shares get
|
|
2269
|
+
// the same treatment and the host's message objects are never
|
|
2270
|
+
// mutated
|
|
2012
2271
|
const raw = state.input?.path ?? state.input?.filePath;
|
|
2013
2272
|
// language stamp (shiki id from the path)
|
|
2014
2273
|
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
@@ -2019,6 +2278,18 @@ export function createEngine(deps) {
|
|
|
2019
2278
|
mutated = true;
|
|
2020
2279
|
}
|
|
2021
2280
|
}
|
|
2281
|
+
// range refinement: html-stamped reads inside script/style blocks
|
|
2282
|
+
if (part.name === "read" && typeof state.output === "string" && state.output.length > 0) {
|
|
2283
|
+
if (refineEmbeddedLanguage(meta, state.output))
|
|
2284
|
+
mutated = true;
|
|
2285
|
+
}
|
|
2286
|
+
// same refinement for edit hunks: a mid-script html diff tokenizes
|
|
2287
|
+
// untyped without the range language
|
|
2288
|
+
if ((part.name === "edit" || part.name === "patch") && typeof meta.language === "string") {
|
|
2289
|
+
const pt = typeof meta.diff === "string" ? meta.diff : typeof meta.filediff?.patch === "string" ? meta.filediff.patch : undefined;
|
|
2290
|
+
if (pt && refinePatchLanguage(meta, pt))
|
|
2291
|
+
mutated = true;
|
|
2292
|
+
}
|
|
2022
2293
|
// lineStart stamp: reads sliced from an offset keep REAL file line
|
|
2023
2294
|
// numbers in the viewer's unified file card (pierre numbers from 1;
|
|
2024
2295
|
// the viewer shifts by this stamp, authoritatively)
|
|
@@ -2173,9 +2444,9 @@ export function createEngine(deps) {
|
|
|
2173
2444
|
r.idleDropTimer = null;
|
|
2174
2445
|
}
|
|
2175
2446
|
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2176
|
-
// child teardown clears shareId after adopting into the parent
|
|
2177
|
-
// the ControlDO's server-side attach would
|
|
2178
|
-
//
|
|
2447
|
+
// child teardown clears shareId after adopting into the parent, and
|
|
2448
|
+
// the ControlDO's server-side attach would then delete the very
|
|
2449
|
+
// share the parent just inherited.
|
|
2179
2450
|
if (r.wsReady && r.shareId) {
|
|
2180
2451
|
wsSend(r, { op: "delete" });
|
|
2181
2452
|
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
@@ -2295,9 +2566,9 @@ export function createEngine(deps) {
|
|
|
2295
2566
|
const lastInfo = (last?.info ?? last);
|
|
2296
2567
|
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
|
|
2297
2568
|
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2298
|
-
// tail (message-v2.ts codec).
|
|
2299
|
-
//
|
|
2300
|
-
// 100-message page
|
|
2569
|
+
// tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
|
|
2570
|
+
// bare message id, which truncates a v1 backfill to its first
|
|
2571
|
+
// 100-message page.
|
|
2301
2572
|
const lastTime = lastInfo?.time?.created;
|
|
2302
2573
|
if (!lastID || typeof lastTime !== "number")
|
|
2303
2574
|
break;
|
|
@@ -2316,7 +2587,7 @@ export function createEngine(deps) {
|
|
|
2316
2587
|
const backfillSession = async (st, client, sid, info) => {
|
|
2317
2588
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid));
|
|
2318
2589
|
if (sessionInfoValue)
|
|
2319
|
-
enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
|
|
2590
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid));
|
|
2320
2591
|
let modelSeen = false;
|
|
2321
2592
|
const consume = (messages) => {
|
|
2322
2593
|
for (const message of messages) {
|
|
@@ -2361,7 +2632,7 @@ export function createEngine(deps) {
|
|
|
2361
2632
|
// one thing a viewer always needs — fetch it explicitly
|
|
2362
2633
|
const fetched = await sessionInfo(client, st.sid);
|
|
2363
2634
|
if (fetched)
|
|
2364
|
-
enqueue(st, itemFor("session", redact(fetched), st.sid));
|
|
2635
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid));
|
|
2365
2636
|
}
|
|
2366
2637
|
// children (subagent sessions) share the parent's page
|
|
2367
2638
|
if (typeof client.session.list === "function") {
|
|
@@ -2454,7 +2725,7 @@ export function createEngine(deps) {
|
|
|
2454
2725
|
const st = sessions.get(parent);
|
|
2455
2726
|
if (st?.activated && !st.closed) {
|
|
2456
2727
|
if (info)
|
|
2457
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2728
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2458
2729
|
}
|
|
2459
2730
|
return;
|
|
2460
2731
|
}
|
|
@@ -2466,7 +2737,7 @@ export function createEngine(deps) {
|
|
|
2466
2737
|
if (flag.opts)
|
|
2467
2738
|
applyOpts(st, flag.opts);
|
|
2468
2739
|
if (info)
|
|
2469
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2740
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2470
2741
|
if (t === "session.updated")
|
|
2471
2742
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
|
|
2472
2743
|
return;
|
|
@@ -2529,7 +2800,7 @@ export function createEngine(deps) {
|
|
|
2529
2800
|
if (!st)
|
|
2530
2801
|
return;
|
|
2531
2802
|
if (info)
|
|
2532
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2803
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2533
2804
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
2534
2805
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
|
|
2535
2806
|
}
|
|
@@ -2559,12 +2830,12 @@ export function createEngine(deps) {
|
|
|
2559
2830
|
touch(st);
|
|
2560
2831
|
if (type === "busy") {
|
|
2561
2832
|
// work resumed: a pending completed flip is cancelled (opencode
|
|
2562
|
-
// idles between sequential automation prompts
|
|
2563
|
-
// "done"), a recovered retry is forgiven, share is live
|
|
2564
|
-
// The busy epoch is the completed-flip guard: only WORK
|
|
2565
|
-
// invalidates a pending settle
|
|
2566
|
-
//
|
|
2567
|
-
//
|
|
2833
|
+
// idles between sequential automation prompts, which is NOT
|
|
2834
|
+
// "done"), a recovered retry is forgiven, and the share is live
|
|
2835
|
+
// again. The busy epoch is the completed-flip guard: only WORK
|
|
2836
|
+
// invalidates a pending settle. v1.18 republishes the status/idle
|
|
2837
|
+
// pair on failure paths, and a repeated idle must NOT void it or
|
|
2838
|
+
// the share stays on live forever.
|
|
2568
2839
|
st.statusEpoch++;
|
|
2569
2840
|
if (st.completedTimer) {
|
|
2570
2841
|
clearTimeout(st.completedTimer);
|
|
@@ -2578,8 +2849,8 @@ export function createEngine(deps) {
|
|
|
2578
2849
|
else if (type === "idle") {
|
|
2579
2850
|
// opencode's idle is authoritative turn end (v1 holds busy
|
|
2580
2851
|
// through steps, tool runs and permission waits). CONTENT
|
|
2581
|
-
// flushes now
|
|
2582
|
-
// interval (
|
|
2852
|
+
// flushes now, because turn-end batches must not ride the sync
|
|
2853
|
+
// interval (subagent finals lagged one interval otherwise).
|
|
2583
2854
|
// Only the completed flip waits out completedDelayMs, guarded
|
|
2584
2855
|
// by the busy epoch: work resuming voids it, repeated idle
|
|
2585
2856
|
// publications do not. Dispose flushes a pending flip, so CI
|
|
@@ -2724,11 +2995,11 @@ export function createEngine(deps) {
|
|
|
2724
2995
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
|
|
2725
2996
|
enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
|
|
2726
2997
|
}
|
|
2727
|
-
// an assistant message that DIED (finish:"error")
|
|
2728
|
-
// retries exhausted
|
|
2729
|
-
// message
|
|
2730
|
-
//
|
|
2731
|
-
//
|
|
2998
|
+
// an assistant message that DIED (finish:"error"), e.g. all
|
|
2999
|
+
// retries exhausted, surfaces as a 200 response with an errored
|
|
3000
|
+
// message rather than session.error. The share must still say
|
|
3001
|
+
// error. Works for v2-native messages too (finish lives on the
|
|
3002
|
+
// message itself).
|
|
2732
3003
|
if (info?.finish === "error") {
|
|
2733
3004
|
st.badSignal = true;
|
|
2734
3005
|
}
|