@saadnvd1/agent-os 0.3.779 → 0.3.781

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.
Files changed (42) hide show
  1. package/app/api/live-shares/[id]/route.ts +30 -4
  2. package/app/api/live-shares/route.test.ts +37 -2
  3. package/app/api/live-shares/route.ts +8 -2
  4. package/components/Chat/Approval.tsx +13 -2
  5. package/components/Chat/EventRow.tsx +5 -0
  6. package/components/Chat/Queue.tsx +27 -15
  7. package/components/Live/DriveBanner.tsx +42 -0
  8. package/components/Live/InviteDialog.tsx +73 -2
  9. package/components/Live/LiveChip.tsx +68 -1
  10. package/components/Live/LivePanel.tsx +26 -1
  11. package/data/live/queries.ts +6 -1
  12. package/lib/chat/driver.ts +4 -0
  13. package/lib/chat/drivers/claude-approvals.ts +93 -25
  14. package/lib/chat/drivers/claude.ts +2 -1
  15. package/lib/chat/events.ts +21 -1
  16. package/lib/chat/queued.ts +57 -10
  17. package/lib/chat/registry.ts +2 -0
  18. package/lib/chat/runner.ts +65 -2
  19. package/lib/chat/socket.ts +12 -9
  20. package/lib/chat/worker/gate-testing.ts +131 -0
  21. package/lib/chat/worker/guest-gate.ts +511 -0
  22. package/lib/chat/worker/host.ts +50 -6
  23. package/lib/chat/worker/main.ts +16 -2
  24. package/lib/chat/worker/protocol.ts +11 -0
  25. package/lib/connect/control-api.ts +30 -1
  26. package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
  27. package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
  28. package/lib/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
  29. package/lib/live/clean.ts +6 -0
  30. package/lib/live/code-view.ts +56 -0
  31. package/lib/live/code.ts +25 -0
  32. package/lib/live/drive.ts +398 -0
  33. package/lib/live/frames.ts +9 -2
  34. package/lib/live/inbox.ts +50 -3
  35. package/lib/live/levels.ts +55 -10
  36. package/lib/live/runtime.ts +22 -0
  37. package/lib/live/service.ts +151 -16
  38. package/lib/live/store.ts +56 -3
  39. package/lib/live/testing.ts +281 -0
  40. package/lib/live/types.ts +37 -1
  41. package/lib/share/connect.ts +20 -0
  42. package/package.json +1 -1
package/lib/live/inbox.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * What Connect sends down. Only the types below are accepted, and only for a
3
3
  * share this machine minted and still holds. Everything else is dropped and
4
- * counted: nothing downward is executed, interpreted or sent on to an agent.
4
+ * counted: nothing downward is executed or interpreted, and only a Drive
5
+ * prompt from the guest this machine gave the wheel reaches an agent
6
+ * (./drive decides). No type answers an approval, sets the level or moves
7
+ * the wheel: those come only from the host's own API.
5
8
  */
6
9
 
7
10
  import { z } from "zod";
@@ -14,8 +17,10 @@ import {
14
17
  logLive,
15
18
  markLiveEnded,
16
19
  openSuggestions,
20
+ setLevel,
17
21
  } from "./store";
18
22
  import { MAX_LOBBY, MAX_PENDING_CARDS, live, type Runtime } from "./runtime";
23
+ import { acceptPrompt, setWheel, stopPrompt } from "./drive";
19
24
 
