@venturewild/workspace 0.6.11 → 0.6.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,262 @@
1
+ // Chat sessions — first-class, server-backed conversations per workspace.
2
+ //
3
+ // WHY: chat history used to live ONLY in each browser's localStorage, so the same
4
+ // workspace opened on a second device showed a blank chat. A "session" is now a
5
+ // thread made first-class and stored on the HOST (under the workspace's
6
+ // `.wild-workspace/sessions/`), so every device that reaches this host (all of one
7
+ // account's devices do — vw-proxy → one daemon → one server) sees, switches, and
8
+ // continues the same conversations. The Claude *resume token* still lives in
9
+ // `chat-session[-<id>].json` (unchanged); this store holds the VISIBLE message
10
+ // history + a per-workspace session registry.
11
+ //
12
+ // Content on the host disk has precedent (transcript.mjs already writes the
13
+ // conversation to ~/.wild-workspace/transcripts/<ws>/). `.wild-workspace` is
14
+ // gitignored AND daemon-sync-ignored, so this never pollutes the user's repo or
15
+ // collides with file-sync. Cross-MEMBER (different accounts on different hosts)
16
+ // sharing rides the rails — see session-rails.mjs.
17
+ //
18
+ // Modeled on settings.mjs / canvas/core.mjs: atomic temp-write+rename, hard caps,
19
+ // and degrade-never-throw (a read-only FS must never break chat — it just falls
20
+ // back to the client's localStorage cache).
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { nanoid } from 'nanoid';
25
+
26
+ // The primary/legacy chat. Maps to threadId=null for turnKey + the Claude resume
27
+ // file (`chat-session.json`) so existing single-chat installs are byte-identical
28
+ // on upgrade. Its message history lives in `sessions/main.json`.
29
+ export const MAIN_SESSION_ID = 'main';
30
+
31
+ const MAX_MESSAGES = 200; // server keeps a bit more than the client's localStorage cap
32
+ const MAX_BYTES = 2 * 1024 * 1024; // 2 MB per session's messages file
33
+ const MAX_SESSIONS = 200; // registry soft cap
34
+ const TITLE_CAP = 120;
35
+
36
+ // An id is usable as a filename component ONLY if it round-trips through the same
37
+ // charset sanitizeThreadId (index.mjs) enforces — reject anything else so the
38
+ // `<id>.json` path can never traverse. nanoid()'s alphabet (A-Za-z0-9_-) + 'main'
39
+ // pass unchanged.
40
+ function safeId(id) {
41
+ if (typeof id !== 'string' || !id || id.length > 64) return null;
42
+ if (id === '.' || id === '..') return null;
43
+ return /^[a-zA-Z0-9._-]+$/.test(id) ? id : null;
44
+ }
45
+
46
+ function readJsonSafe(file, fallback) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
49
+ } catch {
50
+ return fallback;
51
+ }
52
+ }
53
+
54
+ function nowMs() {
55
+ return Date.now();
56
+ }
57
+
58
+ // Recency ordering needs strictly-increasing activity stamps: two touches in the
59
+ // same millisecond (easy on a fast machine/CI) would otherwise tie and the sort
60
+ // would fall back to undefined order. Clamp each new stamp strictly above every
61
+ // existing one (mirrors workspace-registry.mjs nextOpenStamp).
62
+ function nextStamp(sessions) {
63
+ let max = 0;
64
+ for (const s of sessions) {
65
+ if (s.lastActivityAt > max) max = s.lastActivityAt;
66
+ if (s.createdAt > max) max = s.createdAt;
67
+ }
68
+ return Math.max(nowMs(), max + 1);
69
+ }
70
+
71
+ function sanitizeRec(s) {
72
+ const id = safeId(s?.id);
73
+ if (!id) return null;
74
+ return {
75
+ id,
76
+ title: typeof s.title === 'string' && s.title.trim() ? s.title.trim().slice(0, TITLE_CAP) : null,
77
+ account: typeof s.account === 'string' && s.account ? s.account : 'local',
78
+ createdAt: Number(s.createdAt) || nowMs(),
79
+ lastActivityAt: Number(s.lastActivityAt) || Number(s.createdAt) || nowMs(),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Per-workspace session store. Construct from the workspace's dataDir (the
85
+ * `.wild-workspace` folder) — `createChatSessions({ dataDir: workspaceFor(c).dataDir })`.
86
+ */
87
+ export function createChatSessions({ dataDir } = {}) {
88
+ const dir = path.join(dataDir, 'sessions');
89
+ const indexFile = path.join(dir, 'index.json');
90
+ const msgFile = (id) => path.join(dir, `${id}.json`);
91
+ // The Claude resume file for a session: Main → legacy un-suffixed; others suffixed.
92
+ const resumeFile = (id) =>
93
+ id === MAIN_SESSION_ID
94
+ ? path.join(dataDir, 'chat-session.json')
95
+ : path.join(dataDir, `chat-session-${id}.json`);
96
+
97
+ function readIndex() {
98
+ const v = readJsonSafe(indexFile, null);
99
+ const arr = v && Array.isArray(v.sessions) ? v.sessions : [];
100
+ return arr.map(sanitizeRec).filter(Boolean);
101
+ }
102
+
103
+ function writeIndex(sessions) {
104
+ try {
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ const tmp = `${indexFile}.${process.pid}.tmp`;
107
+ fs.writeFileSync(tmp, JSON.stringify({ sessions }, null, 2));
108
+ fs.renameSync(tmp, indexFile);
109
+ } catch {
110
+ /* read-only fs — degrade to not-persisted (client localStorage still works) */
111
+ }
112
+ }
113
+
114
+ /** All sessions, most-recently-active first. */
115
+ function list() {
116
+ return readIndex().sort((a, b) => b.lastActivityAt - a.lastActivityAt);
117
+ }
118
+
119
+ function findIndex(sessions, id) {
120
+ return sessions.findIndex((s) => s.id === id);
121
+ }
122
+
123
+ /** Idempotently ensure the Main session exists (maps to the legacy null thread). */
124
+ function ensureMain(account = 'local') {
125
+ const sessions = readIndex();
126
+ if (findIndex(sessions, MAIN_SESSION_ID) >= 0) return;
127
+ sessions.push(
128
+ sanitizeRec({
129
+ id: MAIN_SESSION_ID,
130
+ title: 'Main',
131
+ account,
132
+ createdAt: nowMs(),
133
+ lastActivityAt: nowMs(),
134
+ }),
135
+ );
136
+ writeIndex(sessions);
137
+ }
138
+
139
+ /** Create a new session. Returns the record. */
140
+ function create({ title, account } = {}) {
141
+ const sessions = readIndex();
142
+ // Soft cap: evict the oldest non-Main session that has no stored messages.
143
+ if (sessions.length >= MAX_SESSIONS) {
144
+ const victim = [...sessions]
145
+ .filter((s) => s.id !== MAIN_SESSION_ID && !fs.existsSync(msgFile(s.id)))
146
+ .sort((a, b) => a.lastActivityAt - b.lastActivityAt)[0];
147
+ if (victim) sessions.splice(findIndex(sessions, victim.id), 1);
148
+ }
149
+ const stamp = nextStamp(sessions);
150
+ const rec = sanitizeRec({
151
+ id: nanoid(10),
152
+ title: title || null,
153
+ account: account || 'local',
154
+ createdAt: stamp,
155
+ lastActivityAt: stamp,
156
+ });
157
+ sessions.push(rec);
158
+ writeIndex(sessions);
159
+ return rec;
160
+ }
161
+
162
+ function getMessages(id) {
163
+ const safe = safeId(id);
164
+ if (!safe) return [];
165
+ const v = readJsonSafe(msgFile(safe), null);
166
+ return v && Array.isArray(v.messages) ? v.messages : [];
167
+ }
168
+
169
+ /**
170
+ * Replace a session's messages (capped). Upserts the registry record (so a PUT
171
+ * is self-sufficient even if create raced). Returns { ok, count } — ok:false on
172
+ * a bad id or an oversized body.
173
+ */
174
+ function putMessages(id, messages, { account } = {}) {
175
+ const safe = safeId(id);
176
+ if (!safe || !Array.isArray(messages)) return { ok: false, count: 0 };
177
+ const capped = messages.slice(-MAX_MESSAGES);
178
+ const body = JSON.stringify({ messages: capped });
179
+ if (Buffer.byteLength(body, 'utf8') > MAX_BYTES) return { ok: false, count: 0, error: 'too_large' };
180
+ try {
181
+ fs.mkdirSync(dir, { recursive: true });
182
+ const tmp = `${msgFile(safe)}.${process.pid}.tmp`;
183
+ fs.writeFileSync(tmp, body);
184
+ fs.renameSync(tmp, msgFile(safe));
185
+ } catch {
186
+ return { ok: false, count: 0, error: 'write_failed' };
187
+ }
188
+ // Upsert + touch the registry record.
189
+ const sessions = readIndex();
190
+ const stamp = nextStamp(sessions);
191
+ const i = findIndex(sessions, safe);
192
+ if (i >= 0) {
193
+ sessions[i] = { ...sessions[i], lastActivityAt: stamp };
194
+ } else {
195
+ sessions.push(
196
+ sanitizeRec({
197
+ id: safe,
198
+ title: safe === MAIN_SESSION_ID ? 'Main' : null,
199
+ account: account || 'local',
200
+ createdAt: stamp,
201
+ lastActivityAt: stamp,
202
+ }),
203
+ );
204
+ }
205
+ writeIndex(sessions);
206
+ return { ok: true, count: capped.length };
207
+ }
208
+
209
+ function rename(id, title) {
210
+ const safe = safeId(id);
211
+ if (!safe) return null;
212
+ const sessions = readIndex();
213
+ const i = findIndex(sessions, safe);
214
+ if (i < 0) return null;
215
+ sessions[i] = {
216
+ ...sessions[i],
217
+ title: typeof title === 'string' && title.trim() ? title.trim().slice(0, TITLE_CAP) : null,
218
+ };
219
+ writeIndex(sessions);
220
+ return sessions[i];
221
+ }
222
+
223
+ /** Remove a session (never Main). Also unlinks its messages + Claude resume file. */
224
+ function remove(id) {
225
+ const safe = safeId(id);
226
+ if (!safe || safe === MAIN_SESSION_ID) return false;
227
+ const sessions = readIndex();
228
+ const i = findIndex(sessions, safe);
229
+ if (i < 0) return false;
230
+ sessions.splice(i, 1);
231
+ writeIndex(sessions);
232
+ for (const f of [msgFile(safe), resumeFile(safe)]) {
233
+ try { fs.unlinkSync(f); } catch { /* already gone / read-only */ }
234
+ }
235
+ return true;
236
+ }
237
+
238
+ /** Bump lastActivityAt (used when a turn finishes, even from another device). */
239
+ function touch(id, { account } = {}) {
240
+ const safe = safeId(id);
241
+ if (!safe) return;
242
+ const sessions = readIndex();
243
+ const stamp = nextStamp(sessions);
244
+ const i = findIndex(sessions, safe);
245
+ if (i >= 0) {
246
+ sessions[i] = { ...sessions[i], lastActivityAt: stamp };
247
+ } else {
248
+ sessions.push(
249
+ sanitizeRec({
250
+ id: safe,
251
+ title: safe === MAIN_SESSION_ID ? 'Main' : null,
252
+ account: account || 'local',
253
+ createdAt: stamp,
254
+ lastActivityAt: stamp,
255
+ }),
256
+ );
257
+ }
258
+ writeIndex(sessions);
259
+ }
260
+
261
+ return { dir, list, ensureMain, create, getMessages, putMessages, rename, remove, touch };
262
+ }
@@ -34,6 +34,8 @@ import { PairingStore } from './pairing.mjs';
34
34
  import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
35
35
  import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
36
36
  import { browseDir, browseRoots, listDrives } from './lobby-browse.mjs';
37
+ import { createChatSessions, MAIN_SESSION_ID } from './chat-sessions.mjs';
38
+ import { createSessionRails } from './session-rails.mjs';
37
39
  import { InboxWatcher } from './inbox.mjs';
38
40
  import { ActivityBus } from './activity.mjs';
39
41
  import { createWorkspacePresence } from './workspace-presence.mjs';
@@ -424,6 +426,9 @@ export async function createServer(overrides = {}) {
424
426
  // cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
425
427
  // agent theme). Person-scoped state stores are built lazily + cached per personKey.
426
428
  const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
429
+ // Cross-host shared chat sessions client (Phase 2). Inert without an account /
430
+ // bmo-sync URL; only used for SHARED workspaces (local ones stay per-host).
431
+ const sessionRails = overrides.sessionRails || createSessionRails(config, config.account);
427
432
  // Shared-workspace membership client (sharing slice — design §4). The account
428
433
  // token is kept top-level on config (out of the broadcast `config.account`), so
429
434
  // pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
@@ -703,6 +708,11 @@ export async function createServer(overrides = {}) {
703
708
  saveChatSessionId(workspace.dataDir, threadId, session.sessionId);
704
709
  }
705
710
  }
711
+ // Keep the session list's recency honest even when the turn was driven from
712
+ // another device (the message PUT is client-driven; this is server-side).
713
+ try {
714
+ createChatSessions({ dataDir: workspace.dataDir }).touch(threadId || MAIN_SESSION_ID);
715
+ } catch { /* best-effort — never break a turn on a registry hiccup */ }
706
716
  broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
707
717
  activityBus.publish({ type: 'chat-end', messageId: id, code });
708
718
  });
