@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.
- package/app/api/live-shares/[id]/route.ts +30 -4
- package/app/api/live-shares/route.test.ts +37 -2
- package/app/api/live-shares/route.ts +8 -2
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/InviteDialog.tsx +73 -2
- package/components/Live/LiveChip.tsx +68 -1
- package/components/Live/LivePanel.tsx +26 -1
- package/data/live/queries.ts +6 -1
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/connect/control-api.ts +30 -1
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/code-view.ts +56 -0
- package/lib/live/code.ts +25 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +50 -3
- package/lib/live/levels.ts +55 -10
- package/lib/live/runtime.ts +22 -0
- package/lib/live/service.ts +151 -16
- package/lib/live/store.ts +56 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +37 -1
- package/lib/share/connect.ts +20 -0
- 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
|
|
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
|
-
|
|
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;
|
package/lib/live/levels.ts
CHANGED
|
@@ -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:
|
|
21
|
+
...(value === "drive" && why
|
|
22
|
+
? { enabled: false, reason: why }
|
|
18
23
|
: { enabled: true }),
|
|
19
24
|
}));
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
export function guestActions(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|
package/lib/live/runtime.ts
CHANGED
|
@@ -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 {
|
package/lib/live/service.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
270
|
-
if (!
|
|
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
|
|
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
|