20
25
  export const DOWNWARD = [
21
26
  "roster",
@@ -28,6 +33,9 @@ export const DOWNWARD = [
28
33
  "invalid",
29
34
  "admit_failed",
30
35
  "access_failed",
36
+ "drive_prompt",
37
+ "drive_stop",
38
+ "code_locked",
31
39
  ] as const;
32
40
 
33
41
  const id = z.string().min(1).max(64);
@@ -46,6 +54,9 @@ const SCHEMAS = {
46
54
  invalid: z.object({}),
47
55
  admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
48
56
  access_failed: z.object({ reason: z.string().max(200).optional() }),
57
+ drive_prompt: z.object({ id, guest, text: z.string().max(16000) }),
58
+ drive_stop: z.object({ id, guest }),
59
+ code_locked: z.object({ until: z.string().max(40) }),
49
60
  } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
50
61
 
51
62
  export interface InboxEffects {
@@ -139,6 +150,7 @@ export function handleFrame(
139
150
  const g = (parsed.data as z.infer<typeof SCHEMAS.guest_left>).guest;
140
151
  const known = rt.guests.get(g.id);
141
152
  if (!known) return true;
153
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "left", fx);
142
154
  if (known.status === "pending") rt.guests.delete(g.id);
143
155
  else known.online = false;
144
156
  return true;
@@ -201,9 +213,44 @@ export function handleFrame(
201
213
  rt.notice = "Your plan's guest limit is reached.";
202
214
  return true;
203
215
  }
204
- case "access_failed":
205
- rt.notice = "Connect refused that access level.";
216
+ case "access_failed": {
217
+ const why = (parsed.data as z.infer<typeof SCHEMAS.access_failed>).reason;
218
+ rt.notice = why
219
+ ? why.replace(/\s+/g, " ").trim().slice(0, 160)
220
+ : "Connect refused that access level.";
221
+ // Connect didn't take it: this machine goes back to the safer side.
222
+ if (rt.drive.wheel) setWheel(rt, null, "taken_back", fx);
223
+ if (rt.share.level === "drive") {
224
+ rt.share = { ...rt.share, level: "suggest" };
225
+ setLevel(shareId, "suggest");
226
+ fx.send(shareId, "access", { level: "suggest" });
227
+ }
228
+ return true;
229
+ }
230
+ case "code_locked": {
231
+ const until = Date.parse(
232
+ (parsed.data as z.infer<typeof SCHEMAS.code_locked>).until
233
+ );
234
+ rt.codeLockedUntil = Number.isFinite(until) ? until : null;
206
235
  return true;
236
+ }
237
+ case "drive_prompt":
238
+ return acceptPrompt(
239
+ rt,
240
+ parsed.data as z.infer<typeof SCHEMAS.drive_prompt>,
241
+ fx,
242
+ now
243
+ )
244
+ ? true
245
+ : (drop("duplicate drive prompt"), false);
246
+ case "drive_stop":
247
+ return stopPrompt(
248
+ rt,
249
+ parsed.data as z.infer<typeof SCHEMAS.drive_stop>,
250
+ fx
251
+ )
252
+ ? true
253
+ : (drop("stop from a guest not driving"), false);
207
254
  case "invalid":
208
255
  rt.drops++;
209
256
  return true;
@@ -1,29 +1,74 @@
1
+ import type { Session } from "../db";
1
2
  import {
2
3
  LEVELS,
4
+ type DriverState,
3
5
  type GuestAction,
4
6
  type LevelOption,
5
7
  type LiveLevel,
8
+ type LiveView,
6
9
  } from "./types";
10
+ import { driveRefusal, driverState } from "./drive";
7
11
  import type { Guest, Runtime } from "./runtime";
8
12
 
9
13
  const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
10
14
 
11
15
  /** What the invite dialog and the Live popover offer, with why one is off. */
12
- export function levelOptions(): LevelOption[] {
16
+ export function levelOptions(session: Session | null): LevelOption[] {
17
+ const why = session ? driveRefusal(session) : "Task sessions only";
13
18
  return ORDER.map((value) => ({
14
19
  value,
15
20
  ...LEVELS[value],
16
- ...(value === "drive"
17
- ? { enabled: false, reason: "Coming soon" }
21
+ ...(value === "drive" && why
22
+ ? { enabled: false, reason: why }
18
23
  : { enabled: true }),
19
24
  }));
20
25
  }
21
26
 
22
- export function guestActions(_rt: Runtime, g: Guest): GuestAction[] {
23
- return g.status === "pending"
24
- ? [
25
- { kind: "admit", label: "Admit" },
26
- { kind: "deny", label: "Deny" },
27
- ]
28
- : [{ kind: "kick", label: "Kick" }];
27
+ export function guestActions(
28
+ rt: Runtime,
29
+ g: Guest,
30
+ canDrive: boolean
31
+ ): GuestAction[] {
32
+ const drive = rt.share.level === "drive" && canDrive;
33
+ if (g.status === "pending")
34
+ return drive && !rt.drive.wheel
35
+ ? [
36
+ { kind: "admit", label: "Let in" },
37
+ { kind: "admit_drive", label: "Let in to drive" },
38
+ { kind: "deny", label: "Deny" },
39
+ ]
40
+ : [
41
+ { kind: "admit", label: drive ? "Let in" : "Admit" },
42
+ { kind: "deny", label: "Deny" },
43
+ ];
44
+ const kick: GuestAction = { kind: "kick", label: "Kick" };
45
+ if (rt.share.level !== "drive") return [kick];
46
+ if (rt.drive.wheel?.guestId === g.id)
47
+ return [{ kind: "take_back", label: "Take back" }, kick];
48
+ return drive
49
+ ? [{ kind: "give_wheel", label: "Give the wheel" }, kick]
50
+ : [kick];
51
+ }
52
+
53
+ const DRIVING: Record<DriverState, string> = {
54
+ idle: "is driving",
55
+ queued: "is driving · prompt queued",
56
+ working: "is driving · working",
57
+ waiting_on_host: "is driving · waiting on you",
58
+ };
59
+
60
+ /** The guest holding the wheel and the banner the host sees above the composer. */
61
+ export function driveView(rt: Runtime): Pick<LiveView, "driver" | "banner"> {
62
+ const w = rt.drive.wheel;
63
+ const g = w && rt.guests.get(w.guestId);
64
+ if (rt.share.level !== "drive" || !w || !g)
65
+ return { driver: null, banner: null };
66
+ const state = driverState(rt);
67
+ return {
68
+ driver: { guestId: g.id, name: g.name, state },
69
+ banner: {
70
+ text: `${g.name} ${DRIVING[state]}`,
71
+ action: { kind: "take_back", label: "Take back" },
72
+ },
73
+ };
29
74
  }
@@ -15,6 +15,25 @@ export interface Guest {
15
15
  at: number;
16
16
  }
17
17
 
18
+ /** A Drive prompt this machine took and hasn't seen finish. */
19
+ export interface DrivePrompt {
20
+ id: string;
21
+ guestId: string;
22
+ // sending: on its way to the worker; queued: behind a turn; running.
23
+ status: "sending" | "queued" | "running";
24
+ // Why its turn was stopped, when it was (the guest's Stop, a cap).
25
+ stopWhy?: string;
26
+ }
27
+
28
+ export interface DriveState {
29
+ wheel: { guestId: string; wheel: string } | null;
30
+ prompts: DrivePrompt[];
31
+ // When prompts were taken, for the rate caps.
32
+ accepted: number[];
33
+ // Bumped whenever a Drive ends, so a send still on its way sends nothing.
34
+ epoch: number;
35
+ }
36
+
18
37
  /** What one live share holds in memory; everything durable is in the log. */
19
38
  export interface Runtime {
20
39
  share: LiveShareRow;
@@ -29,7 +48,10 @@ export interface Runtime {
29
48
  flushTimer: ReturnType<typeof setTimeout> | null;
30
49
  slowUntil: number;
31
50
  notice: string | null;
51
+ /** Connect locked the lobby until this time (ms) after wrong access codes. */
52
+ codeLockedUntil: number | null;
32
53
  drops: number;
54
+ drive: DriveState;
33
55
  }
34
56
 
35
57
  export interface LiveGlobals {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Live shares from the host's side: mint, run, decide on guests, end.
3
3
  * Nothing a guest sends reaches an agent except through sendSuggestion,
4
- * which the host taps.
4
+ * which the host taps, and a Drive prompt from the guest the host gave the
5
+ * wheel (./drive), whose turn the worker's guest gate holds to the host's taps.
5
6
  */
6
7
 
7
8
  import { loadConnect } from "../connect/config";
@@ -9,7 +10,12 @@ import { addTap, getSession, registry } from "../chat/registry";
9
10
  import { sendChatConfirmed } from "../chat/runner";
10
11
  import { shareContext } from "../share/context";
11
12
  import { redactForShare } from "../share/redact";
12
- import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
13
+ import {
14
+ ShareError,
15
+ createLiveRemote,
16
+ revokeRemote,
17
+ setAccessCodeRemote,
18
+ } from "../share/connect";
13
19
  import type { LiveExpiry } from "../connect/control-api";
14
20
  import {
15
21
  LEVELS,
@@ -17,10 +23,21 @@ import {
17
23
  type LiveLevel,
18
24
  type LiveView,
19
25
  } from "./types";
20
- import { guestActions, levelOptions } from "./levels";
26
+ import { driveView, guestActions, levelOptions } from "./levels";
27
+ import { codeView, inviteCode } from "./code-view";
28
+ import { codeRefusal, generateCode, normalizeCode } from "./code";
29
+ import {
30
+ driveRefusal,
31
+ newDrive,
32
+ onDriveMessage,
33
+ releaseDrive,
34
+ setWheel,
35
+ type DriveFx,
36
+ } from "./drive";
37
+ import type { Session } from "../db";
21
38
  import { listItems } from "../chat/store";
22
39
  import { visible } from "../share/snapshot";
23
- import { cleanBody } from "./clean";
40
+ import { cleanBody, noMentions } from "./clean";
24
41
  import { itemMapper } from "./frames";
25
42
  import { handleFrame } from "./inbox";
26
43
  import { onMessage, seed, flush } from "./publisher";
@@ -35,6 +52,7 @@ import {
35
52
  logLive,
36
53
  markLiveEnded,
37
54
  openSuggestions,
55
+ setAccessCode,
38
56
  setLevel,
39
57
  type LiveShareRow,
40
58
  } from "./store";
@@ -63,6 +81,18 @@ export function makeSocket(factory?: WsFactory): LiveSocket {
63
81
 
64
82
  const socket = (): LiveSocket => (live.socket ??= makeSocket());
65
83
 
84
+ const fx: DriveFx = {
85
+ send: (id, action, data) => live.socket?.send(id, action, data),
86
+ };
87
+
88
+ const sessionOrNull = (id: string): Session | null => {
89
+ try {
90
+ return getSession(id);
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+
66
96
  function stateOfSession(sessionId: string) {
67
97
  const l = registry.live.get(sessionId);
68
98
  const s = l?.state;
@@ -86,7 +116,9 @@ function attach(row: LiveShareRow): Runtime {
86
116
  flushTimer: null,
87
117
  slowUntil: 0,
88
118
  notice: null,
119
+ codeLockedUntil: null,
89
120
  drops: 0,
121
+ drive: newDrive(row.id),
90
122
  };
91
123
  live.runtimes.set(row.id, rt);
92
124
  try {
@@ -99,6 +131,7 @@ function attach(row: LiveShareRow): Runtime {
99
131
  /** Stops publishing a share and drops its connection; the row stays as history. */
100
132
  function stop(shareId: string, markEnded = false): void {
101
133
  const rt = live.runtimes.get(shareId);
134
+ if (rt) releaseDrive(rt, "ended", fx);
102
135
  if (markEnded) markLiveEnded(shareId);
103
136
  if (rt?.flushTimer) clearTimeout(rt.flushTimer);
104
137
  live.runtimes.delete(shareId);
@@ -110,6 +143,7 @@ export function startLive(): void {
110
143
  if (live.started) return;
111
144
  live.started = true;
112
145
  addTap(onMessage);
146
+ addTap((sessionId, m) => onDriveMessage(sessionId, m, fx));
113
147
  for (const row of activeLiveShares()) attach(row);
114
148
  setInterval(sweep, SWEEP_MS).unref();
115
149
  }
@@ -137,19 +171,45 @@ export async function createLive(
137
171
  sessionId: string,
138
172
  expiresIn: LiveExpiry,
139
173
  level: LiveLevel = "suggest",
140
- history = true
174
+ history = true,
175
+ codeOpt?: { custom?: string } | null
141
176
  ): Promise<LiveView> {
142
177
  startLive();
143
178
  const session = getSession(sessionId);
179
+ if (level === "drive" && driveRefusal(session))
180
+ throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
144
181
  if (activeLiveShareFor(sessionId))
145
182
  throw new ShareError("This session already has a live link.", 409);
183
+ let accessCode: string | null = null;
184
+ if (codeOpt) {
185
+ if (codeOpt.custom) {
186
+ const why = codeRefusal(codeOpt.custom);
187
+ if (why) throw new ShareError(why, 400);
188
+ }
189
+ accessCode = codeOpt.custom
190
+ ? normalizeCode(codeOpt.custom)
191
+ : generateCode();
192
+ }
146
193
  const ctx = shareContext();
147
194
  const title =
148
195
  redactForShare(session.name, ctx)
149
196
  .text.replace(/\s+/g, " ")
150
197
  .trim()
151
198
  .slice(0, 120) || "Chat";
152
- const made = await createLiveRemote({ title, expiresIn, access: level });
199
+ const made = await createLiveRemote({
200
+ title,
201
+ expiresIn,
202
+ access: level,
203
+ ...(accessCode ? { accessCode } : {}),
204
+ });
205
+ // An old Connect ignores the code and would let anyone reach the lobby.
206
+ if (accessCode && !made.access_code) {
207
+ await revokeRemote(made.id).catch(() => {});
208
+ throw new ShareError(
209
+ "Connect doesn't support access codes yet. Try again without one.",
210
+ 502
211
+ );
212
+ }
153
213
  const row: LiveShareRow = {
154
214
  id: made.id,
155
215
  session_id: sessionId,
@@ -162,6 +222,7 @@ export async function createLive(
162
222
  ended_at: null,
163
223
  level,
164
224
  history: history ? 1 : 0,
225
+ access_code: accessCode,
165
226
  };
166
227
  insertLiveShare(row);
167
228
  attach(row);
@@ -171,16 +232,70 @@ export async function createLive(
171
232
  export function changeLevel(id: string, level: LiveLevel): void {
172
233
  const rt = runtimeFor(id);
173
234
  if (!(level in LEVELS)) throw new ShareError("Unknown level.", 400);
174
- if (!levelOptions().find((o) => o.value === level)?.enabled)
175
- throw new ShareError(`${LEVELS[level].label} isn't available.`, 400);
235
+ const option = levelOptions(sessionOrNull(rt.share.session_id)).find(
236
+ (o) => o.value === level
237
+ );
238
+ if (!option?.enabled)
239
+ throw new ShareError(
240
+ `${LEVELS[level].label} isn't available${option?.reason ? `: ${option.reason}` : ""}.`,
241
+ 400
242
+ );
176
243
  if (rt.share.level === level) return;
177
- // Synchronous and first: from here the machine treats the share as `level`.
244
+ // Synchronous and first: from here the machine treats the share as
245
+ // `level`, so a racing Drive prompt is refused. Then what Drive left goes.
178
246
  rt.share = { ...rt.share, level };
179
247
  setLevel(id, level);
248
+ if (level !== "drive") setWheel(rt, null, "downgraded", fx);
180
249
  rt.notice = null;
181
250
  live.socket?.send(id, "access", { level });
182
251
  }
183
252
 
253
+ /**
254
+ * Turns the access code on (generate, or the host's own), changes it, or turns
255
+ * it off (null). Connect first: if it doesn't confirm, nothing changes here.
256
+ * Guests already in or waiting keep their seat; only new joins need the code.
257
+ */
258
+ export async function changeCode(
259
+ id: string,
260
+ next: { generate: true } | { code: string } | null
261
+ ): Promise<void> {
262
+ const rt = runtimeFor(id);
263
+ let code: string | null = null;
264
+ if (next && "code" in next) {
265
+ const why = codeRefusal(next.code);
266
+ if (why) throw new ShareError(why, 400);
267
+ code = normalizeCode(next.code);
268
+ } else if (next) code = generateCode();
269
+ await setAccessCodeRemote(id, code);
270
+ const now = live.runtimes.get(id);
271
+ if (!now || now.share.ended_at) return;
272
+ now.share = { ...now.share, access_code: code };
273
+ setAccessCode(id, code);
274
+ now.codeLockedUntil = null;
275
+ }
276
+
277
+ /** Gives the wheel to an admitted guest of a Drive share, or takes it back (null). */
278
+ export function giveWheel(id: string, guestId: string | null): void {
279
+ const rt = runtimeFor(id);
280
+ if (rt.share.level !== "drive")
281
+ throw new ShareError("Drive is off for this share.", 409);
282
+ if (guestId) {
283
+ const g = rt.guests.get(guestId);
284
+ if (g?.status !== "admitted" || !admittedGuestIds(id).has(guestId))
285
+ throw new ShareError("That guest isn't in the session.", 404);
286
+ const s = sessionOrNull(rt.share.session_id);
287
+ const why = s ? driveRefusal(s) : "This session is gone";
288
+ if (why) throw new ShareError(`Drive: ${why}.`, 409);
289
+ }
290
+ if ((rt.drive.wheel?.guestId ?? null) === guestId) return;
291
+ setWheel(rt, guestId, "taken_back", fx);
292
+ rt.notice = null;
293
+ live.socket?.send(id, "access", {
294
+ level: "drive",
295
+ driver_guest_id: guestId,
296
+ });
297
+ }
298
+
184
299
  /** What the invite dialog offers for a session. */
185
300
  export function inviteFor(sessionId: string): LiveInvite {
186
301
  const count = Math.min(
@@ -190,18 +305,20 @@ export function inviteFor(sessionId: string): LiveInvite {
190
305
  RING_ITEMS
191
306
  );
192
307
  return {
193
- levels: levelOptions(),
308
+ levels: levelOptions(sessionOrNull(sessionId)),
194
309
  history: {
195
310
  label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
196
311
  default: true,
197
312
  count,
198
313
  },
314
+ code: inviteCode(),
199
315
  };
200
316
  }
201
317
 
202
318
  /** Ends access: Connect disconnects every guest, then this machine lets go. */
203
319
  export async function endLive(id: string): Promise<void> {
204
- runtimeFor(id);
320
+ // Before anything awaits: no Drive prompt is taken in from here.
321
+ releaseDrive(runtimeFor(id), "ended", fx);
205
322
  await revokeRemote(id);
206
323
  markLiveEnded(id);
207
324
  stop(id);
@@ -225,14 +342,23 @@ function logGuest(rt: Runtime, kind: "admit" | "deny" | "kick", g: Guest) {
225
342
  });
226
343
  }
227
344
 
228
- export function admit(id: string, guestId: string): void {
345
+ export function admit(
346
+ id: string,
347
+ guestId: string,
348
+ opts: { drive?: boolean } = {}
349
+ ): void {
229
350
  const rt = runtimeFor(id);
230
351
  const g = guestOf(rt, guestId);
231
352
  if (g.status !== "pending") throw new ShareError("Already let in.", 409);
353
+ if (opts.drive && rt.share.level !== "drive")
354
+ throw new ShareError("Drive is off for this share.", 409);
355
+ if (opts.drive && rt.drive.wheel)
356
+ throw new ShareError("Someone else is driving.", 409);
232
357
  logGuest(rt, "admit", g);
233
358
  g.status = "admitted";
234
359
  rt.notice = null;
235
360
  live.socket?.send(id, "admit", { guest_id: g.id });
361
+ if (opts.drive) giveWheel(id, g.id);
236
362
  }
237
363
 
238
364
  export function deny(id: string, guestId: string): void {
@@ -247,6 +373,7 @@ export function deny(id: string, guestId: string): void {
247
373
  export function kick(id: string, guestId: string): void {
248
374
  const rt = runtimeFor(id);
249
375
  const g = guestOf(rt, guestId);
376
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "kicked", fx);
250
377
  logGuest(rt, "kick", g);
251
378
  rt.guests.delete(g.id);
252
379
  live.socket?.send(id, "kick", { guest_id: g.id });
@@ -266,8 +393,10 @@ export async function sendSuggestion(
266
393
  const rt = runtimeFor(id);
267
394
  const card = openSuggestions(id).find((s) => s.id === suggestionId);
268
395
  if (!card) throw new ShareError("That suggestion is gone.", 404);
269
- const text = cleanBody(edited ?? card.text);
270
- if (!text) throw new ShareError("The message is empty.", 400);
396
+ const body = cleanBody(edited ?? card.text);
397
+ if (!body) throw new ShareError("The message is empty.", 400);
398
+ // It started as a guest's text, edited or not.
399
+ const text = noMentions(body);
271
400
  const key = `${id}:${suggestionId}`;
272
401
  if (sending.has(key)) throw new ShareError("Already sending.", 409);
273
402
  sending.add(key);
@@ -275,6 +404,7 @@ export async function sendSuggestion(
275
404
  await sendChatConfirmed(rt.share.session_id, {
276
405
  text,
277
406
  origin: { kind: "guest", label: card.guestName },
407
+ host: true,
278
408
  });
279
409
  logLive({
280
410
  share_id: id,
@@ -327,6 +457,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
327
457
  names.set(k, (names.get(k) ?? 0) + 1);
328
458
  }
329
459
  const admitted = admittedGuestIds(id);
460
+ const session = sessionOrNull(rt.share.session_id);
461
+ const levels = levelOptions(session);
462
+ const canDrive = !!levels.find((l) => l.value === "drive")?.enabled;
330
463
  return {
331
464
  id,
332
465
  sessionId: rt.share.session_id,
@@ -338,8 +471,10 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
338
471
  connected: live.socket?.connected ?? false,
339
472
  notice: rt.notice,
340
473
  level: rt.share.level,
341
- levels: levelOptions(),
474
+ levels,
475
+ code: codeView(rt, now),
342
476
  history: rt.share.history === 1,
477
+ ...driveView(rt),
343
478
  guests: shown
344
479
  .filter((g) => g.status === "pending" || admitted.has(g.id))
345
480
  .map((g) => ({
@@ -349,7 +484,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
349
484
  online: g.online,
350
485
  duplicateName:
351
486
  (names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
352
- actions: guestActions(rt, g),
487
+ actions: guestActions(rt, g, canDrive),
353
488
  })),
354
489
  suggestions: openSuggestions(id),
355
490
  };
package/lib/live/store.ts CHANGED
@@ -14,6 +14,8 @@ export interface LiveShareRow {
14
14
  level: LiveLevel;
15
15
  /** 1: guests get the chat from before the link was made. */
16
16
  history: number;
17
+ /** The access code in plain text, or null when off. Never logged. */
18
+ access_code: string | null;
17
19
  }
18
20
 
19
21
  export type LogKind =
@@ -24,7 +26,15 @@ export type LogKind =
24
26
  | "suggestion"
25
27
  | "sent"
26
28
  | "dismissed"
27
- | "level";
29
+ | "level"
30
+ // The code was turned on, off or changed; the log never holds the code.
31
+ | "code_changed"
32
+ // Drive: who holds the wheel (guest_id, ref = the wheel's own id; both
33
+ // null when taken back), a prompt they sent (ref = its id), and why a
34
+ // Drive's turn or queue was stopped (ref = the reason).
35
+ | "wheel"
36
+ | "drive_prompt"
37
+ | "drive_stopped";
28
38
 
29
39
  export interface LogRow {
30
40
  id: number;
@@ -41,8 +51,8 @@ export interface LogRow {
41
51
  export function insertLiveShare(row: LiveShareRow): void {
42
52
  db.prepare(
43
53
  `INSERT INTO live_shares
44
- (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
45
- VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
54
+ (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history, access_code)
55
+ VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history, @access_code)`
46
56
  ).run(row);
47
57
  }
48
58
 
@@ -88,6 +98,22 @@ export function setLevel(id: string, level: LiveLevel): void {
88
98
  });
89
99
  }
90
100
 
101
+ export function setAccessCode(id: string, code: string | null): void {
102
+ db.prepare(`UPDATE live_shares SET access_code = ? WHERE id = ?`).run(
103
+ code,
104
+ id
105
+ );
106
+ logLive({
107
+ share_id: id,
108
+ kind: "code_changed",
109
+ guest_id: null,
110
+ guest_name: null,
111
+ ref: code ? "on" : "off",
112
+ text: null,
113
+ sent_text: null,
114
+ });
115
+ }
116
+
91
117
  export function logLive(
92
118
  row: Omit<LogRow, "id" | "at"> & { at?: number }
93
119
  ): void {
@@ -148,6 +174,33 @@ export function admittedGuestIds(shareId: string): Set<string> {
148
174
  return out;
149
175
  }
150
176
 
177
+ /** Who holds the wheel, from the log: a kick or a level other than Drive drops it. */
178
+ export function wheelOf(
179
+ shareId: string
180
+ ): { guestId: string; wheel: string } | null {
181
+ let w: { guestId: string; wheel: string } | null = null;
182
+ for (const l of listLog(shareId)) {
183
+ if (l.kind === "wheel")
184
+ w = l.guest_id && l.ref ? { guestId: l.guest_id, wheel: l.ref } : null;
185
+ else if (l.kind === "level" && l.ref !== "drive") w = null;
186
+ else if (
187
+ (l.kind === "kick" || l.kind === "deny") &&
188
+ l.guest_id === w?.guestId
189
+ )
190
+ w = null;
191
+ }
192
+ return w;
193
+ }
194
+
195
+ /** Whether a Drive prompt with this id was ever logged for the share. */
196
+ export function hasDrivePrompt(shareId: string, ref: string): boolean {
197
+ return !!db
198
+ .prepare(
199
+ `SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'drive_prompt' AND ref = ? LIMIT 1`
200
+ )
201
+ .get(shareId, ref);
202
+ }
203
+
151
204
  /** Whether a suggestion with this id was ever logged for the share. */
152
205
  export function hasSuggestion(shareId: string, ref: string): boolean {
153
206
  return !!db