@@ -2783,6 +2793,149 @@ export async function createServer(overrides = {}) {
2783
2793
  return c.json({ ok: true, ...result });
2784
2794
  });
2785
2795
 
2796
+ // --- chat sessions (server-backed, cross-device) ---------------------------
2797
+ // A "session" is a thread made first-class + stored on the host, so every device
2798
+ // that reaches this host (all of one account's devices) sees/switches/continues
2799
+ // the same conversations. Main maps to the legacy null thread. Reads gate on
2800
+ // `chat`, writes on `chatWrite` (viewers get a read-only list). Per-workspace via
2801
+ // workspaceFor(c).dataDir. (Cross-MEMBER sharing rides the rails — Phase 2.)
2802
+ function sessionsFor(c) {
2803
+ return createChatSessions({ dataDir: workspaceFor(c).dataDir });
2804
+ }
2805
+ // A session id → the turn/resume threadId (Main is the legacy null thread).
2806
+ const threadForSession = (id) => (id === MAIN_SESSION_ID ? null : id);
2807
+ const accountLabel = () => config.account?.email || 'local';
2808
+ // The shared-workspace slug for the active workspace, or null (a LOCAL workspace
2809
+ // stays per-host — Phase 1 only). Phase 2 syncs shared sessions over the rails.
2810
+ function sharedSlugFor(c) {
2811
+ const entry = getWorkspace(workspaceFor(c).id, registryEnv);
2812
+ return entry?.kind === 'shared' && entry.shared?.slug ? entry.shared.slug : null;
2813
+ }
2814
+ // The rails session id is namespaced "<slug>::<localId>" (globally unique + tied
2815
+ // to the workspace). Map both directions.
2816
+ const railsId = (slug, lid) => `${slug}::${lid}`;
2817
+ const fromRailsId = (slug, rid) =>
2818
+ rid.startsWith(`${slug}::`) ? rid.slice(slug.length + 2) : rid;
2819
+
2820
+ app.get('/api/sessions', async (c) => {
2821
+ const forbidden = require(c, 'chat');
2822
+ if (forbidden) return forbidden;
2823
+ const ws = workspaceFor(c);
2824
+ const store = sessionsFor(c);
2825
+ store.ensureMain(accountLabel());
2826
+ const byId = new Map(store.list().map((s) => [s.id, s]));
2827
+ // Shared workspace → merge in the cross-host sessions (labeled by owner email).
2828
+ const slug = sharedSlugFor(c);
2829
+ if (slug && sessionRails.capable) {
2830
+ const r = await sessionRails.listSessions(slug);
2831
+ if (r.ok) {
2832
+ for (const rs of r.sessions) {
2833
+ const lid = fromRailsId(slug, rs.id);
2834
+ const existing = byId.get(lid);
2835
+ byId.set(lid, {
2836
+ id: lid,
2837
+ title: rs.title || existing?.title || (lid === MAIN_SESSION_ID ? 'Main' : null),
2838
+ account: rs.owner_email || existing?.account || 'local',
2839
+ createdAt: (rs.created_at ? rs.created_at * 1000 : existing?.createdAt) || Date.now(),
2840
+ lastActivityAt: (rs.updated_at ? rs.updated_at * 1000 : existing?.lastActivityAt) || 0,
2841
+ });
2842
+ }
2843
+ }
2844
+ }
2845
+ const sessions = [...byId.values()]
2846
+ .sort((a, b) => b.lastActivityAt - a.lastActivityAt)
2847
+ .map((s) => ({ ...s, live: currentTurns.has(turnKey(ws.id, threadForSession(s.id))) }));
2848
+ return c.json({ sessions });
2849
+ });
2850
+
2851
+ app.post('/api/sessions', async (c) => {
2852
+ const forbidden = require(c, 'chatWrite');
2853
+ if (forbidden) return forbidden;
2854
+ const body = await c.req.json().catch(() => ({}));
2855
+ const rec = sessionsFor(c).create({
2856
+ title: typeof body.title === 'string' ? body.title : null,
2857
+ account: accountLabel(),
2858
+ });
2859
+ const slug = sharedSlugFor(c);
2860
+ if (slug && sessionRails.capable) {
2861
+ sessionRails.createSession(slug, railsId(slug, rec.id), rec.title).catch(() => {});
2862
+ }
2863
+ auditAction(c, 'sessions.create', `id=${rec.id}`);
2864
+ return c.json({ session: rec });
2865
+ });
2866
+
2867
+ app.get('/api/sessions/:id/messages', async (c) => {
2868
+ const forbidden = require(c, 'chat');
2869
+ if (forbidden) return forbidden;
2870
+ const id = c.req.param('id');
2871
+ const slug = sharedSlugFor(c);
2872
+ if (slug && sessionRails.capable) {
2873
+ const r = await sessionRails.pullMessages(slug, railsId(slug, id));
2874
+ if (r.ok && r.messages.length) {
2875
+ const msgs = [];
2876
+ for (const m of r.messages) {
2877
+ try { msgs.push(JSON.parse(m.json)); } catch { /* skip a corrupt row */ }
2878
+ }
2879
+ // refresh the local cache so an offline reload still has it
2880
+ try { sessionsFor(c).putMessages(id, msgs, { account: accountLabel() }); } catch { /* ignore */ }
2881
+ return c.json({ messages: msgs });
2882
+ }
2883
+ // rails empty/unreachable → fall through to the local store
2884
+ }
2885
+ return c.json({ messages: sessionsFor(c).getMessages(id) });
2886
+ });
2887
+
2888
+ app.put('/api/sessions/:id/messages', async (c) => {
2889
+ const forbidden = require(c, 'chatWrite');
2890
+ if (forbidden) return forbidden;
2891
+ const id = c.req.param('id');
2892
+ const body = await c.req.json().catch(() => ({}));
2893
+ const messages = Array.isArray(body.messages) ? body.messages : [];
2894
+ const res = sessionsFor(c).putMessages(id, messages, { account: accountLabel() });
2895
+ if (!res.ok) return c.json({ error: res.error || 'bad_request' }, 400);
2896
+ const slug = sharedSlugFor(c);
2897
+ if (slug && sessionRails.capable) {
2898
+ const payload = messages
2899
+ .slice(-200)
2900
+ .filter((m) => m && m.id != null)
2901
+ .map((m) => ({ id: String(m.id), json: JSON.stringify(m) }));
2902
+ if (payload.length) sessionRails.pushMessages(slug, railsId(slug, id), payload).catch(() => {});
2903
+ }
2904
+ return c.json({ ok: true, count: res.count });
2905
+ });
2906
+
2907
+ app.patch('/api/sessions/:id', async (c) => {
2908
+ const forbidden = require(c, 'chatWrite');
2909
+ if (forbidden) return forbidden;
2910
+ const id = c.req.param('id');
2911
+ const body = await c.req.json().catch(() => ({}));
2912
+ const rec = sessionsFor(c).rename(id, body.title);
2913
+ if (!rec) return c.json({ error: 'not_found' }, 404);
2914
+ const slug = sharedSlugFor(c);
2915
+ if (slug && sessionRails.capable) {
2916
+ sessionRails.createSession(slug, railsId(slug, id), rec.title).catch(() => {});
2917
+ }
2918
+ auditAction(c, 'sessions.rename', `id=${rec.id}`);
2919
+ return c.json({ session: rec });
2920
+ });
2921
+
2922
+ app.delete('/api/sessions/:id', (c) => {
2923
+ const forbidden = require(c, 'chatWrite');
2924
+ if (forbidden) return forbidden;
2925
+ const id = c.req.param('id');
2926
+ if (id === MAIN_SESSION_ID) return c.json({ error: 'cannot_remove_main' }, 400);
2927
+ const ws = workspaceFor(c);
2928
+ // Stop a live turn for this session before dropping it (mirror the WS cancel).
2929
+ const live = currentTurns.get(turnKey(ws.id, threadForSession(id)));
2930
+ if (live?.session) {
2931
+ try { live.session.close(); } catch { /* best-effort */ }
2932
+ currentTurns.delete(turnKey(ws.id, threadForSession(id)));
2933
+ }
2934
+ const removed = sessionsFor(c).remove(id);
2935
+ auditAction(c, 'sessions.remove', `id=${id} removed=${removed}`);
2936
+ return c.json({ ok: true, removed });
2937
+ });
2938
+
2786
2939
  // The built site, served SAME-ORIGIN through this (already authed) server — no
