@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/src/core.ts
CHANGED
|
@@ -36,18 +36,20 @@
|
|
|
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
|
|
|
45
45
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
46
|
+
import { homedir } from "node:os"
|
|
46
47
|
import { createHash } from "node:crypto"
|
|
47
48
|
import { isAbsolute, join } from "node:path"
|
|
48
|
-
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
49
|
+
import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
49
50
|
import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
|
|
50
51
|
import { queueBindingWrite } from "./binding-queue.js"
|
|
52
|
+
import { activeRules, redactItem, type RedactionHit } from "./redact.js"
|
|
51
53
|
|
|
52
54
|
// --- minimal structural types (hand-written: the standalone bundle must
|
|
53
55
|
// not depend on the plugin SDK at runtime; shapes verified against
|
|
@@ -86,7 +88,7 @@ export type ShareEvent =
|
|
|
86
88
|
| { type: "session.deleted"; sessionID: string }
|
|
87
89
|
|
|
88
90
|
export type SessionOpts = {
|
|
89
|
-
|
|
91
|
+
ttlHours?: number
|
|
90
92
|
authLogin?: string
|
|
91
93
|
authPassword?: string
|
|
92
94
|
modelLabel?: string
|
|
@@ -121,6 +123,7 @@ type RemoteState = {
|
|
|
121
123
|
authBlocked: boolean
|
|
122
124
|
pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
|
|
123
125
|
frozenRemote?: boolean // server froze the owner mid-session: stop writing
|
|
126
|
+
warnedNoGate?: boolean // B2: one-shot "requested gate missing" warning sent
|
|
124
127
|
spoolAppendFailed?: boolean // last append errored: memory-only frame exists
|
|
125
128
|
spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
|
|
126
129
|
authWaiter?: (() => void) | undefined // frame-auth handshake resolver
|
|
@@ -227,9 +230,9 @@ export type Engine = {
|
|
|
227
230
|
config: () => ShareConfig
|
|
228
231
|
}
|
|
229
232
|
|
|
230
|
-
// Stderr policy
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
+
// Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
|
|
234
|
+
// only. Informational lines (live:/url file:/mode banner, lifecycle
|
|
235
|
+
// traces) are suppressed because the TUI surfaces the URL itself.
|
|
233
236
|
// All-mode (CI/server-only) keeps its stderr contract (announce lines,
|
|
234
237
|
// silenceable via announceStderr). Error-class lines always reach stderr.
|
|
235
238
|
let stderrInfoAllowed = true
|
|
@@ -284,6 +287,22 @@ function redact<T extends { metadata?: Record<string, unknown> }>(info: T): T {
|
|
|
284
287
|
return { ...info, metadata }
|
|
285
288
|
}
|
|
286
289
|
|
|
290
|
+
/** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
|
|
291
|
+
* session item. Viewers recover them from the envelope's private block
|
|
292
|
+
* (gated+authenticated shares only); anonymous shares never store them. */
|
|
293
|
+
function stripLocalFacts<T>(info: T): T {
|
|
294
|
+
if (!info || typeof info !== "object") return info
|
|
295
|
+
const out = { ...(info as Record<string, unknown>) }
|
|
296
|
+
let changed = false
|
|
297
|
+
for (const k of ["directory", "worktree"]) {
|
|
298
|
+
if (out[k] !== undefined) {
|
|
299
|
+
delete out[k]
|
|
300
|
+
changed = true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return changed ? (out as T) : info
|
|
304
|
+
}
|
|
305
|
+
|
|
287
306
|
function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: SessionOpts | undefined } {
|
|
288
307
|
const raw = info?.metadata?.[METADATA_KEY]
|
|
289
308
|
if (!raw || typeof raw !== "object") return { enabled: false, opts: undefined }
|
|
@@ -293,7 +312,9 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
|
|
|
293
312
|
const opts: SessionOpts = {}
|
|
294
313
|
if (optsRaw && typeof optsRaw === "object") {
|
|
295
314
|
const o = optsRaw as Record<string, unknown>
|
|
296
|
-
|
|
315
|
+
// legacy days-shaped opts migrate x24 (old bindings)
|
|
316
|
+
if (typeof o.ttlHours === "number" && o.ttlHours > 0) opts.ttlHours = Math.floor(o.ttlHours)
|
|
317
|
+
else if (typeof o.ttlDays === "number" && o.ttlDays > 0) opts.ttlHours = Math.floor(o.ttlDays * 24)
|
|
297
318
|
if (typeof o.authLogin === "string") opts.authLogin = o.authLogin
|
|
298
319
|
if (typeof o.authPassword === "string") opts.authPassword = o.authPassword
|
|
299
320
|
if (typeof o.modelLabel === "string") opts.modelLabel = o.modelLabel
|
|
@@ -306,6 +327,23 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
|
|
|
306
327
|
* with what the engine actually targets - file config alone can miss
|
|
307
328
|
* plugin-options-layer config the TUI cannot see). */
|
|
308
329
|
let liveTargets: string[] = []
|
|
330
|
+
// the live engine registers its counter here (the TUI imports the
|
|
331
|
+
// accessor without holding the engine instance)
|
|
332
|
+
let statsProvider: ((sid: string) => { messages: number; toolCalls: number }) | null = null
|
|
333
|
+
|
|
334
|
+
// Wave 3 preflight ledger: per-session redaction hits (count + first
|
|
335
|
+
// samples, verbatim, so false positives are visible in the consent
|
|
336
|
+
// preview before anything is published)
|
|
337
|
+
const redactionLedger = new Map<string, { count: number; samples: RedactionHit[] }>()
|
|
338
|
+
|
|
339
|
+
/** Preflight stats for the consent dialog: message/tool counts plus the
|
|
340
|
+
* redaction ledger (what would be stripped before publishing). */
|
|
341
|
+
export function sessionStatsFor(sid: string): { messages: number; toolCalls: number; redaction: { count: number; samples: RedactionHit[] } } {
|
|
342
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] as RedactionHit[] }
|
|
343
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 }
|
|
344
|
+
return { ...counts, redaction: led }
|
|
345
|
+
}
|
|
346
|
+
|
|
309
347
|
export function engineTargetUrls(): string[] {
|
|
310
348
|
return liveTargets
|
|
311
349
|
}
|
|
@@ -357,9 +395,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
357
395
|
const resolveTargets = (): ServerTarget[] => {
|
|
358
396
|
const merged: Record<string, ServerEntry> = {}
|
|
359
397
|
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
|
|
360
|
-
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers
|
|
361
|
-
//
|
|
362
|
-
//
|
|
398
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
|
|
399
|
+
// would silently dual-home the session; an ENV workerUrl is the
|
|
400
|
+
// highest-priority layer and always joins.
|
|
363
401
|
const envWorker = !!process.env.OPENSHARE_WORKER_URL
|
|
364
402
|
if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
|
|
365
403
|
if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
|
|
@@ -404,39 +442,99 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
404
442
|
}
|
|
405
443
|
seedCredentials()
|
|
406
444
|
const ENROLL_RETRY_MS = 60000
|
|
445
|
+
// B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
|
|
446
|
+
// server per boot, adopt ONLY keys with no explicit local source, and
|
|
447
|
+
// persist the adoption to the settings home (owner: no repeated CF
|
|
448
|
+
// fetches). One flag per URL for the whole process lifetime.
|
|
449
|
+
const setsFetched = new Set<string>()
|
|
450
|
+
const adoptServerSuggestions = async (url: string, token: string) => {
|
|
451
|
+
if (setsFetched.has(url)) return
|
|
452
|
+
setsFetched.add(url)
|
|
453
|
+
try {
|
|
454
|
+
const r = await fetch(`${url}/api/token/sets`, {
|
|
455
|
+
headers: { authorization: `Bearer ${token}` },
|
|
456
|
+
signal: AbortSignal.timeout(8000),
|
|
457
|
+
})
|
|
458
|
+
const body = r.ok ? ((await r.json().catch(() => null)) as { sets?: Record<string, { modelLabel?: string; shareMode?: string }> } | null) : null
|
|
459
|
+
const sets = body?.sets ?? {}
|
|
460
|
+
const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0]
|
|
461
|
+
if (!pick) return
|
|
462
|
+
const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel
|
|
463
|
+
const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined
|
|
464
|
+
// adopt only what is UNSPECIFIED locally: modelLabel sits at the
|
|
465
|
+
// default template, shareMode is unset and env carry no override
|
|
466
|
+
const src = configSourceMap(deps.pluginDir ?? "")
|
|
467
|
+
const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel
|
|
468
|
+
const modeUnset = cfg.shareMode === undefined && !src.shareMode
|
|
469
|
+
const adoptModel = wantModel && modelUnset
|
|
470
|
+
const adoptMode = wantMode && modeUnset
|
|
471
|
+
if (!adoptModel && !adoptMode) return
|
|
472
|
+
// surgical write into the settings home: read, patch just these
|
|
473
|
+
// keys when they are absent, write back (never clobber explicit
|
|
474
|
+
// values that appeared since load)
|
|
475
|
+
const home = configHomePath()
|
|
476
|
+
if (!home) return
|
|
477
|
+
let doc: Record<string, unknown> = {}
|
|
478
|
+
try {
|
|
479
|
+
doc = JSON.parse(readFileSync(home, "utf8"))
|
|
480
|
+
if (!doc || typeof doc !== "object") doc = {}
|
|
481
|
+
} catch {}
|
|
482
|
+
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`) }
|
|
485
|
+
if (dirty) writeFileSync(home, JSON.stringify(doc, null, 2) + "\n")
|
|
486
|
+
} catch {
|
|
487
|
+
// suggestions are best-effort; a failed fetch never blocks sharing
|
|
488
|
+
}
|
|
489
|
+
}
|
|
407
490
|
|
|
408
491
|
const ensureCredential = async (url: string): Promise<string> => {
|
|
409
492
|
const c = credOf(url)
|
|
410
493
|
const target = targets.find((t) => t.url === url)
|
|
411
|
-
if (c.credential)
|
|
494
|
+
if (c.credential) {
|
|
495
|
+
void adoptServerSuggestions(url, c.credential)
|
|
496
|
+
return c.credential
|
|
497
|
+
}
|
|
412
498
|
if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS) return ""
|
|
413
499
|
c.lastEnrollAt = Date.now()
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (b?.ok && typeof b.token === "string") {
|
|
428
|
-
c.credential = b.token
|
|
429
|
-
c.error = ""
|
|
430
|
-
const savedTo = saveServerToken(url, c.credential)
|
|
431
|
-
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
|
|
432
|
-
return c.credential
|
|
500
|
+
// A configured code IS expressed onboarding intent (headless-safe: CI
|
|
501
|
+
// carries it precisely to enroll). Without one, never auto-enroll:
|
|
502
|
+
// public servers share anonymously (the ask-once TUI flow offers an
|
|
503
|
+
// account), private ones refuse until the user connects via
|
|
504
|
+
// /openshare.
|
|
505
|
+
const code = target?.entry.code ?? ""
|
|
506
|
+
if (!code) {
|
|
507
|
+
try {
|
|
508
|
+
const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
|
|
509
|
+
const hb = h.ok ? ((await h.json().catch(() => null)) as { auth?: { mode?: string } } | null) : null
|
|
510
|
+
if (hb?.auth?.mode === "public") {
|
|
511
|
+
c.error = "" // anonymous standing - tokenless create works
|
|
512
|
+
return ""
|
|
433
513
|
}
|
|
434
|
-
c.error =
|
|
435
|
-
}
|
|
436
|
-
c.error =
|
|
437
|
-
} else {
|
|
438
|
-
c.error = `server health unreachable (HTTP ${h.status})`
|
|
514
|
+
c.error = "private server - onboard via /openshare (needs an access code)"
|
|
515
|
+
} catch (e) {
|
|
516
|
+
c.error = `server unreachable: ${(e as Error)?.message || e}`
|
|
439
517
|
}
|
|
518
|
+
logError(`no credential for ${url}: ${c.error}`)
|
|
519
|
+
return ""
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const r = await fetch(`${url}/api/enroll`, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
headers: { "content-type": "application/json" },
|
|
525
|
+
body: JSON.stringify({ code }),
|
|
526
|
+
signal: AbortSignal.timeout(10000),
|
|
527
|
+
})
|
|
528
|
+
const b = r.ok ? ((await r.json().catch(() => null)) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string } | null) : null
|
|
529
|
+
if (b?.ok && typeof b.token === "string") {
|
|
530
|
+
c.credential = b.token
|
|
531
|
+
c.error = ""
|
|
532
|
+
const savedTo = saveServerToken(url, c.credential)
|
|
533
|
+
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
|
|
534
|
+
void adoptServerSuggestions(url, c.credential)
|
|
535
|
+
return c.credential
|
|
536
|
+
}
|
|
537
|
+
c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
|
|
440
538
|
} catch (e) {
|
|
441
539
|
c.error = `enrollment failed: ${(e as Error)?.message || e}`
|
|
442
540
|
}
|
|
@@ -800,10 +898,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
800
898
|
// is absent from `servers`, and dropping the marker would let the next
|
|
801
899
|
// refreshOptOuts resurrect (and re-create) the opted-out server
|
|
802
900
|
for (const url of st.optOuts) if (!servers[url]) servers[url] = { optedOut: true }
|
|
803
|
-
// A binding in session metadata means opted-in
|
|
804
|
-
// modes
|
|
805
|
-
// gate fall through to the consent dialog on an already-shared
|
|
806
|
-
// live is
|
|
901
|
+
// A binding in session metadata means opted-in, so enabled: true is
|
|
902
|
+
// written in BOTH modes. All-mode bindings without `enabled` made the
|
|
903
|
+
// TUI gate fall through to the consent dialog on an already-shared
|
|
904
|
+
// session; live is uniformly "url && enabled !== false".
|
|
807
905
|
const value: Record<string, unknown> = { v: 2, servers, enabled: true }
|
|
808
906
|
if (mode === "manual" && st.opts) value.opts = st.opts
|
|
809
907
|
if (st.lastError) value.lastError = st.lastError
|
|
@@ -856,9 +954,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
856
954
|
try {
|
|
857
955
|
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
|
|
858
956
|
} catch (e) {
|
|
859
|
-
// the frame lives ONLY in memory now
|
|
860
|
-
// spoolLoad
|
|
861
|
-
//
|
|
957
|
+
// the frame lives ONLY in memory now, so flag the remote: the next
|
|
958
|
+
// spoolLoad must NOT replace in-memory pending with the shorter disk
|
|
959
|
+
// queue (silent loss otherwise).
|
|
862
960
|
r.spoolAppendFailed = true
|
|
863
961
|
logError("spool append failed", (e as Error)?.message || e)
|
|
864
962
|
}
|
|
@@ -879,10 +977,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
879
977
|
}
|
|
880
978
|
const spoolLoad = (st: SessionState, r: RemoteState) => {
|
|
881
979
|
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
882
|
-
// file is normal (
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
980
|
+
// file is normal (written only after the first successful handshake)
|
|
981
|
+
// and must NOT touch pending. A blanket catch here wipes in-memory
|
|
982
|
+
// pending AND skips the queue read, so everything queued before the
|
|
983
|
+
// first successful connect is lost.
|
|
886
984
|
try {
|
|
887
985
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
|
|
888
986
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -1058,7 +1156,20 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1058
1156
|
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
1059
1157
|
: join(deps.directory ?? process.cwd(), "openshare.urls.txt")
|
|
1060
1158
|
try {
|
|
1061
|
-
|
|
1159
|
+
// "json" format: one machine-readable object per line (CI parses
|
|
1160
|
+
// fields instead of URLs). Status is the engine's current signal
|
|
1161
|
+
// at announce time; the completed flip updates the SHARE, not
|
|
1162
|
+
// this file line.
|
|
1163
|
+
const line =
|
|
1164
|
+
cfg.urlFileFormat === "json"
|
|
1165
|
+
? JSON.stringify({
|
|
1166
|
+
id: r.shareId,
|
|
1167
|
+
url,
|
|
1168
|
+
status: st.lastSentStatus ?? "waiting",
|
|
1169
|
+
gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
|
|
1170
|
+
}) + "\n"
|
|
1171
|
+
: url + "\n"
|
|
1172
|
+
appendFileSync(file, line)
|
|
1062
1173
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
1063
1174
|
urlFileAnnounced = true
|
|
1064
1175
|
lastUrlFile = file
|
|
@@ -1091,12 +1202,28 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1091
1202
|
return { authLogin: login, authPassword: password }
|
|
1092
1203
|
}
|
|
1093
1204
|
|
|
1094
|
-
const ttlFor = (st: SessionState) => st.opts?.
|
|
1205
|
+
const ttlFor = (st: SessionState) => st.opts?.ttlHours ?? cfg.ttlHours
|
|
1206
|
+
|
|
1207
|
+
// free-form labels for team directories/admin listings: ${NAME} refs
|
|
1208
|
+
// resolve from the process env at send time; unresolved refs stay
|
|
1209
|
+
// literal (visible beats silently-empty). Cached env lookup, max 20
|
|
1210
|
+
// keys/500 chars per value (config loader caps the same).
|
|
1211
|
+
const metaFor = (): Record<string, string> | undefined => {
|
|
1212
|
+
if (!cfg.meta || typeof cfg.meta !== "object") return undefined
|
|
1213
|
+
const out: Record<string, string> = {}
|
|
1214
|
+
let n = 0
|
|
1215
|
+
for (const [k, v] of Object.entries(cfg.meta)) {
|
|
1216
|
+
if (n >= 20) break
|
|
1217
|
+
out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m)
|
|
1218
|
+
n++
|
|
1219
|
+
}
|
|
1220
|
+
return Object.keys(out).length ? out : undefined
|
|
1221
|
+
}
|
|
1095
1222
|
|
|
1096
1223
|
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
1097
1224
|
* live sockets, re-send the attach frame over each open socket: the
|
|
1098
1225
|
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
1099
|
-
* tokens on change) and now also honors an explicit
|
|
1226
|
+
* tokens on change) and now also honors an explicit ttlHours override -
|
|
1100
1227
|
* so settings heal immediately instead of waiting for a reconnect.
|
|
1101
1228
|
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
1102
1229
|
* steady state) and revalidates the token. */
|
|
@@ -1108,9 +1235,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1108
1235
|
for (const [url, r] of st.remotes) {
|
|
1109
1236
|
if (!r.shareId || !r.secret || !r.wsReady) continue
|
|
1110
1237
|
const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
|
|
1111
|
-
if (st.opts?.
|
|
1238
|
+
if (st.opts?.ttlHours) attach.ttlHours = st.opts.ttlHours
|
|
1112
1239
|
const sent = wsSend(r, { op: "init", attach })
|
|
1113
|
-
if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.
|
|
1240
|
+
if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
|
|
1114
1241
|
}
|
|
1115
1242
|
}
|
|
1116
1243
|
|
|
@@ -1131,9 +1258,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1131
1258
|
|
|
1132
1259
|
/** Single-flight connect queuing: multiple paths (activation, event
|
|
1133
1260
|
* reconnect, backfill chain) may want a socket before any connect has
|
|
1134
|
-
* STARTED
|
|
1135
|
-
* and double connects
|
|
1136
|
-
*
|
|
1261
|
+
* STARTED. The connecting flag alone guards execution, not the queue,
|
|
1262
|
+
* and double connects create duplicate shares and init storms (one
|
|
1263
|
+
* create per socket). */
|
|
1137
1264
|
const queueConnect = (st: SessionState, r: RemoteState) => {
|
|
1138
1265
|
if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided) return
|
|
1139
1266
|
r.connectQueued = true
|
|
@@ -1235,12 +1362,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1235
1362
|
|
|
1236
1363
|
const connect = async (st: SessionState, r: RemoteState) => {
|
|
1237
1364
|
if (st.closed || r.connecting || r.authBlocked || r.collided) return
|
|
1238
|
-
// "connecting" spans the WHOLE handshake: socket open
|
|
1239
|
-
// is only
|
|
1240
|
-
// event arriving mid-handshake must NEVER spawn a second socket
|
|
1241
|
-
// old connect() returned after merely SENDING init
|
|
1365
|
+
// "connecting" spans the WHOLE handshake: socket open plus init ack.
|
|
1366
|
+
// It is cleared only on init-ok, socket death or the init timeout. An
|
|
1367
|
+
// event arriving mid-handshake must NEVER spawn a second socket: the
|
|
1368
|
+
// old connect() returned after merely SENDING init, a second socket
|
|
1242
1369
|
// raced it, and the loser's late init-ok poisoned the winner's state
|
|
1243
|
-
//
|
|
1370
|
+
// (duplicate shares, mismatch storms, empty pages).
|
|
1244
1371
|
r.connecting = true
|
|
1245
1372
|
r.socketSeq += 1
|
|
1246
1373
|
const seq = r.socketSeq
|
|
@@ -1256,9 +1383,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1256
1383
|
spoolLoad(st, r)
|
|
1257
1384
|
askPolicy(r.url)
|
|
1258
1385
|
// was this socket's init an ATTACH (resume) or a CREATE? A create
|
|
1259
|
-
// ack legitimately starts at rev 0
|
|
1260
|
-
// attaches only
|
|
1261
|
-
//
|
|
1386
|
+
// ack legitimately starts at rev 0, so the mismatch guard is for
|
|
1387
|
+
// attaches only. Applying it to a create spawns a fresh husk share
|
|
1388
|
+
// with the content dropped.
|
|
1262
1389
|
r.initWasAttach = !!(r.shareId && r.secret)
|
|
1263
1390
|
if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`)
|
|
1264
1391
|
// creates need a credential (enrolls on demand — first contact with
|
|
@@ -1346,7 +1473,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1346
1473
|
if (r.initWasAttach) {
|
|
1347
1474
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
|
|
1348
1475
|
} else {
|
|
1349
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1476
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1350
1477
|
}
|
|
1351
1478
|
|
|
1352
1479
|
// handshake deadline: init sent but no ack (silent worker death)
|
|
@@ -1521,9 +1648,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1521
1648
|
return
|
|
1522
1649
|
}
|
|
1523
1650
|
// share swept / removed / revoked: start fresh, never merge into a
|
|
1524
|
-
// bad base (mismatch protocol). Content REPLAYS to the new share
|
|
1525
|
-
// pending is never dropped on a reset
|
|
1526
|
-
// husk
|
|
1651
|
+
// bad base (mismatch protocol). Content REPLAYS to the new share,
|
|
1652
|
+
// and pending is never dropped on a reset: dropping it leaves the
|
|
1653
|
+
// recreate as a husk with the title and zero messages.
|
|
1527
1654
|
if (/limit|too many|rate/i.test(err)) {
|
|
1528
1655
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1529
1656
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
@@ -1546,19 +1673,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1546
1673
|
r.shareId = null
|
|
1547
1674
|
r.secret = null
|
|
1548
1675
|
r.ackedRev = 0
|
|
1549
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1676
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1550
1677
|
return
|
|
1551
1678
|
}
|
|
1552
1679
|
if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
|
|
1553
|
-
// worker lost data behind our back (ATTACH only
|
|
1554
|
-
// legitimately starts at rev 0
|
|
1555
|
-
//
|
|
1680
|
+
// worker lost data behind our back (ATTACH only: a create ack
|
|
1681
|
+
// legitimately starts at rev 0, and misreading that as data loss
|
|
1682
|
+
// spawns a fresh husk)
|
|
1556
1683
|
logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`)
|
|
1557
1684
|
deleteAbandoned(r)
|
|
1558
1685
|
r.shareId = null
|
|
1559
1686
|
r.secret = null
|
|
1560
1687
|
r.ackedRev = 0
|
|
1561
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1688
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1562
1689
|
return
|
|
1563
1690
|
}
|
|
1564
1691
|
const wasFanoutCreate = !r.initWasAttach && st.everConnected
|
|
@@ -1578,11 +1705,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1578
1705
|
clearTimeout(r.initTimer)
|
|
1579
1706
|
r.initTimer = null
|
|
1580
1707
|
}
|
|
1581
|
-
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
// still ready, no reconnect pending) or a second acked sync.
|
|
1708
|
+
// backoff reset is DEFERRED until the socket proves stable: a server
|
|
1709
|
+
// that accepts init then immediately drops would otherwise reconnect
|
|
1710
|
+
// once per second per session-server pair. Stability means 30s alive
|
|
1711
|
+
// (socket still ready, no reconnect pending) or a second acked sync.
|
|
1586
1712
|
if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
|
|
1587
1713
|
r.backoffResetTimer = setTimeout(() => {
|
|
1588
1714
|
r.backoffResetTimer = null
|
|
@@ -1595,9 +1721,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1595
1721
|
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1596
1722
|
// the binding once, and a successful handshake clears any stale
|
|
1597
1723
|
// refusal reason the TUI may be showing
|
|
1598
|
-
|
|
1724
|
+
const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined)
|
|
1725
|
+
if (typeof ackTtl === "number") {
|
|
1599
1726
|
const wanted = ttlFor(st)
|
|
1600
|
-
const clamp = wanted != null &&
|
|
1727
|
+
const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null
|
|
1601
1728
|
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1602
1729
|
st.ttlClamped = clamp
|
|
1603
1730
|
scheduleBindingWrite(st)
|
|
@@ -1607,6 +1734,36 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1607
1734
|
st.lastError = null
|
|
1608
1735
|
scheduleBindingWrite(st)
|
|
1609
1736
|
}
|
|
1737
|
+
// ack-level gate assurance (B2): we asked for a gate (both fields
|
|
1738
|
+
// set) but the server reports none - the page is actually public.
|
|
1739
|
+
// Once per remote; the next init-ok re-checks after a fix.
|
|
1740
|
+
const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== ""
|
|
1741
|
+
if (wantedGate && frame.auth === false && !r.warnedNoGate) {
|
|
1742
|
+
r.warnedNoGate = true
|
|
1743
|
+
logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`)
|
|
1744
|
+
} else if (frame.auth === true) {
|
|
1745
|
+
r.warnedNoGate = false
|
|
1746
|
+
}
|
|
1747
|
+
// Wave 3 pmeta: machine-local facts (directory/worktree) ride their
|
|
1748
|
+
// OWN tiny sync once per init-ok, only to remotes we hold a
|
|
1749
|
+
// credential for - anonymous (ownerless) shares never store them.
|
|
1750
|
+
// Server-side they land in the private block, readable with share
|
|
1751
|
+
// auth. Idempotent upsert; rides no shared frame so fan-out
|
|
1752
|
+
// remotes with mixed identity each get exactly their due.
|
|
1753
|
+
if (credOf(r.url).credential) {
|
|
1754
|
+
const priv: Record<string, string> = {}
|
|
1755
|
+
const dir = (deps.directory ?? "") as string
|
|
1756
|
+
if (dir) priv.directory = dir
|
|
1757
|
+
if (deps.worktree) priv.worktree = String(deps.worktree)
|
|
1758
|
+
// the sharer's home lets the server mask absolute paths as ~/...
|
|
1759
|
+
try {
|
|
1760
|
+
priv.home = homedir()
|
|
1761
|
+
} catch {}
|
|
1762
|
+
if (Object.keys(priv).length) {
|
|
1763
|
+
const seq = ++nextSeq
|
|
1764
|
+
wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq })
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1610
1767
|
announceUrl(st, r)
|
|
1611
1768
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1612
1769
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -1623,12 +1780,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1623
1780
|
}
|
|
1624
1781
|
if (wasFanoutCreate) {
|
|
1625
1782
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1626
|
-
// post-mismatch recreate): its fresh share needs the full history
|
|
1627
|
-
// backfill is keyed-idempotent
|
|
1628
|
-
// The enqueue dedupe must step aside: those hashes
|
|
1629
|
-
// entered the machinery", but the NEW remote
|
|
1630
|
-
// of it
|
|
1631
|
-
//
|
|
1783
|
+
// post-mismatch recreate): its fresh share needs the full history,
|
|
1784
|
+
// and backfill is keyed-idempotent while other servers simply
|
|
1785
|
+
// re-upsert. The enqueue dedupe must step aside here: those hashes
|
|
1786
|
+
// say "already entered the machinery", but the NEW remote received
|
|
1787
|
+
// none of it, so a deduped join backfills nothing and the joined
|
|
1788
|
+
// share stays empty.
|
|
1632
1789
|
st.sentHash.clear()
|
|
1633
1790
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
|
|
1634
1791
|
}
|
|
@@ -1733,8 +1890,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1733
1890
|
}
|
|
1734
1891
|
|
|
1735
1892
|
// returns a promise when the frame goes out via HTTP (the dispose path
|
|
1736
|
-
// awaits it
|
|
1737
|
-
// final status never lands,
|
|
1893
|
+
// awaits it: an unawaited fetch dies with the exiting process and the
|
|
1894
|
+
// final status never lands, leaving the share stuck on "live")
|
|
1738
1895
|
const flush = (st: SessionState): Promise<unknown> | undefined => {
|
|
1739
1896
|
if (st.closed) return
|
|
1740
1897
|
const f = frameFrom(st)
|
|
@@ -1742,8 +1899,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1742
1899
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1743
1900
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1744
1901
|
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1745
|
-
// chunk-frames so a partial
|
|
1746
|
-
//
|
|
1902
|
+
// chunk-frames, so a partial ack can never erase unsent chunks (a
|
|
1903
|
+
// chunk-1 ack followed by socket death used to drop chunks 2..N).
|
|
1747
1904
|
const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
|
|
1748
1905
|
let httpWork: Promise<unknown> | undefined
|
|
1749
1906
|
for (const r of st.remotes.values()) {
|
|
@@ -1767,8 +1924,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1767
1924
|
}
|
|
1768
1925
|
// never send syncs before the init ack: the worker's per-socket
|
|
1769
1926
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1770
|
-
// sync dies as "unknown op"
|
|
1771
|
-
//
|
|
1927
|
+
// sync dies as "unknown op", losing whole turns on post-drop
|
|
1928
|
+
// reconnects. The init-ok handler flushes pending itself.
|
|
1772
1929
|
if (r.ws && !r.wsReady) continue
|
|
1773
1930
|
if (r.wsReady) {
|
|
1774
1931
|
for (const cf of fresh) sendSync(r, cf)
|
|
@@ -1803,13 +1960,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1803
1960
|
|
|
1804
1961
|
const flushViaHttp = async (st: SessionState, r: RemoteState) => {
|
|
1805
1962
|
if (!r.shareId || !r.pending.length) return
|
|
1806
|
-
// ALL pending frames, merged (keyed upserts
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
// server row-write + a rebroadcast frame.
|
|
1963
|
+
// ALL pending frames, merged (keyed upserts, later wins). Sending only
|
|
1964
|
+
// the last frame dropped every earlier batch's items whenever the HTTP
|
|
1965
|
+
// fallback carried more than one, losing content on reconnect paths.
|
|
1966
|
+
// DEDUPE BY KEY before chunking: a streaming message appearing in k
|
|
1967
|
+
// pending frames shipped k copies (up to 25x on a replay burst), and
|
|
1968
|
+
// every duplicate cost a server row-write plus a rebroadcast frame.
|
|
1813
1969
|
const byKey = new Map<string, unknown>()
|
|
1814
1970
|
const unkeyed: unknown[] = []
|
|
1815
1971
|
let status: string | undefined
|
|
@@ -1876,15 +2032,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1876
2032
|
}, wait)
|
|
1877
2033
|
}
|
|
1878
2034
|
|
|
2035
|
+
const homeDir = (() => { try { return homedir() } catch { return "" } })()
|
|
2036
|
+
const userPatterns = (cfg as { redact?: { patterns?: unknown } }).redact?.patterns
|
|
2037
|
+
const redactionRules = activeRules(userPatterns)
|
|
2038
|
+
|
|
1879
2039
|
const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
|
|
1880
2040
|
if (!st || !entry) return
|
|
1881
2041
|
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1882
|
-
// either entered the queue already or been flushed into every
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
// dormant and
|
|
2042
|
+
// either entered the queue already or been flushed into every remote's
|
|
2043
|
+
// pending/spool, and the per-remote delivery machinery owns it from
|
|
2044
|
+
// there. Re-sending it bumps rev, rewrites storage and re-folds every
|
|
2045
|
+
// viewer for nothing. This is the engine-side home dedupe belongs in:
|
|
2046
|
+
// an adapter-side cache can be poisoned while the session is still
|
|
2047
|
+
// dormant, and then the flag-time content is skipped.
|
|
1888
2048
|
let h: string
|
|
1889
2049
|
try {
|
|
1890
2050
|
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
|
|
@@ -1896,6 +2056,24 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1896
2056
|
return
|
|
1897
2057
|
}
|
|
1898
2058
|
if (h) st.sentHash.set(entry.key, h)
|
|
2059
|
+
// Wave 3 trust pass: redaction on UNGATED shares only (a gated share
|
|
2060
|
+
// is private from casual observers already; the server owner sees
|
|
2061
|
+
// content either way until encryption lands). The light path
|
|
2062
|
+
// transform applies regardless. Zero-copy when nothing matches.
|
|
2063
|
+
{
|
|
2064
|
+
const gate = authFrame(st)
|
|
2065
|
+
const gated = gate.authLogin !== "" && gate.authPassword !== ""
|
|
2066
|
+
const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated })
|
|
2067
|
+
if (pass.item !== entry.item) entry = { ...entry, item: pass.item as typeof entry.item }
|
|
2068
|
+
if (pass.hits.length) {
|
|
2069
|
+
const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] as RedactionHit[] }
|
|
2070
|
+
led.count += pass.hits.length
|
|
2071
|
+
for (const hit of pass.hits) {
|
|
2072
|
+
if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind)) led.samples.push(hit)
|
|
2073
|
+
}
|
|
2074
|
+
redactionLedger.set(st.sid, led)
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1899
2077
|
st.queue.set(entry.key, entry.item)
|
|
1900
2078
|
indexMessage(entry)
|
|
1901
2079
|
if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
|
|
@@ -1904,6 +2082,24 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1904
2082
|
scheduleFlush(st)
|
|
1905
2083
|
}
|
|
1906
2084
|
|
|
2085
|
+
// Wave 3 preflight counts: the module-level accessor delegates here
|
|
2086
|
+
statsProvider = (sid: string) => {
|
|
2087
|
+
const st = sessions.get(sid)
|
|
2088
|
+
let messages = 0
|
|
2089
|
+
let toolCalls = 0
|
|
2090
|
+
if (st) {
|
|
2091
|
+
for (const [, merged] of st.merged) {
|
|
2092
|
+
messages++
|
|
2093
|
+
const content = (merged as { content?: unknown }).content
|
|
2094
|
+
if (Array.isArray(content)) toolCalls += content.filter((c: unknown) => !!c && (c as { type?: string }).type === "tool").length
|
|
2095
|
+
}
|
|
2096
|
+
for (const it of st.queue.values()) {
|
|
2097
|
+
if ((it as { type?: string })?.type === "message") messages++
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return { messages, toolCalls }
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1907
2103
|
// ---- authoritative stamps ----------------------------------------------
|
|
1908
2104
|
// The viewer renders stamped facts and never re-derives them (owner
|
|
1909
2105
|
// directive). Both stamps are computed at frame time across the whole
|
|
@@ -1919,10 +2115,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1919
2115
|
}
|
|
1920
2116
|
const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
|
|
1921
2117
|
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1922
|
-
* undone message
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
* deleted turn server-side). */
|
|
2118
|
+
* undone message. dropMergedMessage removes the merged entry but msgIndex
|
|
2119
|
+
* keeps the corpse, so a neighboring-segment change recomputes its
|
|
2120
|
+
* stamps and reEnqueueMessage resurrects the deleted turn server-side. */
|
|
1926
2121
|
const tombstoned = new Set<string>()
|
|
1927
2122
|
|
|
1928
2123
|
const indexMessage = (entry: ItemEntry) => {
|
|
@@ -1985,11 +2180,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1985
2180
|
const agent = segmentAgent(list, users[k])
|
|
1986
2181
|
const d = list[users[k]].data as Record<string, any>
|
|
1987
2182
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
|
|
1988
|
-
// only touch metadata when the stamp actually CHANGES
|
|
1989
|
-
// `d.metadata ??= {}`
|
|
2183
|
+
// only touch metadata when the stamp actually CHANGES: a blanket
|
|
2184
|
+
// `d.metadata ??= {}` pollutes every user message (including the
|
|
1990
2185
|
// host's live objects on v2 pass-through) with an empty metadata
|
|
1991
|
-
// object, which re-
|
|
1992
|
-
// per feed
|
|
2186
|
+
// object, which re-emits the message once per refresh and churns
|
|
2187
|
+
// rev per feed.
|
|
1993
2188
|
if (want !== (d.metadata?.agentSwitch as string | undefined)) {
|
|
1994
2189
|
if (want) {
|
|
1995
2190
|
const meta = (d.metadata ??= {}) as Record<string, unknown>
|
|
@@ -2053,8 +2248,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2053
2248
|
}
|
|
2054
2249
|
|
|
2055
2250
|
/** file tools get metadata.language (shiki lang id, from the path the
|
|
2056
|
-
* call touched)
|
|
2057
|
-
*
|
|
2251
|
+
* call touched). The viewer highlights from this stamp and never
|
|
2252
|
+
* guesses; its extension map only covers old shares without one. */
|
|
2058
2253
|
const LANG_BY_EXT: Record<string, string> = {
|
|
2059
2254
|
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
2060
2255
|
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
@@ -2065,7 +2260,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2065
2260
|
sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
2066
2261
|
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
2067
2262
|
}
|
|
2263
|
+
|
|
2068
2264
|
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
|
|
2265
|
+
|
|
2069
2266
|
const stampFileLanguages = () => {
|
|
2070
2267
|
for (const m of msgIndex.values()) {
|
|
2071
2268
|
if (m.role !== "assistant") continue
|
|
@@ -2078,6 +2275,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2078
2275
|
const state = part.state
|
|
2079
2276
|
const meta = state?.metadata
|
|
2080
2277
|
if (!meta) continue
|
|
2278
|
+
// paths stay absolute here: the server rewrites them against the
|
|
2279
|
+
// session's working folder on read (wireItems), so old shares get
|
|
2280
|
+
// the same treatment and the host's message objects are never
|
|
2281
|
+
// mutated
|
|
2081
2282
|
const raw = state.input?.path ?? state.input?.filePath
|
|
2082
2283
|
// language stamp (shiki id from the path)
|
|
2083
2284
|
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
@@ -2238,9 +2439,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2238
2439
|
r.idleDropTimer = null
|
|
2239
2440
|
}
|
|
2240
2441
|
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2241
|
-
// child teardown clears shareId after adopting into the parent
|
|
2242
|
-
// the ControlDO's server-side attach would
|
|
2243
|
-
//
|
|
2442
|
+
// child teardown clears shareId after adopting into the parent, and
|
|
2443
|
+
// the ControlDO's server-side attach would then delete the very
|
|
2444
|
+
// share the parent just inherited.
|
|
2244
2445
|
if (r.wsReady && r.shareId) {
|
|
2245
2446
|
wsSend(r, { op: "delete" })
|
|
2246
2447
|
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
@@ -2349,9 +2550,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2349
2550
|
const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
|
|
2350
2551
|
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
|
|
2351
2552
|
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2352
|
-
// tail (message-v2.ts codec).
|
|
2353
|
-
//
|
|
2354
|
-
// 100-message page
|
|
2553
|
+
// tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
|
|
2554
|
+
// bare message id, which truncates a v1 backfill to its first
|
|
2555
|
+
// 100-message page.
|
|
2355
2556
|
const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
|
|
2356
2557
|
if (!lastID || typeof lastTime !== "number") break
|
|
2357
2558
|
const nextBefore = cursorFor(lastID, lastTime)
|
|
@@ -2368,7 +2569,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2368
2569
|
|
|
2369
2570
|
const backfillSession = async (st: SessionState, client: ClientApi, sid: string, info?: SessionInfo) => {
|
|
2370
2571
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid))
|
|
2371
|
-
if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
|
|
2572
|
+
if (sessionInfoValue) enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid))
|
|
2372
2573
|
|
|
2373
2574
|
let modelSeen = false
|
|
2374
2575
|
const consume = (messages: Message[]) => {
|
|
@@ -2416,7 +2617,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2416
2617
|
// nothing synced yet and no info in hand: the session item is the
|
|
2417
2618
|
// one thing a viewer always needs — fetch it explicitly
|
|
2418
2619
|
const fetched = await sessionInfo(client, st.sid)
|
|
2419
|
-
if (fetched) enqueue(st, itemFor("session", redact(fetched), st.sid))
|
|
2620
|
+
if (fetched) enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid))
|
|
2420
2621
|
}
|
|
2421
2622
|
|
|
2422
2623
|
// children (subagent sessions) share the parent's page
|
|
@@ -2508,7 +2709,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2508
2709
|
// activates anything on its own
|
|
2509
2710
|
const st = sessions.get(parent)
|
|
2510
2711
|
if (st?.activated && !st.closed) {
|
|
2511
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2712
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2512
2713
|
}
|
|
2513
2714
|
return
|
|
2514
2715
|
}
|
|
@@ -2518,7 +2719,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2518
2719
|
if (st?.activated && !st.closed) {
|
|
2519
2720
|
// already sharing: opts updates apply, flag removal handled below
|
|
2520
2721
|
if (flag.opts) applyOpts(st, flag.opts)
|
|
2521
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2722
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2522
2723
|
if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
2523
2724
|
return
|
|
2524
2725
|
}
|
|
@@ -2578,13 +2779,14 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2578
2779
|
}
|
|
2579
2780
|
}
|
|
2580
2781
|
if (!st) return
|
|
2581
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2782
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2582
2783
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
2583
2784
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
2584
2785
|
}
|
|
2585
2786
|
}
|
|
2586
2787
|
|
|
2587
2788
|
return {
|
|
2789
|
+
|
|
2588
2790
|
mode,
|
|
2589
2791
|
config: () => cfg,
|
|
2590
2792
|
hooks: {
|
|
@@ -2607,12 +2809,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2607
2809
|
touch(st)
|
|
2608
2810
|
if (type === "busy") {
|
|
2609
2811
|
// work resumed: a pending completed flip is cancelled (opencode
|
|
2610
|
-
// idles between sequential automation prompts
|
|
2611
|
-
// "done"), a recovered retry is forgiven, share is live
|
|
2612
|
-
// The busy epoch is the completed-flip guard: only WORK
|
|
2613
|
-
// invalidates a pending settle
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2812
|
+
// idles between sequential automation prompts, which is NOT
|
|
2813
|
+
// "done"), a recovered retry is forgiven, and the share is live
|
|
2814
|
+
// again. The busy epoch is the completed-flip guard: only WORK
|
|
2815
|
+
// invalidates a pending settle. v1.18 republishes the status/idle
|
|
2816
|
+
// pair on failure paths, and a repeated idle must NOT void it or
|
|
2817
|
+
// the share stays on live forever.
|
|
2616
2818
|
st.statusEpoch++
|
|
2617
2819
|
if (st.completedTimer) {
|
|
2618
2820
|
clearTimeout(st.completedTimer)
|
|
@@ -2625,8 +2827,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2625
2827
|
} else if (type === "idle") {
|
|
2626
2828
|
// opencode's idle is authoritative turn end (v1 holds busy
|
|
2627
2829
|
// through steps, tool runs and permission waits). CONTENT
|
|
2628
|
-
// flushes now
|
|
2629
|
-
// interval (
|
|
2830
|
+
// flushes now, because turn-end batches must not ride the sync
|
|
2831
|
+
// interval (subagent finals lagged one interval otherwise).
|
|
2630
2832
|
// Only the completed flip waits out completedDelayMs, guarded
|
|
2631
2833
|
// by the busy epoch: work resuming voids it, repeated idle
|
|
2632
2834
|
// publications do not. Dispose flushes a pending flip, so CI
|
|
@@ -2758,11 +2960,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2758
2960
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
|
|
2759
2961
|
enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid))
|
|
2760
2962
|
}
|
|
2761
|
-
// an assistant message that DIED (finish:"error")
|
|
2762
|
-
// retries exhausted
|
|
2763
|
-
// message
|
|
2764
|
-
//
|
|
2765
|
-
//
|
|
2963
|
+
// an assistant message that DIED (finish:"error"), e.g. all
|
|
2964
|
+
// retries exhausted, surfaces as a 200 response with an errored
|
|
2965
|
+
// message rather than session.error. The share must still say
|
|
2966
|
+
// error. Works for v2-native messages too (finish lives on the
|
|
2967
|
+
// message itself).
|
|
2766
2968
|
if ((info as { finish?: string; role?: string })?.finish === "error") {
|
|
2767
2969
|
st.badSignal = true
|
|
2768
2970
|
}
|