@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
@@ -3,20 +3,41 @@ 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,
9
10
  endLive,
11
+ giveWheel,
10
12
  kick,
11
13
  sendSuggestion,
12
14
  startLive,
13
15
  } from "@/lib/live/service";
14
16
 
15
17
  const Action = z.discriminatedUnion("action", [
16
- z.object({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
18
+ z.object({
19
+ action: z.literal("admit"),
20
+ guestId: z.string().min(1).max(64),
21
+ drive: z.boolean().optional(),
22
+ }),
17
23
  z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
18
24
  z.object({ action: z.literal("kick"), guestId: z.string().min(1).max(64) }),
19
- z.object({ action: z.literal("level"), level: z.enum(["watch", "suggest"]) }),
25
+ z.object({
26
+ action: z.literal("level"),
27
+ level: z.enum(["watch", "suggest", "drive"]),
28
+ }),
29
+ // Gives the wheel to an admitted guest of a Drive share; null takes it back.
30
+ z.object({
31
+ action: z.literal("wheel"),
32
+ guestId: z.string().min(1).max(64).nullable(),
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") }),
20
41
  z.object({
21
42
  action: z.literal("send"),
22
43
  suggestionId: z.string().min(1).max(64),
@@ -29,7 +50,8 @@ const Action = z.discriminatedUnion("action", [
29
50
  ]);
30
51
 
31
52
  // POST /api/live-shares/:id - the host's decisions: let a guest in, turn one
32
- // away, remove one, send a suggestion to the agent or dismiss it.
53
+ // away, remove one, send a suggestion to the agent or dismiss it, change the
54
+ // level, give or take back the wheel.
33
55
  export async function POST(
34
56
  request: Request,
35
57
  { params }: { params: Promise<{ id: string }> }
@@ -44,10 +66,14 @@ export async function POST(
44
66
  startLive();
45
67
  try {
46
68
  const a = body.data;
47
- if (a.action === "admit") admit(id, a.guestId);
69
+ if (a.action === "admit") admit(id, a.guestId, { drive: a.drive });
48
70
  else if (a.action === "deny") deny(id, a.guestId);
49
71
  else if (a.action === "kick") kick(id, a.guestId);
50
72
  else if (a.action === "level") changeLevel(id, a.level);
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 });
51
77
  else if (a.action === "send")
52
78
  await sendSuggestion(id, a.suggestionId, a.text);
53
79
  else dismissSuggestion(id, a.suggestionId);
@@ -43,9 +43,24 @@ describe("live-shares routes", () => {
43
43
  );
44
44
  });
45
45
 
46
- it("accept watch and suggest only, and history as a boolean", async () => {
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
+
60
+ it("accept watch, suggest and drive, and history as a boolean", async () => {
61
+ // Past validation: not linked to Connect here, so 409, never 400.
47
62
  expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
48
- 400
63
+ 409
49
64
  );
50
65
  expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
51
66
  400
@@ -53,8 +68,28 @@ describe("live-shares routes", () => {
53
68
  expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
54
69
  400
55
70
  );
71
+ // A level for a share this machine doesn't hold: 404, so it validated.
56
72
  expect(
57
73
  (await act(req({ action: "level", level: "drive" }), ctx)).status
74
+ ).toBe(404);
75
+ expect(
76
+ (await act(req({ action: "level", level: "admin" }), ctx)).status
58
77
  ).toBe(400);
59
78
  });
79
+
80
+ it("validate the wheel and admit-to-drive actions", async () => {
81
+ for (const body of [
82
+ { action: "wheel" },
83
+ { action: "wheel", guestId: 7 },
84
+ { action: "wheel", guestId: "" },
85
+ { action: "admit", guestId: "g", drive: "yes" },
86
+ ])
87
+ expect((await act(req(body), ctx)).status).toBe(400);
88
+ for (const body of [
89
+ { action: "wheel", guestId: "g1" },
90
+ { action: "wheel", guestId: null },
91
+ { action: "admit", guestId: "g", drive: true },
92
+ ])
93
+ expect((await act(req(body), ctx)).status).toBe(404);
94
+ });
60
95
  });
@@ -7,8 +7,13 @@ import { createLive, inviteFor, listLive, startLive } from "@/lib/live/service";
7
7
  const Create = z.object({
8
8
  sessionId: z.string().min(1).max(100),
9
9
  expiresIn: z.enum(["1h", "session"]).default("1h"),
10
- level: z.enum(["watch", "suggest"]).default("suggest"),
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) {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { MessageCircleQuestion, ShieldQuestion } from "lucide-react";
4
+ import { MessageCircleQuestion, ShieldQuestion, Users } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
7
7
  import { cn } from "@/lib/utils";
@@ -20,6 +20,17 @@ function AccessCard({
20
20
  }) {
21
21
  return (
22
22
  <div className="bg-primary/[0.06] space-y-3 rounded-xl p-3">
23
+ {item.guest && (
24
+ <p className="text-muted-foreground flex items-center gap-1.5 text-xs">
25
+ <Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
26
+ <span className="min-w-0 truncate">
27
+ <span className="text-foreground font-medium">
28
+ {item.guest.name}&apos;s turn
29
+ </span>{" "}
30
+ · a live guest asked for this
31
+ </span>
32
+ </p>
33
+ )}
23
34
  <div className="flex items-start gap-2 text-sm">
24
35
  <ShieldQuestion className="text-primary mt-0.5 h-4 w-4 shrink-0" />
25
36
  <span className="min-w-0 break-words">{item.title}</span>
@@ -33,7 +44,7 @@ function AccessCard({
33
44
  >
34
45
  Deny
35
46
  </Button>
36
- {item.canAlways && (
47
+ {item.canAlways && !item.guest && (
37
48
  <Button
38
49
  variant="secondary"
39
50
  className="h-11 md:h-9"
@@ -29,6 +29,7 @@ export const ICON: Record<ChatOriginKind, LucideIcon> = {
29
29
  decision: UserCheck,
30
30
  device: Smartphone,
31
31
  guest: Users,
32
+ "guest-drive": Users,
32
33
  };
33
34
 
34
35
  // Longer than this (what fits beside a source on a phone), or more than one line,
@@ -183,6 +184,10 @@ export function EventRow({
183
184
  <span className="truncate font-medium">
184
185
  Suggested by {origin.label}
185
186
  </span>
187
+ ) : origin.kind === "guest-drive" ? (
188
+ <span className="truncate font-medium">
189
+ Prompted by {origin.label}
190
+ </span>
186
191
  ) : (
187
192
  <Source origin={origin} />
188
193
  )
@@ -53,7 +53,15 @@ export function Queue({
53
53
  <span className="text-muted-foreground w-5 shrink-0 pt-3 text-center text-xs tabular-nums">
54
54
  {i + 1}
55
55
  </span>
56
- {editing?.id === m.id ? (
56
+ {m.guest ? (
57
+ // A driving guest's prompt: theirs to word, the host's to remove.
58
+ <div className="min-h-11 min-w-0 flex-1 px-2 py-2.5 text-sm break-words whitespace-pre-wrap">
59
+ <span className="text-muted-foreground block text-xs">
60
+ {m.guest.name} (guest)
61
+ </span>
62
+ <span className="line-clamp-3">{m.guest.body}</span>
63
+ </div>
64
+ ) : editing?.id === m.id ? (
57
65
  <textarea
58
66
  autoFocus
59
67
  value={editing.text}
@@ -99,20 +107,24 @@ export function Queue({
99
107
  </button>
100
108
  )}
101
109
  <div className="flex shrink-0 items-center">
102
- <IconButton
103
- label="Send now"
104
- onClick={() => onSendNow(m.id)}
105
- className="text-foreground"
106
- >
107
- <SendHorizontal className="h-4 w-4" />
108
- </IconButton>
109
- <IconButton
110
- label="Edit"
111
- onClick={() => setEditing({ id: m.id, text: m.text })}
112
- className="max-md:hidden"
113
- >
114
- <Pencil className="h-3.5 w-3.5" />
115
- </IconButton>
110
+ {!m.guest && (
111
+ <>
112
+ <IconButton
113
+ label="Send now"
114
+ onClick={() => onSendNow(m.id)}
115
+ className="text-foreground"
116
+ >
117
+ <SendHorizontal className="h-4 w-4" />
118
+ </IconButton>
119
+ <IconButton
120
+ label="Edit"
121
+ onClick={() => setEditing({ id: m.id, text: m.text })}
122
+ className="max-md:hidden"
123
+ >
124
+ <Pencil className="h-3.5 w-3.5" />
125
+ </IconButton>
126
+ </>
127
+ )}
116
128
  <IconButton
117
129
  label="Move up"
118
130
  disabled={i === 0}
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { Hand, Users } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import type { LiveView } from "@/data/live";
6
+
7
+ // A guest holds the wheel: what they're doing, and the host's way back.
8
+ export function DriveBanner({
9
+ banner,
10
+ busy,
11
+ onTakeBack,
12
+ }: {
13
+ banner: NonNullable<LiveView["banner"]>;
14
+ busy: boolean;
15
+ onTakeBack: () => void;
16
+ }) {
17
+ return (
18
+ <div
19
+ role="status"
20
+ className="bg-primary/[0.06] flex items-center gap-2 rounded-xl py-1 pr-1 pl-3 shadow-sm"
21
+ >
22
+ <Users className="text-primary h-4 w-4 shrink-0" aria-hidden />
23
+ <div className="min-w-0 flex-1 text-sm">
24
+ <p className="leading-snug font-medium">{banner.text}</p>
25
+ <p className="text-muted-foreground truncate text-xs max-sm:hidden">
26
+ Every edit, command and outside read asks you first.
27
+ </p>
28
+ </div>
29
+ {banner.action && (
30
+ <Button
31
+ variant="outline"
32
+ className="min-h-11 shrink-0"
33
+ disabled={busy}
34
+ onClick={onTakeBack}
35
+ >
36
+ <Hand className="h-4 w-4" aria-hidden />
37
+ {banner.action.label}
38
+ </Button>
39
+ )}
40
+ </div>
41
+ );
42
+ }
@@ -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
+ }
@@ -3,12 +3,14 @@
3
3
  import { toast } from "sonner";
4
4
  import { useEndLive, useLiveAction, useLiveShare } from "@/data/live";
5
5
  import { liveUiActions } from "@/stores/liveUi";
6
+ import { DriveBanner } from "./DriveBanner";
6
7
  import { GuestPromptCard } from "./GuestPromptCard";
7
8
  import { LiveChip } from "./LiveChip";
8
9
  import { LobbyCard } from "./LobbyCard";
9
10
 
10
11
  // Above the composer while a session has a live link: the Live chip, who is
11
- // waiting to come in, and the suggestions to send, edit or dismiss.
12
+ // driving, who is waiting to come in, and the suggestions to send, edit or
13
+ // dismiss.
12
14
  export function LivePanel({
13
15
  sessionId,
14
16
  title,
@@ -28,6 +30,12 @@ export function LivePanel({
28
30
  const doAction = (guestId: string, kind: string) => {
29
31
  if (kind === "admit" || kind === "deny" || kind === "kick")
30
32
  act.mutate({ action: kind, guestId }, { onError: fail });
33
+ else if (kind === "admit_drive")
34
+ act.mutate({ action: "admit", guestId, drive: true }, { onError: fail });
35
+ else if (kind === "give_wheel")
36
+ act.mutate({ action: "wheel", guestId }, { onError: fail });
37
+ else if (kind === "take_back")
38
+ act.mutate({ action: "wheel", guestId: null }, { onError: fail });
31
39
  };
32
40
  const lobby = share.guests.filter((g) => g.status === "pending");
33
41
  return (
@@ -39,9 +47,26 @@ export function LivePanel({
39
47
  onLevel={(level) =>
40
48
  act.mutate({ action: "level", level }, { onError: fail })
41
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
+ }}
42
58
  onEnd={() => end.mutate(undefined, { onError: fail })}
43
59
  onInvite={() => liveUiActions.open(sessionId, title)}
44
60
  />
61
+ {share.banner && share.driver && (
62
+ <DriveBanner
63
+ banner={share.banner}
64
+ busy={busy}
65
+ onTakeBack={() =>
66
+ act.mutate({ action: "wheel", guestId: null }, { onError: fail })
67
+ }
68
+ />
69
+ )}
45
70
  {lobby.map((g) => (
46
71
  <LobbyCard
47
72
  key={g.id}
@@ -57,14 +57,19 @@ 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
  });
63
64
  }
64
65
 
65
66
  export type LiveAction =
66
- | { action: "admit" | "deny" | "kick"; guestId: string }
67
+ | { action: "admit"; guestId: string; drive?: boolean }
68
+ | { action: "deny" | "kick"; guestId: string }
69
+ | { action: "wheel"; guestId: string | null }
67
70
  | { action: "level"; level: LiveLevel }
71
+ | { action: "code"; code: string | null }
72
+ | { action: "new_code" }
68
73
  | { action: "send"; suggestionId: string; text?: string }
69
74
  | { action: "dismiss"; suggestionId: string };
70
75
 
@@ -2,6 +2,7 @@ import type {
2
2
  McpServerConfig,
3
3
  PermissionMode,
4
4
  } from "@anthropic-ai/claude-agent-sdk";
5
+ import type { GuestGate } from "./worker/guest-gate";
5
6
  import type {
6
7
  ApprovalDecision,
7
8
  ChatAccess,
@@ -60,6 +61,9 @@ export interface ChatStartOptions {
60
61
  // refused, null to let it go on to them, or a warning the agent is shown
61
62
  // while the call still goes on.
62
63
  toolGuard?: ToolGuard;
64
+ // Holds a live guest's turn to the host's taps (./worker/guest-gate). A
65
+ // driver without it can't take Drive prompts (the worker's caps say so).
66
+ guestGate?: GuestGate;
63
67
  // The note handed back to the agent right after its context is compacted
64
68
  // (lib/chat/compact-context), empty for none. Only Claude Code has a hook
65
69
  // for it; other drivers ignore it.