@saadnvd1/agent-os 0.3.780 → 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.
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
4
4
  import {
5
5
  admit,
6
+ changeCode,
6
7
  changeLevel,
7
8
  deny,
8
9
  dismissSuggestion,
@@ -30,6 +31,13 @@ const Action = z.discriminatedUnion("action", [
30
31
  action: z.literal("wheel"),
31
32
  guestId: z.string().min(1).max(64).nullable(),
32
33
  }),
34
+ // Sets the access code to the host's own, or turns it off (null).
35
+ z.object({
36
+ action: z.literal("code"),
37
+ code: z.string().max(40).nullable(),
38
+ }),
39
+ // Turns the access code on, or changes it, to a generated one.
40
+ z.object({ action: z.literal("new_code") }),
33
41
  z.object({
34
42
  action: z.literal("send"),
35
43
  suggestionId: z.string().min(1).max(64),
@@ -63,6 +71,9 @@ export async function POST(
63
71
  else if (a.action === "kick") kick(id, a.guestId);
64
72
  else if (a.action === "level") changeLevel(id, a.level);
65
73
  else if (a.action === "wheel") giveWheel(id, a.guestId);
74
+ else if (a.action === "code")
75
+ await changeCode(id, a.code === null ? null : { code: a.code });
76
+ else if (a.action === "new_code") await changeCode(id, { generate: true });
66
77
  else if (a.action === "send")
67
78
  await sendSuggestion(id, a.suggestionId, a.text);
68
79
  else dismissSuggestion(id, a.suggestionId);
@@ -43,6 +43,20 @@ describe("live-shares routes", () => {
43
43
  );
44
44
  });
45
45
 
46
+ it("validate the access code shape (400) and accept the code actions", async () => {
47
+ expect(
48
+ (await POST(req({ sessionId: "s", code: { custom: 5 } }))).status
49
+ ).toBe(400);
50
+ expect((await POST(req({ sessionId: "s", code: {} }))).status).toBe(409);
51
+ expect((await POST(req({ sessionId: "s", code: null }))).status).toBe(409);
52
+ // A bad own code on a share this machine holds nothing of: 404, so it validated.
53
+ expect((await act(req({ action: "code", code: "ab" }), ctx)).status).toBe(
54
+ 404
55
+ );
56
+ expect((await act(req({ action: "code", code: 5 }), ctx)).status).toBe(400);
57
+ expect((await act(req({ action: "new_code" }), ctx)).status).toBe(404);
58
+ });
59
+
46
60
  it("accept watch, suggest and drive, and history as a boolean", async () => {
47
61
  // Past validation: not linked to Connect here, so 409, never 400.
48
62
  expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
@@ -9,6 +9,11 @@ const Create = z.object({
9
9
  expiresIn: z.enum(["1h", "session"]).default("1h"),
10
10
  level: z.enum(["watch", "suggest", "drive"]).default("suggest"),
11
11
  history: z.boolean().default(true),
12
+ // Absent or null: no code. {} generates one; { custom } uses the host's.
13
+ code: z
14
+ .object({ custom: z.string().max(40).optional() })
15
+ .nullable()
16
+ .optional(),
12
17
  });
13
18
 
14
19
  // POST /api/live-shares - mint a live link for one chat and start publishing.
@@ -25,7 +30,8 @@ export async function POST(request: Request) {
25
30
  body.data.sessionId,
26
31
  body.data.expiresIn,
27
32
  body.data.level,
28
- body.data.history
33
+ body.data.history,
34
+ body.data.code
29
35
  );
30
36
  return NextResponse.json(view, { status: 201 });
31
37
  } catch (err) {
@@ -78,10 +78,13 @@ function Create({
78
78
  const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
79
79
  const [level, setLevel] = useState<LiveLevel>("suggest");
80
80
  const [history, setHistory] = useState<boolean | null>(null);
81
+ const [codeOn, setCodeOn] = useState<boolean | null>(null);
82
+ const [custom, setCustom] = useState("");
81
83
  const create = useCreateLive();
82
84
  const options = invite?.levels ?? [];
83
85
  const chosen = options.find((o) => o.value === level);
84
86
  const withHistory = history ?? invite?.history.default ?? true;
87
+ const withCode = codeOn ?? invite?.code.default ?? false;
85
88
  return (
86
89
  <>
87
90
  <div className="flex flex-col gap-1.5">
@@ -118,6 +121,36 @@ function Create({
118
121
  {invite.history.label}
119
122
  </label>
120
123
  )}
124
+ {invite && (
125
+ <div className="flex flex-col gap-1.5">
126
+ <label className="flex min-h-11 items-center gap-3 text-sm">
127
+ <input
128
+ type="checkbox"
129
+ className="size-5"
130
+ checked={withCode}
131
+ onChange={(e) => setCodeOn(e.target.checked)}
132
+ />
133
+ {invite.code.label}
134
+ </label>
135
+ {withCode && (
136
+ <>
137
+ <input
138
+ value={custom}
139
+ onChange={(e) => setCustom(e.target.value)}
140
+ maxLength={40}
141
+ autoComplete="off"
142
+ spellCheck={false}
143
+ placeholder={invite.code.customLabel}
144
+ aria-label={invite.code.customLabel}
145
+ className="bg-muted min-h-11 rounded-lg px-3 font-mono text-sm"
146
+ />
147
+ <p className="text-muted-foreground text-xs">
148
+ {invite.code.hint} {invite.code.rules}
149
+ </p>
150
+ </>
151
+ )}
152
+ </div>
153
+ )}
121
154
  <div className="flex flex-col gap-1.5">
122
155
  <span className="text-muted-foreground text-xs font-medium">
123
156
  The link works for
@@ -139,7 +172,17 @@ function Create({
139
172
  className="min-h-11"
140
173
  disabled={create.isPending}
141
174
  onClick={() =>
142
- create.mutate({ sessionId, expiresIn, level, history: withHistory })
175
+ create.mutate({
176
+ sessionId,
177
+ expiresIn,
178
+ level,
179
+ history: withHistory,
180
+ code: withCode
181
+ ? custom.trim()
182
+ ? { custom: custom.trim() }
183
+ : {}
184
+ : null,
185
+ })
143
186
  }
144
187
  >
145
188
  <Users className="h-4 w-4" />
@@ -153,6 +196,7 @@ function Link({ share }: { share: LiveView }) {
153
196
  const { copied, copy } = useCopyToClipboard();
154
197
  const end = useEndLive(share.id);
155
198
  const qr = useQr(share.url);
199
+ const code = share.code;
156
200
  const canShare = typeof navigator !== "undefined" && "share" in navigator;
157
201
  return (
158
202
  <>
@@ -175,6 +219,33 @@ function Link({ share }: { share: LiveView }) {
175
219
  className="bg-muted min-h-11 w-full rounded-lg px-3 font-mono text-xs"
176
220
  />
177
221
  </div>
222
+ {code.on && code.copyText && (
223
+ <div className="flex flex-col items-center gap-2">
224
+ <span className="text-muted-foreground text-xs font-medium">
225
+ {code.label}
226
+ </span>
227
+ <span
228
+ className="font-mono text-3xl font-semibold tracking-widest"
229
+ aria-label={code.label}
230
+ >
231
+ {code.display}
232
+ </span>
233
+ <Button
234
+ className="min-h-11 w-full"
235
+ onClick={() => copy(code.copyText!)}
236
+ >
237
+ {copied ? (
238
+ <Check className="h-4 w-4" />
239
+ ) : (
240
+ <Copy className="h-4 w-4" />
241
+ )}
242
+ Copy link and code
243
+ </Button>
244
+ <p className="text-muted-foreground text-center text-xs">
245
+ {code.hint}
246
+ </p>
247
+ </div>
248
+ )}
178
249
  <div className="flex flex-wrap gap-2">
179
250
  <Button
180
251
  variant="outline"
@@ -186,7 +257,7 @@ function Link({ share }: { share: LiveView }) {
186
257
  ) : (
187
258
  <Copy className="h-4 w-4" />
188
259
  )}
189
- Copy
260
+ Copy link
190
261
  </Button>
191
262
  {canShare && (
192
263
  <Button
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { ChevronDown, Radio, UserMinus } from "lucide-react";
4
+ import { Check, ChevronDown, Copy, Radio, UserMinus } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Segmented } from "@/components/ui/segmented";
7
7
  import { StatusPill } from "@/components/ui/status-pill";
8
8
  import type { LiveLevel, LiveView } from "@/data/live";
9
+ import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
9
10
  import { cn } from "@/lib/utils";
10
11
 
11
12
  export function presenceText(guests: LiveView["guests"]): string {
@@ -21,6 +22,7 @@ export function LiveChip({
21
22
  busy,
22
23
  onAction,
23
24
  onLevel,
25
+ onCode,
24
26
  onEnd,
25
27
  onInvite,
26
28
  }: {
@@ -28,6 +30,7 @@ export function LiveChip({
28
30
  busy: boolean;
29
31
  onAction: (guestId: string, kind: string) => void;
30
32
  onLevel: (level: LiveLevel) => void;
33
+ onCode: (kind: string, own?: string) => void;
31
34
  onEnd: () => void;
32
35
  onInvite: () => void;
33
36
  }) {
@@ -76,6 +79,7 @@ export function LiveChip({
76
79
  <p className="text-muted-foreground px-2 text-xs">
77
80
  {share.levels.find((o) => o.value === share.level)?.hint}
78
81
  </p>
82
+ <CodeRow share={share} busy={busy} onCode={onCode} />
79
83
  {admitted.length === 0 && (
80
84
  <p className="text-muted-foreground px-2 py-1 text-xs">
81
85
  No one has joined yet.
@@ -128,3 +132,66 @@ export function LiveChip({
128
132
  </div>
129
133
  );
130
134
  }
135
+
136
+ function CodeRow({
137
+ share,
138
+ busy,
139
+ onCode,
140
+ }: {
141
+ share: LiveView;
142
+ busy: boolean;
143
+ onCode: (kind: string, own?: string) => void;
144
+ }) {
145
+ const { copied, copy } = useCopyToClipboard();
146
+ const { code } = share;
147
+ const change = (kind: string) => {
148
+ if (kind === "copy") copy(code.copyText ?? share.url);
149
+ else if (kind === "copy_link") copy(share.url);
150
+ else if (kind === "set_code") {
151
+ const own = window.prompt(`${code.prompt.title}\n${code.prompt.message}`);
152
+ if (own?.trim()) onCode(kind, own.trim());
153
+ } else onCode(kind);
154
+ };
155
+ return (
156
+ <div className="flex flex-col gap-1 px-2 py-1">
157
+ <div className="flex min-h-11 items-center gap-2">
158
+ <span className="text-muted-foreground text-xs">{code.label}</span>
159
+ <span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold tracking-wider">
160
+ {code.on ? code.display : "Off"}
161
+ </span>
162
+ {code.on && code.copyText && (
163
+ <Button
164
+ variant="ghost"
165
+ className="min-h-11"
166
+ onClick={() => copy(code.copyText!)}
167
+ aria-label="Copy link and code"
168
+ >
169
+ {copied ? (
170
+ <Check className="h-4 w-4" />
171
+ ) : (
172
+ <Copy className="h-4 w-4" />
173
+ )}
174
+ </Button>
175
+ )}
176
+ </div>
177
+ {code.notice && (
178
+ <p className="text-muted-foreground text-xs">{code.notice}</p>
179
+ )}
180
+ <div className="flex flex-wrap gap-1">
181
+ {code.actions
182
+ .filter((a) => a.kind !== "copy" && a.kind !== "copy_link")
183
+ .map((a) => (
184
+ <Button
185
+ key={a.kind}
186
+ variant="ghost"
187
+ className="min-h-11"
188
+ disabled={busy}
189
+ onClick={() => change(a.kind)}
190
+ >
191
+ {a.label}
192
+ </Button>
193
+ ))}
194
+ </div>
195
+ </div>
196
+ );
197
+ }
@@ -47,6 +47,14 @@ export function LivePanel({
47
47
  onLevel={(level) =>
48
48
  act.mutate({ action: "level", level }, { onError: fail })
49
49
  }
50
+ onCode={(kind, own) => {
51
+ if (kind === "code_off")
52
+ act.mutate({ action: "code", code: null }, { onError: fail });
53
+ else if (kind === "set_code" && own)
54
+ act.mutate({ action: "code", code: own }, { onError: fail });
55
+ else if (kind === "new_code" || kind === "code_on")
56
+ act.mutate({ action: "new_code" }, { onError: fail });
57
+ }}
50
58
  onEnd={() => end.mutate(undefined, { onError: fail })}
51
59
  onInvite={() => liveUiActions.open(sessionId, title)}
52
60
  />
@@ -57,6 +57,7 @@ export function useCreateLive() {
57
57
  expiresIn: LiveExpiry;
58
58
  level: LiveLevel;
59
59
  history: boolean;
60
+ code?: { custom?: string } | null;
60
61
  }) => json<LiveView>(await send("/api/live-shares", "POST", req)),
61
62
  onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
62
63
  });
@@ -67,6 +68,8 @@ export type LiveAction =
67
68
  | { action: "deny" | "kick"; guestId: string }
68
69
  | { action: "wheel"; guestId: string | null }
69
70
  | { action: "level"; level: LiveLevel }
71
+ | { action: "code"; code: string | null }
72
+ | { action: "new_code" }
70
73
  | { action: "send"; suggestionId: string; text?: string }
71
74
  | { action: "dismiss"; suggestionId: string };
72
75
 
@@ -482,13 +482,20 @@ export interface LiveShare {
482
482
  expires_at: string | null;
483
483
  ends_with_session: boolean;
484
484
  max_guests: number;
485
+ /** Connect confirmed it holds an access code for the share. */
486
+ access_code: boolean;
485
487
  }
486
488
 
487
489
  /** Mints a live link for one session; the answer carries the link, once. */
488
490
  export async function createLiveShare(
489
491
  config: ConnectConfig,
490
492
  privateKey: string,
491
- share: { title: string; expiresIn: LiveExpiry; access?: string }
493
+ share: {
494
+ title: string;
495
+ expiresIn: LiveExpiry;
496
+ access?: string;
497
+ accessCode?: string;
498
+ }
492
499
  ): Promise<LiveShare> {
493
500
  const res = await signed(config, privateKey)(
494
501
  "POST",
@@ -497,6 +504,7 @@ export async function createLiveShare(
497
504
  title: share.title,
498
505
  expires_in: share.expiresIn,
499
506
  ...(share.access ? { access: share.access } : {}),
507
+ ...(share.accessCode ? { access_code: share.accessCode } : {}),
500
508
  }
501
509
  );
502
510
  const r = (await res.json().catch(() => null)) as Record<
@@ -517,5 +525,26 @@ export async function createLiveShare(
517
525
  expires_at: typeof r.expires_at === "string" ? r.expires_at : null,
518
526
  ends_with_session: r.ends_with_session === true,
519
527
  max_guests: typeof r.max_guests === "number" ? r.max_guests : 0,
528
+ access_code: r.access_code === true,
520
529
  };
521
530
  }
531
+
532
+ /** Sets (a code) or clears (null) a live share's access code; Connect must say it did. */
533
+ export async function setLiveAccessCode(
534
+ config: ConnectConfig,
535
+ privateKey: string,
536
+ id: string,
537
+ code: string | null
538
+ ): Promise<void> {
539
+ if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
540
+ const res = await signed(config, privateKey)(
541
+ "PUT",
542
+ `/api/v1/machines/${config.machineId}/live-shares/${id}/access-code`,
543
+ { access_code: code }
544
+ );
545
+ const r = (await res.json().catch(() => null)) as {
546
+ access_code?: unknown;
547
+ } | null;
548
+ if (r?.access_code !== (code !== null))
549
+ throw new Error("the Connect service did not confirm the access code");
550
+ }
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261031T1200_live_share_access_code",
5
+ up: (db) => {
6
+ const cols = (
7
+ db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
8
+ ).map((c) => c.name);
9
+ // Null is off. The code is kept in plain text so the host can see it again;
10
+ // Connect holds only a keyed hash of it.
11
+ if (!cols.includes("access_code"))
12
+ db.exec(`ALTER TABLE live_shares ADD COLUMN access_code TEXT`);
13
+ },
14
+ } satisfies Migration;
@@ -0,0 +1,56 @@
1
+ import type { LiveCodeView, LiveInvite } from "./types";
2
+ import { displayCode } from "./code";
3
+ import type { Runtime } from "./runtime";
4
+
5
+ const HINT =
6
+ "Guests type this before they reach your lobby. You still let each one in.";
7
+
8
+ const RULES = "6 to 32 letters or digits.";
9
+
10
+ /** The access code block of a share, with the actions it offers right now. */
11
+ export function codeView(rt: Runtime, now = Date.now()): LiveCodeView {
12
+ const code = rt.share.access_code;
13
+ const locked = rt.codeLockedUntil && rt.codeLockedUntil > now;
14
+ return {
15
+ on: !!code,
16
+ display: code ? displayCode(code) : null,
17
+ copyText: code
18
+ ? `${rt.share.url}\nAccess code: ${displayCode(code)}`
19
+ : null,
20
+ label: "Access code",
21
+ hint: HINT,
22
+ notice:
23
+ code && locked
24
+ ? `Too many wrong codes. New guests can join again at ${new Date(
25
+ rt.codeLockedUntil!
26
+ ).toLocaleTimeString([], {
27
+ hour: "numeric",
28
+ minute: "2-digit",
29
+ })}, or change the code now.`
30
+ : null,
31
+ prompt: {
32
+ title: "Use my own code",
33
+ message: RULES,
34
+ placeholder: "Your code",
35
+ },
36
+ actions: code
37
+ ? [
38
+ { kind: "copy", label: "Copy link and code" },
39
+ { kind: "copy_link", label: "Copy link" },
40
+ { kind: "new_code", label: "New code" },
41
+ { kind: "set_code", label: "Use my own…" },
42
+ { kind: "code_off", label: "Turn off" },
43
+ ]
44
+ : [{ kind: "code_on", label: "Turn on" }],
45
+ };
46
+ }
47
+
48
+ export function inviteCode(): LiveInvite["code"] {
49
+ return {
50
+ default: false,
51
+ label: "Require an access code",
52
+ hint: HINT,
53
+ customLabel: "Use my own code",
54
+ rules: RULES,
55
+ };
56
+ }
@@ -0,0 +1,25 @@
1
+ import crypto from "crypto";
2
+
3
+ /** The code is never logged, so nothing here prints it. */
4
+
5
+ /** Six digits, zero-padded so 123 is "000123". */
6
+ export function generateCode(): string {
7
+ return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
8
+ }
9
+
10
+ /** The shape Connect checks: spaces and dashes dropped, upper-cased. */
11
+ export function normalizeCode(code: string): string {
12
+ return code.replace(/[\s-]/g, "").toUpperCase();
13
+ }
14
+
15
+ /** Why a host's own code is refused, or null when it's fine. */
16
+ export function codeRefusal(code: string): string | null {
17
+ return /^[A-Z0-9]{6,32}$/.test(normalizeCode(code))
18
+ ? null
19
+ : "The access code must be 6 to 32 letters or digits.";
20
+ }
21
+
22
+ /** What the host sees and copies: "482-917" for six digits, else as typed in capitals. */
23
+ export function displayCode(code: string): string {
24
+ return /^\d{6}$/.test(code) ? `${code.slice(0, 3)}-${code.slice(3)}` : code;
25
+ }
package/lib/live/inbox.ts CHANGED
@@ -35,6 +35,7 @@ export const DOWNWARD = [
35
35
  "access_failed",
36
36
  "drive_prompt",
37
37
  "drive_stop",
38
+ "code_locked",
38
39
  ] as const;
39
40
 
40
41
  const id = z.string().min(1).max(64);
@@ -55,6 +56,7 @@ const SCHEMAS = {
55
56
  access_failed: z.object({ reason: z.string().max(200).optional() }),
56
57
  drive_prompt: z.object({ id, guest, text: z.string().max(16000) }),
57
58
  drive_stop: z.object({ id, guest }),
59
+ code_locked: z.object({ until: z.string().max(40) }),
58
60
  } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
59
61
 
60
62
  export interface InboxEffects {
@@ -225,6 +227,13 @@ export function handleFrame(
225
227
  }
226
228
  return true;
227
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;
235
+ return true;
236
+ }
228
237
  case "drive_prompt":
229
238
  return acceptPrompt(
230
239
  rt,
@@ -48,6 +48,8 @@ export interface Runtime {
48
48
  flushTimer: ReturnType<typeof setTimeout> | null;
49
49
  slowUntil: number;
50
50
  notice: string | null;
51
+ /** Connect locked the lobby until this time (ms) after wrong access codes. */
52
+ codeLockedUntil: number | null;
51
53
  drops: number;
52
54
  drive: DriveState;
53
55
  }
@@ -10,7 +10,12 @@ import { addTap, getSession, registry } from "../chat/registry";
10
10
  import { sendChatConfirmed } from "../chat/runner";
11
11
  import { shareContext } from "../share/context";
12
12
  import { redactForShare } from "../share/redact";
13
- import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
13
+ import {
14
+ ShareError,
15
+ createLiveRemote,
16
+ revokeRemote,
17
+ setAccessCodeRemote,
18
+ } from "../share/connect";
14
19
  import type { LiveExpiry } from "../connect/control-api";
15
20
  import {
16
21
  LEVELS,
@@ -19,6 +24,8 @@ import {
19
24
  type LiveView,
20
25
  } from "./types";
21
26
  import { driveView, guestActions, levelOptions } from "./levels";
27
+ import { codeView, inviteCode } from "./code-view";
28
+ import { codeRefusal, generateCode, normalizeCode } from "./code";
22
29
  import {
23
30
  driveRefusal,
24
31
  newDrive,
@@ -45,6 +52,7 @@ import {
45
52
  logLive,
46
53
  markLiveEnded,
47
54
  openSuggestions,
55
+ setAccessCode,
48
56
  setLevel,
49
57
  type LiveShareRow,
50
58
  } from "./store";
@@ -108,6 +116,7 @@ function attach(row: LiveShareRow): Runtime {
108
116
  flushTimer: null,
109
117
  slowUntil: 0,
110
118
  notice: null,
119
+ codeLockedUntil: null,
111
120
  drops: 0,
112
121
  drive: newDrive(row.id),
113
122
  };
@@ -162,7 +171,8 @@ export async function createLive(
162
171
  sessionId: string,
163
172
  expiresIn: LiveExpiry,
164
173
  level: LiveLevel = "suggest",
165
- history = true
174
+ history = true,
175
+ codeOpt?: { custom?: string } | null
166
176
  ): Promise<LiveView> {
167
177
  startLive();
168
178
  const session = getSession(sessionId);
@@ -170,13 +180,36 @@ export async function createLive(
170
180
  throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
171
181
  if (activeLiveShareFor(sessionId))
172
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
+ }
173
193
  const ctx = shareContext();
174
194
  const title =
175
195
  redactForShare(session.name, ctx)
176
196
  .text.replace(/\s+/g, " ")
177
197
  .trim()
178
198
  .slice(0, 120) || "Chat";
179
- 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
+ }
180
213
  const row: LiveShareRow = {
181
214
  id: made.id,
182
215
  session_id: sessionId,
@@ -189,6 +222,7 @@ export async function createLive(
189
222
  ended_at: null,
190
223
  level,
191
224
  history: history ? 1 : 0,
225
+ access_code: accessCode,
192
226
  };
193
227
  insertLiveShare(row);
194
228
  attach(row);
@@ -216,6 +250,30 @@ export function changeLevel(id: string, level: LiveLevel): void {
216
250
  live.socket?.send(id, "access", { level });
217
251
  }
218
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
+
219
277
  /** Gives the wheel to an admitted guest of a Drive share, or takes it back (null). */
220
278
  export function giveWheel(id: string, guestId: string | null): void {
221
279
  const rt = runtimeFor(id);
@@ -253,6 +311,7 @@ export function inviteFor(sessionId: string): LiveInvite {
253
311
  default: true,
254
312
  count,
255
313
  },
314
+ code: inviteCode(),
256
315
  };
257
316
  }
258
317
 
@@ -413,6 +472,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
413
472
  notice: rt.notice,
414
473
  level: rt.share.level,
415
474
  levels,
475
+ code: codeView(rt, now),
416
476
  history: rt.share.history === 1,
417
477
  ...driveView(rt),
418
478
  guests: shown
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 =
@@ -25,6 +27,8 @@ export type LogKind =
25
27
  | "sent"
26
28
  | "dismissed"
27
29
  | "level"
30
+ // The code was turned on, off or changed; the log never holds the code.
31
+ | "code_changed"
28
32
  // Drive: who holds the wheel (guest_id, ref = the wheel's own id; both
29
33
  // null when taken back), a prompt they sent (ref = its id), and why a
30
34
  // Drive's turn or queue was stopped (ref = the reason).
@@ -47,8 +51,8 @@ export interface LogRow {
47
51
  export function insertLiveShare(row: LiveShareRow): void {
48
52
  db.prepare(
49
53
  `INSERT INTO live_shares
50
- (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
51
- 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)`
52
56
  ).run(row);
53
57
  }
54
58
 
@@ -94,6 +98,22 @@ export function setLevel(id: string, level: LiveLevel): void {
94
98
  });
95
99
  }
96
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
+
97
117
  export function logLive(
98
118
  row: Omit<LogRow, "id" | "at"> & { at?: number }
99
119
  ): void {
package/lib/live/types.ts CHANGED
@@ -29,6 +29,25 @@ export interface GuestAction {
29
29
  label: string;
30
30
  }
31
31
 
32
+ export type CodeActionKind =
33
+ "copy" | "copy_link" | "new_code" | "set_code" | "code_off" | "code_on";
34
+
35
+ /** The access code as the host's screens show it; built on the machine. */
36
+ export interface LiveCodeView {
37
+ on: boolean;
38
+ /** "482-917", or the host's own code in capitals. Null when off. */
39
+ display: string | null;
40
+ /** The link and the code on two lines, for one paste. Null when off. */
41
+ copyText: string | null;
42
+ label: string;
43
+ hint: string;
44
+ /** Set after too many wrong tries; a code change clears it. */
45
+ notice: string | null;
46
+ /** For "Use my own". */
47
+ prompt: { title: string; message: string; placeholder: string };
48
+ actions: { kind: CodeActionKind; label: string }[];
49
+ }
50
+
32
51
  export type DriverState = "idle" | "queued" | "working" | "waiting_on_host";
33
52
 
34
53
  // What the host's screens show of a live share (served by /api/live-shares).
@@ -46,6 +65,7 @@ export interface LiveView {
46
65
  levels: LevelOption[];
47
66
  /** Whether the guests got the chat from before the link was made. */
48
67
  history: boolean;
68
+ code: LiveCodeView;
49
69
  /** The guest holding the wheel on a Drive share. */
50
70
  driver: { guestId: string; name: string; state: DriverState } | null;
51
71
  /** Shown above the composer while a guest drives. */
@@ -81,6 +101,13 @@ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
81
101
  export interface LiveInvite {
82
102
  levels: LevelOption[];
83
103
  history: { label: string; default: boolean; count: number };
104
+ code: {
105
+ default: boolean;
106
+ label: string;
107
+ hint: string;
108
+ customLabel: string;
109
+ rules: string;
110
+ };
84
111
  }
85
112
 
86
113
  export interface LiveResponse {
@@ -7,6 +7,7 @@ import {
7
7
  listShares,
8
8
  replaceShare,
9
9
  createLiveShare,
10
+ setLiveAccessCode,
10
11
  revokeShare,
11
12
  uploadShare,
12
13
  type LiveExpiry,
@@ -112,6 +113,7 @@ export async function createLiveRemote(share: {
112
113
  title: string;
113
114
  expiresIn: LiveExpiry;
114
115
  access?: string;
116
+ accessCode?: string;
115
117
  }) {
116
118
  const { config, machineKey } = files();
117
119
  try {
@@ -120,3 +122,21 @@ export async function createLiveRemote(share: {
120
122
  explain(err);
121
123
  }
122
124
  }
125
+
126
+ /** Puts a new access code on Connect (null turns it off). Throws if Connect doesn't confirm. */
127
+ export async function setAccessCodeRemote(
128
+ id: string,
129
+ code: string | null
130
+ ): Promise<void> {
131
+ const { config, machineKey } = files();
132
+ try {
133
+ await setLiveAccessCode(config, machineKey, id, code);
134
+ } catch (err) {
135
+ if (err instanceof ApiError && (err.status === 404 || err.status === 410))
136
+ throw new ShareError("This link has ended.", 404);
137
+ throw new ShareError(
138
+ "Connect didn't confirm the access code, so it is unchanged. Try again.",
139
+ 502
140
+ );
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.780",
3
+ "version": "0.3.781",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"