@saadnvd1/agent-os 0.3.778 → 0.3.779

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
+ changeLevel,
6
7
  deny,
7
8
  dismissSuggestion,
8
9
  endLive,
@@ -15,6 +16,7 @@ const Action = z.discriminatedUnion("action", [
15
16
  z.object({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
16
17
  z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
17
18
  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"]) }),
18
20
  z.object({
19
21
  action: z.literal("send"),
20
22
  suggestionId: z.string().min(1).max(64),
@@ -45,6 +47,7 @@ export async function POST(
45
47
  if (a.action === "admit") admit(id, a.guestId);
46
48
  else if (a.action === "deny") deny(id, a.guestId);
47
49
  else if (a.action === "kick") kick(id, a.guestId);
50
+ else if (a.action === "level") changeLevel(id, a.level);
48
51
  else if (a.action === "send")
49
52
  await sendSuggestion(id, a.suggestionId, a.text);
50
53
  else dismissSuggestion(id, a.suggestionId);
@@ -42,4 +42,19 @@ describe("live-shares routes", () => {
42
42
  400
43
43
  );
44
44
  });
45
+
46
+ it("accept watch and suggest only, and history as a boolean", async () => {
47
+ expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
48
+ 400
49
+ );
50
+ expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
51
+ 400
52
+ );
53
+ expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
54
+ 400
55
+ );
56
+ expect(
57
+ (await act(req({ action: "level", level: "drive" }), ctx)).status
58
+ ).toBe(400);
59
+ });
45
60
  });
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { linked, NOT_LINKED } from "@/lib/share/connect";
4
4
  import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
5
- import { createLive, listLive, startLive } from "@/lib/live/service";
5
+ import { createLive, inviteFor, listLive, startLive } from "@/lib/live/service";
6
6
 
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"),
11
+ history: z.boolean().default(true),
10
12
  });
11
13
 
12
14
  // POST /api/live-shares - mint a live link for one chat and start publishing.