2787
2940
  // squatted dev port, no mixed-content under the public proxy. The build dir
2788
2941
  // comes from preview.json (written by the agent's launch_preview / record_use).
@@ -0,0 +1,111 @@
1
+ // SessionRails — cross-host SHARED chat sessions on the rails.
2
+ //
3
+ // WHY: a session stored on one host is reachable by all of THAT account's devices
4
+ // (one host). For a SHARED workspace, members live on DIFFERENT hosts, so the
5
+ // session list + transcripts ride VW's rails (bmo-sync) — exactly like canvas
6
+ // state. This is a thin client over POST /api/workspace-sessions/{create,list,
7
+ // messages/push,messages/pull} (account-token self-authed + membership-gated; the
8
+ // caller is derived server-side, no IDOR). Local (unshared) workspaces never call
9
+ // this — they're per-host only (chat-sessions.mjs).
10
+ //
11
+ // The rails session id is namespaced "<slug>::<localId>" so it's globally unique
12
+ // and tied to the workspace; the Node caller (index.mjs) does that mapping.
13
+ //
14
+ // degrade-never-throw: every method fails soft so chat keeps working from the
15
+ // local store when the rails are down or the install has no account. Modeled on
16
+ // canvas-rails.mjs.
17
+
18
+ const DEFAULT_TIMEOUT_MS = 3000;
19
+
20
+ export class SessionRails {
21
+ constructor({
22
+ bmoSyncUrl,
23
+ accountToken,
24
+ timeoutMs = DEFAULT_TIMEOUT_MS,
25
+ fetchImpl = (...a) => globalThis.fetch(...a),
26
+ } = {}) {
27
+ this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
28
+ this.accountToken = accountToken || null;
29
+ this.timeoutMs = timeoutMs;
30
+ this.fetchImpl = fetchImpl;
31
+ this.capable = Boolean(this.accountToken) && Boolean(this.bmoSyncUrl);
32
+ }
33
+
34
+ async _post(path, body) {
35
+ if (!this.capable) return null;
36
+ const ctrl = new AbortController();
37
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
38
+ if (timer.unref) timer.unref();
39
+ try {
40
+ const r = await this.fetchImpl(`${this.bmoSyncUrl}${path}`, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify({ account_token: this.accountToken, ...body }),
44
+ signal: ctrl.signal,
45
+ });
46
+ if (!r || !r.ok) return null;
47
+ return await r.json().catch(() => null);
48
+ } catch {
49
+ return null;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ /** Create-or-touch a shared session. `id` must be the namespaced "<slug>::<localId>". */
56
+ async createSession(slug, id, title = null) {
57
+ const resp = await this._post('/api/workspace-sessions/create', { slug, id, title });
58
+ return Boolean(resp && resp.ok === true);
59
+ }
60
+
61
+ /**
62
+ * List a shared workspace's sessions.
63
+ * @returns {Promise<{ok:boolean, sessions:Array}>} each: {id,title,owner_account_id,owner_email,created_at,updated_at}
64
+ */
65
+ async listSessions(slug) {
66
+ const resp = await this._post('/api/workspace-sessions/list', { slug });
67
+ if (!resp || resp.ok !== true) return { ok: false, sessions: [] };
68
+ return { ok: true, sessions: Array.isArray(resp.sessions) ? resp.sessions : [] };
69
+ }
70
+
71
+ /**
72
+ * Push messages (append-with-content-upsert; idempotent per message id).
73
+ * @param messages Array<{id:string, json:string}> — json = the stringified message object.
74
+ */
75
+ async pushMessages(slug, sessionId, messages, title = null) {
76
+ const resp = await this._post('/api/workspace-sessions/messages/push', {
77
+ slug,
78
+ session_id: sessionId,
79
+ title,
80
+ messages,
81
+ });
82
+ return Boolean(resp && resp.ok === true);
83
+ }
84
+
85
+ /**
86
+ * Pull a session's messages in stable order.
87
+ * @returns {Promise<{ok:boolean, messages:Array<{seq:number,json:string}>, maxSeq:number}>}
88
+ */
89
+ async pullMessages(slug, sessionId, since = 0) {
90
+ const resp = await this._post('/api/workspace-sessions/messages/pull', {
91
+ slug,
92
+ session_id: sessionId,
93
+ since,
94
+ });
95
+ if (!resp || resp.ok !== true) return { ok: false, messages: [], maxSeq: since };
96
+ return {
97
+ ok: true,
98
+ messages: Array.isArray(resp.messages) ? resp.messages : [],
99
+ maxSeq: Number(resp.max_seq) || since,
100
+ };
101
+ }
102
+ }
103
+
104
+ /** Build the rails client from server config + account (or inert when not logged in). */
105
+ export function createSessionRails(config, account, fetchImpl) {
106
+ return new SessionRails({
107
+ bmoSyncUrl: config?.bmoSyncServerUrl,
108
+ accountToken: account?.accountToken,
109
+ fetchImpl,
110
+ });
111
+ }