@saadnvd1/agent-os 0.3.779 → 0.3.780

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.
@@ -7,16 +7,29 @@ import {
7
7
  deny,
8
8
  dismissSuggestion,
9
9
  endLive,
10
+ giveWheel,
10
11
  kick,
11
12
  sendSuggestion,
12
13
  startLive,
13
14
  } from "@/lib/live/service";
14
15
 
15
16
  const Action = z.discriminatedUnion("action", [
16
- z.object({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
17
+ z.object({
18
+ action: z.literal("admit"),
19
+ guestId: z.string().min(1).max(64),
20
+ drive: z.boolean().optional(),
21
+ }),
17
22
  z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
18
23
  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"]) }),
24
+ z.object({
25
+ action: z.literal("level"),
26
+ level: z.enum(["watch", "suggest", "drive"]),
27
+ }),
28
+ // Gives the wheel to an admitted guest of a Drive share; null takes it back.
29
+ z.object({
30
+ action: z.literal("wheel"),
31
+ guestId: z.string().min(1).max(64).nullable(),
32
+ }),
20
33
  z.object({
21
34
  action: z.literal("send"),
22
35
  suggestionId: z.string().min(1).max(64),
@@ -29,7 +42,8 @@ const Action = z.discriminatedUnion("action", [
29
42
  ]);
30
43
 
31
44
  // 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.
45
+ // away, remove one, send a suggestion to the agent or dismiss it, change the
46
+ // level, give or take back the wheel.
33
47
  export async function POST(
34
48
  request: Request,
35
49
  { params }: { params: Promise<{ id: string }> }
@@ -44,10 +58,11 @@ export async function POST(
44
58
  startLive();
45
59
  try {
46
60
  const a = body.data;
47
- if (a.action === "admit") admit(id, a.guestId);
61
+ if (a.action === "admit") admit(id, a.guestId, { drive: a.drive });
48
62
  else if (a.action === "deny") deny(id, a.guestId);
49
63
  else if (a.action === "kick") kick(id, a.guestId);
50
64
  else if (a.action === "level") changeLevel(id, a.level);
65
+ else if (a.action === "wheel") giveWheel(id, a.guestId);
51
66
  else if (a.action === "send")
52
67
  await sendSuggestion(id, a.suggestionId, a.text);
53
68
  else dismissSuggestion(id, a.suggestionId);
@@ -43,9 +43,10 @@ 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("accept watch, suggest and drive, and history as a boolean", async () => {
47
+ // Past validation: not linked to Connect here, so 409, never 400.
47
48
  expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
48
- 400
49
+ 409
49
50
  );
50
51
  expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
51
52
  400
@@ -53,8 +54,28 @@ describe("live-shares routes", () => {
53
54
  expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
54
55
  400
55
56
  );
57
+ // A level for a share this machine doesn't hold: 404, so it validated.
56
58
  expect(
57
59
  (await act(req({ action: "level", level: "drive" }), ctx)).status
60
+ ).toBe(404);
61
+ expect(
62
+ (await act(req({ action: "level", level: "admin" }), ctx)).status
58
63
  ).toBe(400);
59
64
  });
65
+
66
+ it("validate the wheel and admit-to-drive actions", async () => {
67
+ for (const body of [
68
+ { action: "wheel" },
69
+ { action: "wheel", guestId: 7 },
70
+ { action: "wheel", guestId: "" },
71
+ { action: "admit", guestId: "g", drive: "yes" },
72
+ ])
73
+ expect((await act(req(body), ctx)).status).toBe(400);
74
+ for (const body of [
75
+ { action: "wheel", guestId: "g1" },
76
+ { action: "wheel", guestId: null },
77
+ { action: "admit", guestId: "g", drive: true },
78
+ ])
79
+ expect((await act(req(body), ctx)).status).toBe(404);
80
+ });
60
81
  });
@@ -7,7 +7,7 @@ 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
12
  });
13
13
 
@@ -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
+ }
@@ -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 (
@@ -42,6 +50,15 @@ export function LivePanel({
42
50
  onEnd={() => end.mutate(undefined, { onError: fail })}
43
51
  onInvite={() => liveUiActions.open(sessionId, title)}
44
52
  />
53
+ {share.banner && share.driver && (
54
+ <DriveBanner
55
+ banner={share.banner}
56
+ busy={busy}
57
+ onTakeBack={() =>
58
+ act.mutate({ action: "wheel", guestId: null }, { onError: fail })
59
+ }
60
+ />
61
+ )}
45
62
  {lobby.map((g) => (
46
63
  <LobbyCard
47
64
  key={g.id}
@@ -63,7 +63,9 @@ export function useCreateLive() {
63
63
  }
64
64
 
65
65
  export type LiveAction =
66
- | { action: "admit" | "deny" | "kick"; guestId: string }
66
+ | { action: "admit"; guestId: string; drive?: boolean }
67
+ | { action: "deny" | "kick"; guestId: string }
68
+ | { action: "wheel"; guestId: string | null }
67
69
  | { action: "level"; level: LiveLevel }
68
70
  | { action: "send"; suggestionId: string; text?: string }
69
71
  | { action: "dismiss"; suggestionId: string };
@@ -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.
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../events";
16
16
  import { toolDiff, toolTitle } from "../tools";
17
17
  import type { ToolGuard } from "../driver";
18
+ import { guestGateHook, type GuestGate } from "../worker/guest-gate";
18
19
 
19
20
  type Approval = Extract<ChatItem, { kind: "approval" }>;
20
21
 
@@ -101,45 +102,94 @@ export class Approvals {
101
102
  private planFile: () => string | undefined = () => undefined
102
103
  ) {}
103
104
 
105
+ // Who steers the running turn, when a live guest does (lib/chat/worker/
106
+ // guest-gate): every card it raises is theirs, with no Always.
107
+ guestOf: () => { name: string } | null = () => null;
108
+ // Calls the guest gate let through, by tool use id: the permission prompt
109
+ // that may follow for one is already answered.
110
+ private gatePassed = new Set<string>();
111
+
104
112
  canUseTool: CanUseTool = (toolName, input, options) => {
105
113
  // Already answered on its card by the question hook.
106
114
  if (toolName === "AskUserQuestion" && "answers" in input)
107
115
  return Promise.resolve({ behavior: "allow", updatedInput: input });
116
+ if (options.toolUseID && this.gatePassed.delete(options.toolUseID))
117
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
108
118
  const questions =
109
119
  toolName === "AskUserQuestion"
110
120
  ? ((input as { questions?: ChatQuestion[] }).questions ?? [])
111
121
  : undefined;
112
- const item: Approval = {
113
- id: `approval-${options.toolUseID}`,
114
- kind: "approval",
115
- toolName,
116
- title: options.title ?? toolTitle(toolName, input),
117
- input,
118
- diff: toolDiff(toolName, input),
119
- questions,
120
- canAlways:
121
- !questions &&
122
- !!options.suggestions?.length &&
123
- !options.suppressAlwaysAllowRule,
124
- status: "pending",
125
- createdAt: Date.now(),
126
- };
122
+ const guest = this.guestOf() ?? undefined;
123
+ return this.ask(
124
+ {
125
+ id: `approval-${options.toolUseID}`,
126
+ kind: "approval",
127
+ toolName,
128
+ title: options.title ?? toolTitle(toolName, input),
129
+ input,
130
+ diff: toolDiff(toolName, input),
131
+ questions,
132
+ canAlways:
133
+ !guest &&
134
+ !questions &&
135
+ !!options.suggestions?.length &&
136
+ !options.suppressAlwaysAllowRule,
137
+ status: "pending",
138
+ createdAt: Date.now(),
139
+ ...(guest && { guest }),
140
+ },
141
+ options.signal,
142
+ guest ? undefined : options.suggestions
143
+ );
144
+ };
145
+
146
+ // A step of a guest-steered turn: the host's card, never Always.
147
+ askGuest(
148
+ toolName: string,
149
+ input: Record<string, unknown>,
150
+ toolUseID: string,
151
+ signal: AbortSignal,
152
+ guest: { name: string }
153
+ ): Promise<PermissionResult> {
154
+ return this.ask(
155
+ {
156
+ id: `approval-${toolUseID}`,
157
+ kind: "approval",
158
+ toolName,
159
+ title: toolTitle(toolName, input),
160
+ input,
161
+ diff: toolDiff(toolName, input),
162
+ canAlways: false,
163
+ status: "pending",
164
+ createdAt: Date.now(),
165
+ guest,
166
+ },
167
+ signal
168
+ );
169
+ }
170
+
171
+ passed(toolUseID: string): void {
172
+ if (toolUseID) this.gatePassed.add(toolUseID);
173
+ }
174
+
175
+ private ask(
176
+ item: Approval,
177
+ signal: AbortSignal,
178
+ suggestions?: Parameters<CanUseTool>[2]["suggestions"]
179
+ ): Promise<PermissionResult> {
127
180
  return new Promise<PermissionResult>((resolve) => {
128
- this.pending.set(item.id, {
129
- item,
130
- suggestions: options.suggestions,
131
- resolve,
132
- });
133
- options.signal.addEventListener("abort", () => {
181
+ this.pending.set(item.id, { item, suggestions, resolve });
182
+ const expire = () =>
134
183
  this.settle(item.id, "expired", {
135
184
  behavior: "deny",
136
185
  message: DENIED,
137
186
  });
138
- });
187
+ if (signal.aborted) return queueMicrotask(expire);
188
+ signal.addEventListener("abort", expire);
139
189
  this.emit({ type: "item", item });
140
190
  this.emit({ type: "state", state: "waiting" });
141
191
  });
142
- };
192
+ }
143
193
 
144
194
  // Full access approves every tool before canUseTool is asked, questions
145
195
  // included, so the agent's questions come through this hook instead.
@@ -209,11 +259,14 @@ export class Approvals {
209
259
  } else if (answer.decision === "deny") {
210
260
  this.settle(id, "denied", { behavior: "deny", message: DENIED });
211
261
  } else {
262
+ // A guest's card has no Always: an "always" answer is a plain allow.
212
263
  this.settle(id, "allowed", {
213
264
  behavior: "allow",
214
265
  updatedInput: input,
215
266
  updatedPermissions:
216
- answer.decision === "always" ? p.suggestions : undefined,
267
+ answer.decision === "always" && !p.item.guest
268
+ ? p.suggestions
269
+ : undefined,
217
270
  });
218
271
  }
219
272
  }
@@ -270,9 +323,24 @@ export function guardHook(guard: ToolGuard): HookCallback {
270
323
  export function preToolUseHooks(
271
324
  approvals: Approvals,
272
325
  guard?: ToolGuard,
273
- subagentModels?: HookCallback | null
326
+ subagentModels?: HookCallback | null,
327
+ // A live guest's turn: every call but a worktree read waits for the host.
328
+ guest?: { gate: GuestGate; cwd: string }
274
329
  ): HookCallbackMatcher[] {
330
+ if (guest)
331
+ approvals.guestOf = () =>
332
+ guest.gate.steer ? { name: guest.gate.steer.name } : null;
275
333
  return [
334
+ // First, and on every tool: no steer, no effect. It waits on the host
335
+ // for as long as the card is up (the turn's own cap ends it sooner).
336
+ ...(guest
337
+ ? [
338
+ {
339
+ hooks: [guestGateHook(approvals, guest.gate, guest.cwd)],
340
+ timeout: 24 * 60 * 60,
341
+ },
342
+ ]
343
+ : []),
276
344
  // Sets the model of a subagent call by its type. It only fills in the
277
345
  // input, so the guard and permissions below still see the call.
278
346
  ...(subagentModels
@@ -222,7 +222,8 @@ export const claudeDriver: ChatDriver = {
222
222
  PreToolUse: preToolUseHooks(
223
223
  approvals,
224
224
  options.toolGuard,
225
- subagentHook(options.env)
225
+ subagentHook(options.env),
226
+ options.guestGate && { gate: options.guestGate, cwd: options.cwd }
226
227
  ),
227
228
  ...(options.compactContext && {
228
229
  SessionStart: compactedHooks(options.compactContext),
@@ -84,7 +84,12 @@ export type ChatOriginKind =
84
84
  | "device"
85
85
  // A suggestion from a guest on a live link, sent by the reader after
86
86
  // reading it. Its label is the guest's name.
87
- | "guest";
87
+ | "guest"
88
+ // A prompt from the guest holding the wheel on a Drive live link: it went
89
+ // in without the host's tap, so every step it takes past reading the
90
+ // task's files waits for one (lib/chat/worker/guest-gate). Its body is
91
+ // the guest's own text; the agent got it framed as a stranger's.
92
+ | "guest-drive";
88
93
 
89
94
  export interface ChatOrigin {
90
95
  kind: ChatOriginKind;
@@ -102,6 +107,11 @@ export interface ChatOrigin {
102
107
  // Only Away mode's own lines: the turn it starts isn't anyone's activity
103
108
  // (lib/away/activity).
104
109
  away?: true;
110
+ // A guest-drive message: the live share, the guest it came from, and the
111
+ // wheel it was sent under (a new one each time the host gives it).
112
+ share?: string;
113
+ guestId?: string;
114
+ wheel?: string;
105
115
  }
106
116
 
107
117
  // Who sent a message, carried with it through the queue and the worker.
@@ -109,6 +119,10 @@ export interface SentBy {
109
119
  from?: string;
110
120
  peer?: PeerMessage;
111
121
  origin?: ChatOrigin;
122
+ // The host sent it themselves: set only by their own composer and taps
123
+ // (never by an agent, event or schedule). Only such a message ends a live
124
+ // guest's steer (lib/chat/worker/guest-gate).
125
+ host?: true;
112
126
  }
113
127
 
114
128
  export interface McpServerView {
@@ -285,6 +299,9 @@ export type ChatItem =
285
299
  status: "pending" | "allowed" | "denied" | "answered" | "expired";
286
300
  // Allowed by No approvals mode, not by the reader.
287
301
  auto?: boolean;
302
+ // Asked in a turn a live guest steered: only the reader's tap answers
303
+ // it (never No approvals mode), and it has no Always.
304
+ guest?: { name: string };
288
305
  })
289
306
  | (Base & {
290
307
  // Work the agent runs alongside the turn: a background shell, a
@@ -323,6 +340,9 @@ export interface QueuedMessage {
323
340
  imageCount?: number;
324
341
  fileCount?: number;
325
342
  createdAt: number;
343
+ // A live guest's Drive prompt: who it's from and what they typed (the
344
+ // text is what the agent gets, framed).
345
+ guest?: { name: string; body: string };
326
346
  }
327
347
 
328
348
  // A file or folder the agent would match for an @mention.
@@ -26,7 +26,7 @@ export function listQueue(sessionId: string): QueuedMessage[] {
26
26
  return (
27
27
  db
28
28
  .prepare(
29
- `SELECT id, text, image_count, files, created_at
29
+ `SELECT id, text, image_count, files, sent_by, created_at
30
30
  FROM chat_queue WHERE session_id = ? ORDER BY position, created_at`
31
31
  )
32
32
  .all(sessionId) as {
@@ -34,15 +34,57 @@ export function listQueue(sessionId: string): QueuedMessage[] {
34
34
  text: string;
35
35
  image_count: number;
36
36
  files: string | null;
37
+ sent_by: string | null;
37
38
  created_at: number;
38
39
  }[]
39
- ).map((r) => ({
40
- id: r.id,
41
- text: r.text,
42
- imageCount: r.image_count || undefined,
43
- fileCount: r.files ? (JSON.parse(r.files) as unknown[]).length : undefined,
44
- createdAt: r.created_at,
45
- }));
40
+ ).map((r) => {
41
+ const o = r.sent_by ? (JSON.parse(r.sent_by) as SentBy).origin : undefined;
42
+ return {
43
+ id: r.id,
44
+ text: r.text,
45
+ imageCount: r.image_count || undefined,
46
+ fileCount: r.files
47
+ ? (JSON.parse(r.files) as unknown[]).length
48
+ : undefined,
49
+ createdAt: r.created_at,
50
+ ...(o?.kind === "guest-drive"
51
+ ? { guest: { name: o.label, body: o.body ?? r.text } }
52
+ : {}),
53
+ };
54
+ });
55
+ }
56
+
57
+ // Who sent the message that goes next, without taking it.
58
+ export function peekNextSentBy(sessionId: string): SentBy | null {
59
+ const row = db
60
+ .prepare(
61
+ `SELECT sent_by FROM chat_queue WHERE session_id = ? ORDER BY position, created_at LIMIT 1`
62
+ )
63
+ .get(sessionId) as { sent_by: string | null } | undefined;
64
+ if (!row) return null;
65
+ return row.sent_by ? (JSON.parse(row.sent_by) as SentBy) : {};
66
+ }
67
+
68
+ // Drops every queued message whose sender matches; returns the ids dropped.
69
+ export function deleteQueuedWhere(
70
+ sessionId: string,
71
+ match: (by: SentBy) => boolean
72
+ ): string[] {
73
+ return db.transaction(() => {
74
+ const rows = db
75
+ .prepare(
76
+ `SELECT id, sent_by FROM chat_queue WHERE session_id = ? AND sent_by IS NOT NULL`
77
+ )
78
+ .all(sessionId) as { id: string; sent_by: string }[];
79
+ const ids = rows
80
+ .filter((r) => match(JSON.parse(r.sent_by) as SentBy))
81
+ .map((r) => r.id);
82
+ const del = db.prepare(
83
+ `DELETE FROM chat_queue WHERE session_id = ? AND id = ?`
84
+ );
85
+ for (const id of ids) del.run(sessionId, id);
86
+ return ids;
87
+ })();
46
88
  }
47
89
 
48
90
  // At the back. A retried send with the same id is only queued once.
@@ -56,7 +98,12 @@ export function enqueue(
56
98
  } & SentBy
57
99
  ): void {
58
100
  const images = m.images?.length ? JSON.stringify(m.images) : null;
59
- const sentBy: SentBy = { from: m.from, peer: m.peer, origin: m.origin };
101
+ const sentBy: SentBy = {
102
+ from: m.from,
103
+ peer: m.peer,
104
+ origin: m.origin,
105
+ host: m.host,
106
+ };
60
107
  if (m.text.length > MAX_QUEUED_TEXT)
61
108
  throw new Error("That message is too long to queue");
62
109
  if (images && images.length > MAX_QUEUED_IMAGES)
@@ -77,7 +124,7 @@ export function enqueue(
77
124
  images,
78
125
  m.images?.length ?? 0,
79
126
  m.files?.length ? JSON.stringify(m.files) : null,
80
- m.from || m.peer || m.origin ? JSON.stringify(sentBy) : null,
127
+ m.from || m.peer || m.origin || m.host ? JSON.stringify(sentBy) : null,
81
128
  Date.now()
82
129
  );
83
130
  }