@saadnvd1/agent-os 0.3.775 → 0.3.777
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 +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/page.tsx +5 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/share/connect.ts +24 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { useEndLive, useLiveAction, useLiveShare } from "@/data/live";
|
|
5
|
+
import { liveUiActions } from "@/stores/liveUi";
|
|
6
|
+
import { GuestPromptCard } from "./GuestPromptCard";
|
|
7
|
+
import { LiveChip } from "./LiveChip";
|
|
8
|
+
import { LobbyCard } from "./LobbyCard";
|
|
9
|
+
|
|
10
|
+
// 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
|
+
export function LivePanel({
|
|
13
|
+
sessionId,
|
|
14
|
+
title,
|
|
15
|
+
onEdit,
|
|
16
|
+
}: {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
title: string;
|
|
19
|
+
onEdit: (text: string) => void;
|
|
20
|
+
}) {
|
|
21
|
+
const query = useLiveShare(sessionId);
|
|
22
|
+
const share = query.data?.share;
|
|
23
|
+
const act = useLiveAction(share?.id ?? "");
|
|
24
|
+
const end = useEndLive(share?.id ?? "");
|
|
25
|
+
if (!share) return null;
|
|
26
|
+
const busy = act.isPending || end.isPending;
|
|
27
|
+
const fail = (e: Error) => toast.error(e.message);
|
|
28
|
+
const lobby = share.guests.filter((g) => g.status === "pending");
|
|
29
|
+
return (
|
|
30
|
+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 pb-2">
|
|
31
|
+
<LiveChip
|
|
32
|
+
share={share}
|
|
33
|
+
busy={busy}
|
|
34
|
+
onKick={(guestId) =>
|
|
35
|
+
act.mutate({ action: "kick", guestId }, { onError: fail })
|
|
36
|
+
}
|
|
37
|
+
onEnd={() => end.mutate(undefined, { onError: fail })}
|
|
38
|
+
onInvite={() => liveUiActions.open(sessionId, title)}
|
|
39
|
+
/>
|
|
40
|
+
{lobby.map((g) => (
|
|
41
|
+
<LobbyCard
|
|
42
|
+
key={g.id}
|
|
43
|
+
guest={g}
|
|
44
|
+
busy={busy}
|
|
45
|
+
onAdmit={() =>
|
|
46
|
+
act.mutate({ action: "admit", guestId: g.id }, { onError: fail })
|
|
47
|
+
}
|
|
48
|
+
onDeny={() =>
|
|
49
|
+
act.mutate({ action: "deny", guestId: g.id }, { onError: fail })
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
))}
|
|
53
|
+
{share.suggestions.map((s) => (
|
|
54
|
+
<GuestPromptCard
|
|
55
|
+
key={s.id}
|
|
56
|
+
suggestion={s}
|
|
57
|
+
busy={busy}
|
|
58
|
+
onSend={() =>
|
|
59
|
+
act.mutate(
|
|
60
|
+
{ action: "send", suggestionId: s.id },
|
|
61
|
+
{ onError: fail }
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
onEdit={() => {
|
|
65
|
+
onEdit(s.text);
|
|
66
|
+
act.mutate(
|
|
67
|
+
{ action: "dismiss", suggestionId: s.id },
|
|
68
|
+
{ onError: fail }
|
|
69
|
+
);
|
|
70
|
+
}}
|
|
71
|
+
onDismiss={() =>
|
|
72
|
+
act.mutate(
|
|
73
|
+
{ action: "dismiss", suggestionId: s.id },
|
|
74
|
+
{ onError: fail }
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, UserRound, X } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import type { LiveView } from "@/data/live";
|
|
6
|
+
|
|
7
|
+
type Guest = LiveView["guests"][number];
|
|
8
|
+
|
|
9
|
+
// Someone opened the link and is waiting. The name is whatever they typed.
|
|
10
|
+
export function LobbyCard({
|
|
11
|
+
guest,
|
|
12
|
+
busy,
|
|
13
|
+
onAdmit,
|
|
14
|
+
onDeny,
|
|
15
|
+
}: {
|
|
16
|
+
guest: Guest;
|
|
17
|
+
busy: boolean;
|
|
18
|
+
onAdmit: () => void;
|
|
19
|
+
onDeny: () => void;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
role="group"
|
|
24
|
+
aria-label={`${guest.name} wants to join`}
|
|
25
|
+
className="bg-muted/60 flex flex-col gap-2 rounded-xl p-3 shadow-sm sm:flex-row sm:items-center"
|
|
26
|
+
>
|
|
27
|
+
<div className="flex min-w-0 flex-1 items-start gap-2">
|
|
28
|
+
<UserRound
|
|
29
|
+
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
|
30
|
+
aria-hidden
|
|
31
|
+
/>
|
|
32
|
+
<div className="min-w-0 text-sm">
|
|
33
|
+
<p className="[overflow-wrap:anywhere]">
|
|
34
|
+
<span className="font-medium">{guest.name}</span> wants to join
|
|
35
|
+
</p>
|
|
36
|
+
<p className="text-muted-foreground text-xs">
|
|
37
|
+
{guest.duplicateName
|
|
38
|
+
? "Another guest here has the same name. Names aren't verified."
|
|
39
|
+
: "Names aren't verified."}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex gap-2">
|
|
44
|
+
<Button
|
|
45
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
46
|
+
disabled={busy}
|
|
47
|
+
onClick={onAdmit}
|
|
48
|
+
>
|
|
49
|
+
<Check className="h-4 w-4" />
|
|
50
|
+
Admit
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
variant="outline"
|
|
54
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
55
|
+
disabled={busy}
|
|
56
|
+
onClick={onDeny}
|
|
57
|
+
>
|
|
58
|
+
<X className="h-4 w-4" />
|
|
59
|
+
Deny
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
Search,
|
|
25
25
|
Star,
|
|
26
26
|
Share2,
|
|
27
|
+
Users,
|
|
27
28
|
Download,
|
|
28
29
|
TextSearch,
|
|
29
30
|
Eye,
|
|
@@ -36,6 +37,7 @@ import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
|
36
37
|
import { RolePill } from "@/components/Sessions/RolePill";
|
|
37
38
|
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
38
39
|
import { shareUiActions } from "@/stores/shareUi";
|
|
40
|
+
import { liveUiActions } from "@/stores/liveUi";
|
|
39
41
|
import { exportUiActions } from "@/stores/exportUi";
|
|
40
42
|
import { analystUiActions } from "@/stores/analystUi";
|
|
41
43
|
import { useCanAddAnalyst } from "@/components/Analyst/pair";
|
|
@@ -228,6 +230,12 @@ export function MobileTabBar({
|
|
|
228
230
|
<Share2 className="h-4 w-4" />
|
|
229
231
|
Share…
|
|
230
232
|
</DropdownMenuItem>
|
|
233
|
+
<DropdownMenuItem
|
|
234
|
+
onSelect={() => liveUiActions.open(session.id, session.name)}
|
|
235
|
+
>
|
|
236
|
+
<Users className="h-4 w-4" />
|
|
237
|
+
Invite to watch live…
|
|
238
|
+
</DropdownMenuItem>
|
|
231
239
|
<DropdownMenuItem
|
|
232
240
|
onSelect={() =>
|
|
233
241
|
exportUiActions.open(session.id, session.name)
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Link,
|
|
9
9
|
MoreHorizontal,
|
|
10
10
|
Share2,
|
|
11
|
+
Users,
|
|
11
12
|
Download,
|
|
12
13
|
SplitSquareHorizontal,
|
|
13
14
|
SplitSquareVertical,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
import { copySessionLink, writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
30
31
|
import type { Session } from "@/lib/db";
|
|
31
32
|
import { shareUiActions } from "@/stores/shareUi";
|
|
33
|
+
import { liveUiActions } from "@/stores/liveUi";
|
|
32
34
|
import { exportUiActions } from "@/stores/exportUi";
|
|
33
35
|
import { analystUiActions } from "@/stores/analystUi";
|
|
34
36
|
import { useCanAddAnalyst } from "@/components/Analyst/pair";
|
|
@@ -125,6 +127,14 @@ export function PaneMenu({
|
|
|
125
127
|
Share…
|
|
126
128
|
</DropdownMenuItem>
|
|
127
129
|
)}
|
|
130
|
+
{session.view === "chat" && (
|
|
131
|
+
<DropdownMenuItem
|
|
132
|
+
onSelect={() => liveUiActions.open(session.id, session.name)}
|
|
133
|
+
>
|
|
134
|
+
<Users className="h-4 w-4" />
|
|
135
|
+
Invite to watch live…
|
|
136
|
+
</DropdownMenuItem>
|
|
137
|
+
)}
|
|
128
138
|
{session.view === "chat" && (
|
|
129
139
|
<DropdownMenuItem
|
|
130
140
|
onSelect={() => exportUiActions.open(session.id, session.name)}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
KanbanSquare,
|
|
16
16
|
Link,
|
|
17
17
|
Share2,
|
|
18
|
+
Users,
|
|
18
19
|
Download,
|
|
19
20
|
Loader2,
|
|
20
21
|
Pencil,
|
|
@@ -57,6 +58,7 @@ import { copySessionLink } from "@/hooks/useCopyToClipboard";
|
|
|
57
58
|
import { useLinkedHostNames } from "@/data/hosts";
|
|
58
59
|
import { remoteBlocks, remoteNote } from "@/lib/hosts/remote-menu";
|
|
59
60
|
import { movableSession } from "@/lib/tasks/move-targets";
|
|
61
|
+
import { liveUiActions } from "@/stores/liveUi";
|
|
60
62
|
import { useMoveTargets } from "@/data/tasks/move";
|
|
61
63
|
import {
|
|
62
64
|
rowMenu,
|
|
@@ -200,6 +202,10 @@ export function SessionRowMenu({
|
|
|
200
202
|
title: session.name,
|
|
201
203
|
})
|
|
202
204
|
);
|
|
205
|
+
case "invite-live":
|
|
206
|
+
return item("Invite to watch live…", Users, () =>
|
|
207
|
+
liveUiActions.open(session.id, session.name)
|
|
208
|
+
);
|
|
203
209
|
case "export":
|
|
204
210
|
return item("Export for AI…", Download, () =>
|
|
205
211
|
exportUiActions.open(session.id, session.name)
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useSharesQuery,
|
|
10
10
|
type SharedLinkRow,
|
|
11
11
|
} from "@/data/shares";
|
|
12
|
+
import { useAllLiveShares, useEndLive, type LiveView } from "@/data/live";
|
|
12
13
|
import { cn } from "@/lib/utils";
|
|
13
14
|
import { SessionAnchor } from "@/components/Chat/SessionAnchor";
|
|
14
15
|
|
|
@@ -108,11 +109,61 @@ function Row({ share }: { share: SharedLinkRow }) {
|
|
|
108
109
|
);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
function LiveRow({ share }: { share: LiveView }) {
|
|
113
|
+
const end = useEndLive(share.id);
|
|
114
|
+
const watching = share.guests.filter((g) => g.status === "admitted").length;
|
|
115
|
+
return (
|
|
116
|
+
<li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
|
|
117
|
+
<div className="min-w-0 flex-1">
|
|
118
|
+
<div className="truncate text-sm font-medium">
|
|
119
|
+
<SessionAnchor
|
|
120
|
+
sessionId={share.sessionId}
|
|
121
|
+
className="hover:underline"
|
|
122
|
+
>
|
|
123
|
+
{share.title}
|
|
124
|
+
</SessionAnchor>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
|
|
127
|
+
<span>Live</span>
|
|
128
|
+
<span>{watching} joined</span>
|
|
129
|
+
<span>
|
|
130
|
+
{share.endsWithSession
|
|
131
|
+
? "Until the session ends"
|
|
132
|
+
: share.expiresAt
|
|
133
|
+
? `Ends ${new Date(share.expiresAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
|
|
134
|
+
: ""}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<Button
|
|
139
|
+
variant="outline"
|
|
140
|
+
className="text-destructive min-h-11 shrink-0 sm:min-h-8"
|
|
141
|
+
disabled={end.isPending}
|
|
142
|
+
onClick={() =>
|
|
143
|
+
end.mutate(undefined, {
|
|
144
|
+
onError: (err) => toast.error((err as Error).message),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
End access
|
|
149
|
+
</Button>
|
|
150
|
+
</li>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
111
154
|
// Every snapshot link made from this machine, with views from Connect.
|
|
112
155
|
export function SharedLinksPanel() {
|
|
113
156
|
const { data, isLoading, error } = useSharesQuery();
|
|
157
|
+
const liveNow = useAllLiveShares().data?.shares ?? [];
|
|
114
158
|
return (
|
|
115
159
|
<div className="space-y-3">
|
|
160
|
+
{liveNow.length > 0 && (
|
|
161
|
+
<ul className="space-y-2">
|
|
162
|
+
{liveNow.map((s) => (
|
|
163
|
+
<LiveRow key={s.id} share={s} />
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
)}
|
|
116
167
|
<p className="text-muted-foreground text-sm">
|
|
117
168
|
Read-only snapshots you shared from a chat or a page. Revoking one
|
|
118
169
|
deletes it from Connect straight away.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
|
|
3
|
+
import { liveKeys } from "./keys";
|
|
4
|
+
|
|
5
|
+
export type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
|
|
6
|
+
|
|
7
|
+
async function json<T>(res: Response): Promise<T> {
|
|
8
|
+
const data = await res.json().catch(() => ({}));
|
|
9
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
10
|
+
return data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const send = (url: string, method: string, body?: unknown) =>
|
|
14
|
+
fetch(url, {
|
|
15
|
+
method,
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The session's live link, who is waiting and who is watching. Asked every
|
|
22
|
+
* couple of seconds while a link is live, and once a minute when not, so a
|
|
23
|
+
* request or a suggestion shows up on the host's screen quickly.
|
|
24
|
+
*/
|
|
25
|
+
export function useLiveShare(sessionId: string | undefined, enabled = true) {
|
|
26
|
+
return useQuery({
|
|
27
|
+
queryKey: liveKeys.session(sessionId ?? ""),
|
|
28
|
+
enabled: enabled && !!sessionId,
|
|
29
|
+
queryFn: async (): Promise<LiveResponse> =>
|
|
30
|
+
json(await fetch(`/api/live-shares?sessionId=${sessionId}`)),
|
|
31
|
+
select: (r) => ({
|
|
32
|
+
linked: r.linked,
|
|
33
|
+
share: r.shares[0] as LiveView | undefined,
|
|
34
|
+
}),
|
|
35
|
+
refetchInterval: (q) => (q.state.data?.shares.length ? 2000 : 60_000),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useCreateLive() {
|
|
40
|
+
const qc = useQueryClient();
|
|
41
|
+
return useMutation({
|
|
42
|
+
mutationFn: async (req: { sessionId: string; expiresIn: LiveExpiry }) =>
|
|
43
|
+
json<LiveView>(await send("/api/live-shares", "POST", req)),
|
|
44
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LiveAction =
|
|
49
|
+
| { action: "admit" | "deny" | "kick"; guestId: string }
|
|
50
|
+
| { action: "send"; suggestionId: string; text?: string }
|
|
51
|
+
| { action: "dismiss"; suggestionId: string };
|
|
52
|
+
|
|
53
|
+
export function useLiveAction(shareId: string) {
|
|
54
|
+
const qc = useQueryClient();
|
|
55
|
+
return useMutation({
|
|
56
|
+
mutationFn: async (req: LiveAction) =>
|
|
57
|
+
json<{ ok: true }>(
|
|
58
|
+
await send(`/api/live-shares/${shareId}`, "POST", req)
|
|
59
|
+
),
|
|
60
|
+
onSettled: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useEndLive(shareId: string) {
|
|
65
|
+
const qc = useQueryClient();
|
|
66
|
+
return useMutation({
|
|
67
|
+
mutationFn: async () =>
|
|
68
|
+
json<{ ok: true }>(await send(`/api/live-shares/${shareId}`, "DELETE")),
|
|
69
|
+
onSettled: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every live link this machine holds, for the Shared links page. */
|
|
74
|
+
export function useAllLiveShares() {
|
|
75
|
+
return useQuery({
|
|
76
|
+
queryKey: [...liveKeys.all, "all"] as const,
|
|
77
|
+
queryFn: async (): Promise<LiveResponse> =>
|
|
78
|
+
json(await fetch("/api/live-shares")),
|
|
79
|
+
refetchInterval: 5000,
|
|
80
|
+
});
|
|
81
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -81,7 +81,10 @@ export type ChatOriginKind =
|
|
|
81
81
|
// The reader's own decision, made outside the composer (a plan carried out).
|
|
82
82
|
| "decision"
|
|
83
83
|
// The reader's phone: a shake report or something shared to AgentOS.
|
|
84
|
-
| "device"
|
|
84
|
+
| "device"
|
|
85
|
+
// A suggestion from a guest on a live link, sent by the reader after
|
|
86
|
+
// reading it. Its label is the guest's name.
|
|
87
|
+
| "guest";
|
|
85
88
|
|
|
86
89
|
export interface ChatOrigin {
|
|
87
90
|
kind: ChatOriginKind;
|
package/lib/chat/registry.ts
CHANGED
|
@@ -85,6 +85,8 @@ interface Registry {
|
|
|
85
85
|
caps: Map<string, Capabilities>;
|
|
86
86
|
// Told of every finished turn (lib/analyst), set by the server.
|
|
87
87
|
turnEndHook?: TurnEndHook;
|
|
88
|
+
// Read-only taps on every message of every conversation (lib/live).
|
|
89
|
+
taps?: Set<(sessionId: string, m: ChatServerMessage) => void>;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
export type TurnEndHook = (
|
|
@@ -96,6 +98,13 @@ export function setTurnEndHook(fn: TurnEndHook): void {
|
|
|
96
98
|
registry.turnEndHook = fn;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
export function addTap(
|
|
102
|
+
fn: (sessionId: string, m: ChatServerMessage) => void
|
|
103
|
+
): () => void {
|
|
104
|
+
(registry.taps ??= new Set()).add(fn);
|
|
105
|
+
return () => registry.taps?.delete(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
// Shared across every module instance in the process (custom server and the
|
|
100
109
|
// Next.js route bundles each load their own copy of this file).
|
|
101
110
|
const g = globalThis as unknown as { __agentosChat?: Registry };
|
|
@@ -120,6 +129,7 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
|
120
129
|
touchActive(sessionId);
|
|
121
130
|
if (m.type === "item" && m.item.kind === "turn_end")
|
|
122
131
|
registry.turnEndHook?.(sessionId, m.item);
|
|
132
|
+
registry.taps?.forEach((tap) => tap(sessionId, m));
|
|
123
133
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
124
134
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
125
135
|
// sidebar hears at once.
|
|
@@ -471,3 +471,47 @@ export async function revokeShare(
|
|
|
471
471
|
);
|
|
472
472
|
return shareFrom(await res.json().catch(() => null));
|
|
473
473
|
}
|
|
474
|
+
|
|
475
|
+
export type LiveExpiry = "1h" | "session";
|
|
476
|
+
|
|
477
|
+
export interface LiveShare {
|
|
478
|
+
id: string;
|
|
479
|
+
url: string;
|
|
480
|
+
title: string;
|
|
481
|
+
status: ShareStatus;
|
|
482
|
+
expires_at: string | null;
|
|
483
|
+
ends_with_session: boolean;
|
|
484
|
+
max_guests: number;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Mints a live link for one session; the answer carries the link, once. */
|
|
488
|
+
export async function createLiveShare(
|
|
489
|
+
config: ConnectConfig,
|
|
490
|
+
privateKey: string,
|
|
491
|
+
share: { title: string; expiresIn: LiveExpiry }
|
|
492
|
+
): Promise<LiveShare> {
|
|
493
|
+
const res = await signed(config, privateKey)(
|
|
494
|
+
"POST",
|
|
495
|
+
`/api/v1/machines/${config.machineId}/live-shares`,
|
|
496
|
+
{ title: share.title, expires_in: share.expiresIn }
|
|
497
|
+
);
|
|
498
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
499
|
+
string,
|
|
500
|
+
unknown
|
|
501
|
+
> | null;
|
|
502
|
+
if (
|
|
503
|
+
typeof r?.id !== "string" ||
|
|
504
|
+
typeof r.url !== "string" ||
|
|
505
|
+
!/^https:\/\//.test(r.url)
|
|
506
|
+
)
|
|
507
|
+
throw new Error("the Connect service sent no link");
|
|
508
|
+
return {
|
|
509
|
+
id: r.id,
|
|
510
|
+
url: r.url,
|
|
511
|
+
title: typeof r.title === "string" ? r.title : share.title,
|
|
512
|
+
status: (r.status as ShareStatus) ?? "active",
|
|
513
|
+
expires_at: typeof r.expires_at === "string" ? r.expires_at : null,
|
|
514
|
+
ends_with_session: r.ends_with_session === true,
|
|
515
|
+
max_guests: typeof r.max_guests === "number" ? r.max_guests : 0,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261029T1400_live_shares",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// One row per live link this machine minted. The id is Connect's share id.
|
|
7
|
+
db.exec(
|
|
8
|
+
`CREATE TABLE IF NOT EXISTS live_shares (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
session_id TEXT NOT NULL,
|
|
11
|
+
title TEXT NOT NULL,
|
|
12
|
+
url TEXT NOT NULL,
|
|
13
|
+
ends_with_session INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
max_guests INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
expires_at TEXT,
|
|
17
|
+
ended_at TEXT
|
|
18
|
+
)`
|
|
19
|
+
);
|
|
20
|
+
db.exec(
|
|
21
|
+
`CREATE INDEX IF NOT EXISTS idx_live_shares_session ON live_shares(session_id)`
|
|
22
|
+
);
|
|
23
|
+
// What happened on a share, kept on this machine only: who asked to join,
|
|
24
|
+
// who was let in, and each suggestion with the text actually sent.
|
|
25
|
+
db.exec(
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS live_share_log (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
share_id TEXT NOT NULL,
|
|
29
|
+
at INTEGER NOT NULL,
|
|
30
|
+
kind TEXT NOT NULL,
|
|
31
|
+
guest_id TEXT,
|
|
32
|
+
guest_name TEXT,
|
|
33
|
+
ref TEXT,
|
|
34
|
+
text TEXT,
|
|
35
|
+
sent_text TEXT
|
|
36
|
+
)`
|
|
37
|
+
);
|
|
38
|
+
db.exec(
|
|
39
|
+
`CREATE INDEX IF NOT EXISTS idx_live_share_log_share ON live_share_log(share_id, id)`
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text from a guest is cleaned here again, on this machine, whatever Connect
|
|
3
|
+
* did to it: nothing from the wire is shown on a card or reaches the agent
|
|
4
|
+
* without passing through. Mirrors Connect's Live::Text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const INVISIBLE =
|
|
8
|
+
/[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Zl}\p{Zp}⠀\u{1D159}\u{E0000}-\u{E0FFF}]/gu;
|
|
9
|
+
const SPACES = /\p{Zs}/gu;
|
|
10
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
11
|
+
const CONTROL_KEEPING_NEWLINE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
12
|
+
const MARK_RUN = /\p{M}{3,}/u;
|
|
13
|
+
const SCRIPTS = [
|
|
14
|
+
/\p{Script=Latin}/u,
|
|
15
|
+
/\p{Script=Cyrillic}/u,
|
|
16
|
+
/\p{Script=Greek}/u,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const MAX_NAME = 40;
|
|
20
|
+
export const MAX_SUGGESTION = 2000;
|
|
21
|
+
|
|
22
|
+
const wellFormed = (s: string) => s.isWellFormed();
|
|
23
|
+
|
|
24
|
+
/** A guest's name: one line, 1-40 characters, or null. */
|
|
25
|
+
export function cleanName(input: unknown): string | null {
|
|
26
|
+
if (typeof input !== "string" || !wellFormed(input)) return null;
|
|
27
|
+
if (input.length > MAX_NAME * 8) return null;
|
|
28
|
+
const s = input
|
|
29
|
+
.normalize("NFC")
|
|
30
|
+
.replace(SPACES, " ")
|
|
31
|
+
.replace(INVISIBLE, "")
|
|
32
|
+
.replace(CONTROL, " ")
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
if (!s || [...s].length > MAX_NAME || !/[\p{L}\p{N}]/u.test(s)) return null;
|
|
36
|
+
if (MARK_RUN.test(s) || SCRIPTS.filter((r) => r.test(s)).length > 1)
|
|
37
|
+
return null;
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A block of text (a suggestion): newlines and tabs kept, or null. */
|
|
42
|
+
export function cleanBody(input: unknown, max = MAX_SUGGESTION): string | null {
|
|
43
|
+
if (typeof input !== "string" || !wellFormed(input)) return null;
|
|
44
|
+
if (input.length > max * 8) return null;
|
|
45
|
+
const s = input
|
|
46
|
+
.normalize("NFC")
|
|
47
|
+
.replace(/\r/g, "")
|
|
48
|
+
.replace(SPACES, " ")
|
|
49
|
+
.replace(INVISIBLE, "")
|
|
50
|
+
.replace(CONTROL_KEEPING_NEWLINE, "")
|
|
51
|
+
.trim();
|
|
52
|
+
if (!s || [...s].length > max) return null;
|
|
53
|
+
return s;
|
|
54
|
+
}
|