@@ -19,7 +21,12 @@ export async function POST(request: Request) {
19
21
  if (!body.success) return fail("sessionId is required.", 400);
20
22
  if (!linked()) return fail(NOT_LINKED, 409);
21
23
  try {
22
- const view = await createLive(body.data.sessionId, body.data.expiresIn);
24
+ const view = await createLive(
25
+ body.data.sessionId,
26
+ body.data.expiresIn,
27
+ body.data.level,
28
+ body.data.history
29
+ );
23
30
  return NextResponse.json(view, { status: 201 });
24
31
  } catch (err) {
25
32
  if ((err as Error).message === "Session not found")
@@ -36,5 +43,9 @@ export async function GET(request: Request) {
36
43
  startLive();
37
44
  const sessionId =
38
45
  new URL(request.url).searchParams.get("sessionId") ?? undefined;
39
- return NextResponse.json({ linked: linked(), shares: listLive(sessionId) });
46
+ return NextResponse.json({
47
+ linked: linked(),
48
+ shares: listLive(sessionId),
49
+ ...(sessionId ? { invite: inviteFor(sessionId) } : {}),
50
+ });
40
51
  }
@@ -19,6 +19,8 @@ import {
19
19
  useEndLive,
20
20
  useLiveShare,
21
21
  type LiveExpiry,
22
+ type LiveInvite,
23
+ type LiveLevel,
22
24
  type LiveView,
23
25
  } from "@/data/live";
24
26
  import { EXPIRY_LABEL } from "@/lib/live/types";
@@ -47,8 +49,8 @@ function InviteBody({ sessionId }: { sessionId: string }) {
47
49
  <DialogTitle>Invite to watch live</DialogTitle>
48
50
  <DialogDescription>
49
51
  A guest opens the link, types a name and waits for you to let them in.
50
- They see this chat as it happens, with secrets masked, and can suggest
51
- a prompt. Nothing they type reaches the agent unless you tap Send.
52
+ They see this chat as it happens, with secrets masked. You choose what
53
+ else they can do.
52
54
  </DialogDescription>
53
55
  </div>
54
56
  {query.isPending ? (
@@ -60,15 +62,26 @@ function InviteBody({ sessionId }: { sessionId: string }) {
60
62
  ) : share ? (
61
63
  <Link share={share} />
62
64
  ) : (
63
- <Create sessionId={sessionId} />
65
+ <Create sessionId={sessionId} invite={query.data.invite} />
64
66
  )}
65
67
  </DialogContent>
66
68
  );
67
69
  }
68
70
 
69
- function Create({ sessionId }: { sessionId: string }) {
71
+ function Create({
72
+ sessionId,
73
+ invite,
74
+ }: {
75
+ sessionId: string;
76
+ invite?: LiveInvite;
77
+ }) {
70
78
  const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
79
+ const [level, setLevel] = useState<LiveLevel>("suggest");
80
+ const [history, setHistory] = useState<boolean | null>(null);
71
81
  const create = useCreateLive();
82
+ const options = invite?.levels ?? [];
83
+ const chosen = options.find((o) => o.value === level);
84
+ const withHistory = history ?? invite?.history.default ?? true;
72
85
  return (
73
86
  <>
74
87
  <div className="flex flex-col gap-1.5">
@@ -77,18 +90,34 @@ function Create({ sessionId }: { sessionId: string }) {
77
90
  </span>
78
91
  <Segmented
79
92
  label="Access"
80
- value="watch"
81
- onChange={() => {}}
82
- options={[
83
- { value: "watch", label: "Watch + suggest" },
84
- { value: "drive", label: "Drive (later)" },
85
- ]}
86
- disabled
93
+ value={level}
94
+ onChange={setLevel}
95
+ options={options.map((o) => ({
96
+ value: o.value,
97
+ label: o.label,
98
+ hint: o.reason ?? o.hint,
99
+ disabled: !o.enabled,
100
+ }))}
87
101
  />
88
102
  <p className="text-muted-foreground text-xs">
89
- Driving the agent from a guest link isn&apos;t available yet.
103
+ {chosen?.hint}
104
+ {options
105
+ .filter((o) => !o.enabled && o.reason)
106
+ .map((o) => ` ${o.label}: ${o.reason}.`)
107
+ .join("")}
90
108
  </p>
91
109
  </div>
110
+ {invite && (
111
+ <label className="flex min-h-11 items-center gap-3 text-sm">
112
+ <input
113
+ type="checkbox"
114
+ className="size-5"
115
+ checked={withHistory}
116
+ onChange={(e) => setHistory(e.target.checked)}
117
+ />
118
+ {invite.history.label}
119
+ </label>
120
+ )}
92
121
  <div className="flex flex-col gap-1.5">
93
122
  <span className="text-muted-foreground text-xs font-medium">
94
123
  The link works for
@@ -109,7 +138,9 @@ function Create({ sessionId }: { sessionId: string }) {
109
138
  <Button
110
139
  className="min-h-11"
111
140
  disabled={create.isPending}
112
- onClick={() => create.mutate({ sessionId, expiresIn })}
141
+ onClick={() =>
142
+ create.mutate({ sessionId, expiresIn, level, history: withHistory })
143
+ }
113
144
  >
114
145
  <Users className="h-4 w-4" />
115
146
  {create.isPending ? "Creating…" : "Create invite link"}
@@ -3,8 +3,9 @@
3
3
  import { useState } from "react";
4
4
  import { ChevronDown, Radio, UserMinus } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { Segmented } from "@/components/ui/segmented";
6
7
  import { StatusPill } from "@/components/ui/status-pill";
7
- import type { LiveView } from "@/data/live";
8
+ import type { LiveLevel, LiveView } from "@/data/live";
8
9
  import { cn } from "@/lib/utils";
9
10
 
10
11
  export function presenceText(guests: LiveView["guests"]): string {
@@ -18,13 +19,15 @@ export function presenceText(guests: LiveView["guests"]): string {
18
19
  export function LiveChip({
19
20
  share,
20
21
  busy,
21
- onKick,
22
+ onAction,
23
+ onLevel,
22
24
  onEnd,
23
25
  onInvite,
24
26
  }: {
25
27
  share: LiveView;
26
28
  busy: boolean;
27
- onKick: (guestId: string) => void;
29
+ onAction: (guestId: string, kind: string) => void;
30
+ onLevel: (level: LiveLevel) => void;
28
31
  onEnd: () => void;
29
32
  onInvite: () => void;
30
33
  }) {
@@ -58,6 +61,21 @@ export function LiveChip({
58
61
  {share.notice && (
59
62
  <p className="text-muted-foreground px-2 text-xs">{share.notice}</p>
60
63
  )}
64
+ <Segmented
65
+ label="Access"
66
+ value={share.level}
67
+ onChange={onLevel}
68
+ disabled={busy}
69
+ options={share.levels.map((o) => ({
70
+ value: o.value,
71
+ label: o.label,
72
+ hint: o.reason ?? o.hint,
73
+ disabled: !o.enabled,
74
+ }))}
75
+ />
76
+ <p className="text-muted-foreground px-2 text-xs">
77
+ {share.levels.find((o) => o.value === share.level)?.hint}
78
+ </p>
61
79
  {admitted.length === 0 && (
62
80
  <p className="text-muted-foreground px-2 py-1 text-xs">
63
81
  No one has joined yet.
@@ -73,16 +91,19 @@ export function LiveChip({
73
91
  aria-hidden
74
92
  />
75
93
  <span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
76
- <Button
77
- variant="ghost"
78
- className="min-h-11"
79
- disabled={busy}
80
- onClick={() => onKick(g.id)}
81
- aria-label={`Kick ${g.name}`}
82
- >
83
- <UserMinus className="h-4 w-4" />
84
- Kick
85
- </Button>
94
+ {g.actions.map((a) => (
95
+ <Button
96
+ key={a.kind}
97
+ variant="ghost"
98
+ className="min-h-11"
99
+ disabled={busy}
100
+ onClick={() => onAction(g.id, a.kind)}
101
+ aria-label={`${a.label} ${g.name}`}
102
+ >
103
+ {a.kind === "kick" && <UserMinus className="h-4 w-4" />}
104
+ {a.label}
105
+ </Button>
106
+ ))}
86
107
  </div>
87
108
  ))}
88
109
  <div className="flex gap-2 pt-1">
@@ -25,14 +25,19 @@ export function LivePanel({
25
25
  if (!share) return null;
26
26
  const busy = act.isPending || end.isPending;
27
27
  const fail = (e: Error) => toast.error(e.message);
28
+ const doAction = (guestId: string, kind: string) => {
29
+ if (kind === "admit" || kind === "deny" || kind === "kick")
30
+ act.mutate({ action: kind, guestId }, { onError: fail });
31
+ };
28
32
  const lobby = share.guests.filter((g) => g.status === "pending");
29
33
  return (
30
34
  <div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 pb-2">
31
35
  <LiveChip
32
36
  share={share}
33
37
  busy={busy}
34
- onKick={(guestId) =>
35
- act.mutate({ action: "kick", guestId }, { onError: fail })
38
+ onAction={doAction}
39
+ onLevel={(level) =>
40
+ act.mutate({ action: "level", level }, { onError: fail })
36
41
  }
37
42
  onEnd={() => end.mutate(undefined, { onError: fail })}
38
43
  onInvite={() => liveUiActions.open(sessionId, title)}
@@ -42,12 +47,7 @@ export function LivePanel({
42
47
  key={g.id}
43
48
  guest={g}
44
49
  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
- }
50
+ onAction={(kind) => doAction(g.id, kind)}
51
51
  />
52
52
  ))}
53
53
  {share.suggestions.map((s) => (
@@ -10,13 +10,11 @@ type Guest = LiveView["guests"][number];
10
10
  export function LobbyCard({
11
11
  guest,
12
12
  busy,
13
- onAdmit,
14
- onDeny,
13
+ onAction,
15
14
  }: {
16
15
  guest: Guest;
17
16
  busy: boolean;
18
- onAdmit: () => void;
19
- onDeny: () => void;
17
+ onAction: (kind: string) => void;
20
18
  }) {
21
19
  return (
22
20
  <div
@@ -41,23 +39,22 @@ export function LobbyCard({
41
39
  </div>
42
40
  </div>
43
41
  <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>
42
+ {guest.actions.map((a, i) => (
43
+ <Button
44
+ key={a.kind}
45
+ variant={i === 0 ? "default" : "outline"}
46
+ className="min-h-11 flex-1 sm:flex-none"
47
+ disabled={busy}
48
+ onClick={() => onAction(a.kind)}
49
+ >
50
+ {a.kind === "admit" ? (
51
+ <Check className="h-4 w-4" />
52
+ ) : a.kind === "deny" ? (
53
+ <X className="h-4 w-4" />
54
+ ) : null}
55
+ {a.label}
56
+ </Button>
57
+ ))}
61
58
  </div>
62
59
  </div>
63
60
  );
@@ -6,6 +6,7 @@ export interface SegmentedOption<T extends string> {
6
6
  value: T;
7
7
  label: string;
8
8
  hint?: string;
9
+ disabled?: boolean;
9
10
  }
10
11
 
11
12
  /** One choice out of a few, side by side. Radio semantics, 44px targets. */
@@ -37,7 +38,7 @@ export function Segmented<T extends string>({
37
38
  role="radio"
38
39
  aria-checked={on}
39
40
  title={o.hint}
40
- disabled={disabled}
41
+ disabled={disabled || o.disabled}
41
42
  onClick={() => !on && onChange(o.value)}
42
43
  className={cn(
43
44
  "min-h-11 rounded-md px-2 text-sm font-medium transition-colors disabled:opacity-50",
@@ -1,8 +1,20 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
- import type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
2
+ import type {
3
+ LiveExpiry,
4
+ LiveInvite,
5
+ LiveLevel,
6
+ LiveResponse,
7
+ LiveView,
8
+ } from "@/lib/live/types";
3
9
  import { liveKeys } from "./keys";
4
10
 
5
- export type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
11
+ export type {
12
+ LiveExpiry,
13
+ LiveInvite,
14
+ LiveLevel,
15
+ LiveResponse,
16
+ LiveView,
17
+ } from "@/lib/live/types";
6
18
 
7
19
  async function json<T>(res: Response): Promise<T> {
8
20
  const data = await res.json().catch(() => ({}));
@@ -31,6 +43,7 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
31
43
  select: (r) => ({
32
44
  linked: r.linked,
33
45
  share: r.shares[0] as LiveView | undefined,
46
+ invite: r.invite as LiveInvite | undefined,
34
47
  }),
35
48
  refetchInterval: (q) => (q.state.data?.shares.length ? 2000 : 60_000),
36
49
  });
@@ -39,14 +52,19 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
39
52
  export function useCreateLive() {
40
53
  const qc = useQueryClient();
41
54
  return useMutation({
42
- mutationFn: async (req: { sessionId: string; expiresIn: LiveExpiry }) =>
43
- json<LiveView>(await send("/api/live-shares", "POST", req)),
55
+ mutationFn: async (req: {
56
+ sessionId: string;
57
+ expiresIn: LiveExpiry;
58
+ level: LiveLevel;
59
+ history: boolean;
60
+ }) => json<LiveView>(await send("/api/live-shares", "POST", req)),
44
61
  onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
45
62
  });
46
63
  }
47
64
 
48
65
  export type LiveAction =
49
66
  | { action: "admit" | "deny" | "kick"; guestId: string }
67
+ | { action: "level"; level: LiveLevel }
50
68
  | { action: "send"; suggestionId: string; text?: string }
51
69
  | { action: "dismiss"; suggestionId: string };
52
70
 
@@ -488,12 +488,16 @@ export interface LiveShare {
488
488
  export async function createLiveShare(
489
489
  config: ConnectConfig,
490
490
  privateKey: string,
491
- share: { title: string; expiresIn: LiveExpiry }
491
+ share: { title: string; expiresIn: LiveExpiry; access?: string }
492
492
  ): Promise<LiveShare> {
493
493
  const res = await signed(config, privateKey)(
494
494
  "POST",
495
495
  `/api/v1/machines/${config.machineId}/live-shares`,
496
- { title: share.title, expires_in: share.expiresIn }
496
+ {
497
+ title: share.title,
498
+ expires_in: share.expiresIn,
499
+ ...(share.access ? { access: share.access } : {}),
500
+ }
497
501
  );
498
502
  const r = (await res.json().catch(() => null)) as Record<
499
503
  string,
@@ -0,0 +1,19 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1200_live_share_level",
5
+ up: (db) => {
6
+ const cols = (
7
+ db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
8
+ ).map((c) => c.name);
9
+ // Existing links keep what they always did: suggest, with the whole chat.
10
+ if (!cols.includes("level"))
11
+ db.exec(
12
+ `ALTER TABLE live_shares ADD COLUMN level TEXT NOT NULL DEFAULT 'suggest'`
13
+ );
14
+ if (!cols.includes("history"))
15
+ db.exec(
16
+ `ALTER TABLE live_shares ADD COLUMN history INTEGER NOT NULL DEFAULT 1`
17
+ );
18
+ },
19
+ } satisfies Migration;
package/lib/live/inbox.ts CHANGED
@@ -27,6 +27,7 @@ export const DOWNWARD = [
27
27
  "slow_down",
28
28
  "invalid",
29
29
  "admit_failed",
30
+ "access_failed",
30
31
  ] as const;
31
32
 
32
33
  const id = z.string().min(1).max(64);
@@ -44,6 +45,7 @@ const SCHEMAS = {
44
45
  slow_down: z.object({}),
45
46
  invalid: z.object({}),
46
47
  admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
48
+ access_failed: z.object({ reason: z.string().max(200).optional() }),
47
49
  } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
48
50
 
49
51
  export interface InboxEffects {
@@ -143,6 +145,9 @@ export function handleFrame(
143
145
  }
144
146
  case "suggestion": {
145
147
  const s = parsed.data as z.infer<typeof SCHEMAS.suggestion>;
148
+ // Watch guests can't suggest. Connect refuses first; this is the machine's own check.
149
+ if (rt.share.level === "watch")
150
+ return (drop("suggestion on a watch-only share"), false);
146
151
  const known = rt.guests.get(s.guest.id);
147
152
  // Only a guest this machine let in can leave a card.
148
153
  if (!known || known.status !== "admitted")
@@ -196,6 +201,9 @@ export function handleFrame(
196
201
  rt.notice = "Your plan's guest limit is reached.";
197
202
  return true;
198
203
  }
204
+ case "access_failed":
205
+ rt.notice = "Connect refused that access level.";
206
+ return true;
199
207
  case "invalid":
200
208
  rt.drops++;
201
209
  return true;
@@ -0,0 +1,29 @@
1
+ import {
2
+ LEVELS,
3
+ type GuestAction,
4
+ type LevelOption,
5
+ type LiveLevel,
6
+ } from "./types";
7
+ import type { Guest, Runtime } from "./runtime";
8
+
9
+ const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
10
+
11
+ /** What the invite dialog and the Live popover offer, with why one is off. */
12
+ export function levelOptions(): LevelOption[] {
13
+ return ORDER.map((value) => ({
14
+ value,
15
+ ...LEVELS[value],
16
+ ...(value === "drive"
17
+ ? { enabled: false, reason: "Coming soon" }
18
+ : { enabled: true }),
19
+ }));
20
+ }
21
+
22
+ export function guestActions(_rt: Runtime, g: Guest): GuestAction[] {
23
+ return g.status === "pending"
24
+ ? [
25
+ { kind: "admit", label: "Admit" },
26
+ { kind: "deny", label: "Deny" },
27
+ ]
28
+ : [{ kind: "kick", label: "Kick" }];
29
+ }
@@ -13,8 +13,17 @@ import { live, type Runtime } from "./runtime";
13
13
  const FLUSH_MS = 1000;
14
14
  const SLOW_FLUSH_MS = 4000;
15
15
 
16
+ /** With "earlier messages" off, nothing from before the link was made goes out. */
17
+ function beforeLink(rt: Runtime, item: ChatItem): boolean {
18
+ return (
19
+ rt.share.history === 0 &&
20
+ (item.createdAt ?? Infinity) < Date.parse(rt.share.created_at)
21
+ );
22
+ }
23
+
16
24
  export function seed(rt: Runtime, sessionId: string): void {
17
25
  for (const item of visible(listItems(sessionId))) {
26
+ if (beforeLink(rt, item)) continue;
18
27
  const frame = rt.map(item);
19
28
  if (frame) rt.ring.upsert(frame);
20
29
  }
@@ -55,6 +64,7 @@ export function onMessage(sessionId: string, m: ChatServerMessage): void {
55
64
  live.socket?.send(rt.share.id, "state", { state: stateOf(m.state) });
56
65
  } else if (m.type === "item") {
57
66
  const item = m.item;
67
+ if (beforeLink(rt, item)) continue;
58
68
  if (item.kind === "turn_end") {
59
69
  flush(rt);
60
70
  live.socket?.send(rt.share.id, "state", { state: "idle" });
@@ -11,12 +11,20 @@ import { shareContext } from "../share/context";
11
11
  import { redactForShare } from "../share/redact";
12
12
  import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
13
13
  import type { LiveExpiry } from "../connect/control-api";
14
- import type { LiveView } from "./types";
14
+ import {
15
+ LEVELS,
16
+ type LiveInvite,
17
+ type LiveLevel,
18
+ type LiveView,
19
+ } from "./types";
20
+ import { guestActions, levelOptions } from "./levels";
21
+ import { listItems } from "../chat/store";
22
+ import { visible } from "../share/snapshot";
15
23
  import { cleanBody } from "./clean";
16
24
  import { itemMapper } from "./frames";
17
25
  import { handleFrame } from "./inbox";
18
26
  import { onMessage, seed, flush } from "./publisher";
19
- import { Ring } from "./ring";
27
+ import { RING_ITEMS, Ring } from "./ring";
20
28
  import { LOBBY_MS, live, type Guest, type Runtime } from "./runtime";
21
29
  import { LiveSocket, type WsFactory } from "./socket";
22
30
  import {
@@ -27,6 +35,7 @@ import {
27
35
  logLive,
28
36
  markLiveEnded,
29
37
  openSuggestions,
38
+ setLevel,
30
39
  type LiveShareRow,
31
40
  } from "./store";
32
41
 
@@ -126,7 +135,9 @@ function runtimeFor(id: string): Runtime {
126
135
 
127
136
  export async function createLive(
128
137
  sessionId: string,
129
- expiresIn: LiveExpiry
138
+ expiresIn: LiveExpiry,
139
+ level: LiveLevel = "suggest",
140
+ history = true
130
141
  ): Promise<LiveView> {
131
142
  startLive();
132
143
  const session = getSession(sessionId);
@@ -138,7 +149,7 @@ export async function createLive(
138
149
  .text.replace(/\s+/g, " ")
139
150
  .trim()
140
151
  .slice(0, 120) || "Chat";
141
- const made = await createLiveRemote({ title, expiresIn });
152
+ const made = await createLiveRemote({ title, expiresIn, access: level });
142
153
  const row: LiveShareRow = {
143
154
  id: made.id,
144
155
  session_id: sessionId,
@@ -149,12 +160,45 @@ export async function createLive(
149
160
  created_at: new Date().toISOString(),
150
161
  expires_at: made.expires_at,
151
162
  ended_at: null,
163
+ level,
164
+ history: history ? 1 : 0,
152
165
  };
153
166
  insertLiveShare(row);
154
167
  attach(row);
155
168
  return viewOf(row.id)!;
156
169
  }
157
170
 
171
+ export function changeLevel(id: string, level: LiveLevel): void {
172
+ const rt = runtimeFor(id);
173
+ if (!(level in LEVELS)) throw new ShareError("Unknown level.", 400);
174
+ if (!levelOptions().find((o) => o.value === level)?.enabled)
175
+ throw new ShareError(`${LEVELS[level].label} isn't available.`, 400);
176
+ if (rt.share.level === level) return;
177
+ // Synchronous and first: from here the machine treats the share as `level`.
178
+ rt.share = { ...rt.share, level };
179
+ setLevel(id, level);
180
+ rt.notice = null;
181
+ live.socket?.send(id, "access", { level });
182
+ }
183
+
184
+ /** What the invite dialog offers for a session. */
185
+ export function inviteFor(sessionId: string): LiveInvite {
186
+ const count = Math.min(
187
+ visible(listItems(sessionId)).filter(
188
+ (i) => i.kind === "user" || i.kind === "assistant"
189
+ ).length,
190
+ RING_ITEMS
191
+ );
192
+ return {
193
+ levels: levelOptions(),
194
+ history: {
195
+ label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
196
+ default: true,
197
+ count,
198
+ },
199
+ };
200
+ }
201
+
158
202
  /** Ends access: Connect disconnects every guest, then this machine lets go. */
159
203
  export async function endLive(id: string): Promise<void> {
160
204
  runtimeFor(id);
@@ -293,6 +337,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
293
337
  maxGuests: rt.share.max_guests,
294
338
  connected: live.socket?.connected ?? false,
295
339
  notice: rt.notice,
340
+ level: rt.share.level,
341
+ levels: levelOptions(),
342
+ history: rt.share.history === 1,
296
343
  guests: shown
297
344
  .filter((g) => g.status === "pending" || admitted.has(g.id))
298
345
  .map((g) => ({
@@ -302,6 +349,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
302
349
  online: g.online,
303
350
  duplicateName:
304
351
  (names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
352
+ actions: guestActions(rt, g),
305
353
  })),
306
354
  suggestions: openSuggestions(id),
307
355
  };
package/lib/live/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { db } from "../db";
2
+ import type { LiveLevel } from "./types";
2
3
 
3
4
  export interface LiveShareRow {
4
5
  id: string;
@@ -10,6 +11,9 @@ export interface LiveShareRow {
10
11
  created_at: string;
11
12
  expires_at: string | null;
12
13
  ended_at: string | null;
14
+ level: LiveLevel;
15
+ /** 1: guests get the chat from before the link was made. */
16
+ history: number;
13
17
  }
14
18
 
15
19
  export type LogKind =
@@ -19,7 +23,8 @@ export type LogKind =
19
23
  | "kick"
20
24
  | "suggestion"
21
25
  | "sent"
22
- | "dismissed";
26
+ | "dismissed"
27
+ | "level";
23
28
 
24
29
  export interface LogRow {
25
30
  id: number;
@@ -36,8 +41,8 @@ export interface LogRow {
36
41
  export function insertLiveShare(row: LiveShareRow): void {
37
42
  db.prepare(
38
43
  `INSERT INTO live_shares
39
- (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
40
- VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
44
+ (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
45
+ VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
41
46
  ).run(row);
42
47
  }
43
48
 
@@ -70,6 +75,19 @@ export function markLiveEnded(id: string): void {
70
75
  ).run(new Date().toISOString(), id);
71
76
  }
72
77
 
78
+ export function setLevel(id: string, level: LiveLevel): void {
79
+ db.prepare(`UPDATE live_shares SET level = ? WHERE id = ?`).run(level, id);
80
+ logLive({
81
+ share_id: id,
82
+ kind: "level",
83
+ guest_id: null,
84
+ guest_name: null,
85
+ ref: level,
86
+ text: null,
87
+ sent_text: null,
88
+ });
89
+ }
90
+
73
91
  export function logLive(
74
92
  row: Omit<LogRow, "id" | "at"> & { at?: number }
75
93
  ): void {
package/lib/live/types.ts CHANGED
@@ -1,3 +1,34 @@
1
+ export type LiveLevel = "watch" | "suggest" | "drive";
2
+
3
+ /** The single source for what each level is called and what it lets a guest do. */
4
+ export const LEVELS: Record<LiveLevel, { label: string; hint: string }> = {
5
+ watch: {
6
+ label: "Watch",
7
+ hint: "Guests only watch. They can't send you anything.",
8
+ },
9
+ suggest: {
10
+ label: "Suggest",
11
+ hint: "Guests can suggest a prompt. Nothing reaches the agent unless you tap Send.",
12
+ },
13
+ drive: {
14
+ label: "Drive",
15
+ hint: "Guests can make the agent read this task's files. Everything else waits for you.",
16
+ },
17
+ };
18
+
19
+ export interface LevelOption {
20
+ value: LiveLevel;
21
+ label: string;
22
+ hint: string;
23
+ enabled: boolean;
24
+ reason?: string;
25
+ }
26
+
27
+ export interface GuestAction {
28
+ kind: "kick" | "admit" | "deny";
29
+ label: string;
30
+ }
31
+
1
32
  // What the host's screens show of a live share (served by /api/live-shares).
2
33
  export interface LiveView {
3
34
  id: string;
@@ -9,6 +40,10 @@ export interface LiveView {
9
40
  maxGuests: number;
10
41
  connected: boolean;
11
42
  notice: string | null;
43
+ level: LiveLevel;
44
+ levels: LevelOption[];
45
+ /** Whether the guests got the chat from before the link was made. */
46
+ history: boolean;
12
47
  guests: {
13
48
  id: string;
14
49
  name: string;
@@ -16,6 +51,7 @@ export interface LiveView {
16
51
  online: boolean;
17
52
  /** Another guest here has the same name. */
18
53
  duplicateName: boolean;
54
+ actions: GuestAction[];
19
55
  }[];
20
56
  suggestions: {
21
57
  id: string;
@@ -33,7 +69,14 @@ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
33
69
  session: "Until the session ends",
34
70
  };
35
71
 
72
+ export interface LiveInvite {
73
+ levels: LevelOption[];
74
+ history: { label: string; default: boolean; count: number };
75
+ }
76
+
36
77
  export interface LiveResponse {
37
78
  linked: boolean;
38
79
  shares: LiveView[];
80
+ /** Only when the request named a session. */
81
+ invite?: LiveInvite;
39
82
  }
@@ -111,6 +111,7 @@ export async function revokeRemote(id: string): Promise<void> {
111
111
  export async function createLiveRemote(share: {
112
112
  title: string;
113
113
  expiresIn: LiveExpiry;
114
+ access?: string;
114
115
  }) {
115
116
  const { config, machineKey } = files();
116
117
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.778",
3
+ "version": "0.3.779",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"