@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.
- package/app/api/live-shares/[id]/route.ts +19 -4
- package/app/api/live-shares/route.test.ts +23 -2
- package/app/api/live-shares/route.ts +1 -1
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/LivePanel.tsx +18 -1
- package/data/live/queries.ts +3 -1
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +41 -3
- package/lib/live/levels.ts +55 -10
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +88 -13
- package/lib/live/store.ts +34 -1
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +10 -1
- package/package.json +1 -1
|
@@ -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({
|
|
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({
|
|
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
|
|
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
|
-
|
|
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}'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
|
-
{
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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}
|
package/data/live/queries.ts
CHANGED
|
@@ -63,7 +63,9 @@ export function useCreateLive() {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type LiveAction =
|
|
66
|
-
| { action: "admit"
|
|
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 };
|
package/lib/chat/driver.ts
CHANGED
|
@@ -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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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"
|
|
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),
|
package/lib/chat/events.ts
CHANGED
|
@@ -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.
|
package/lib/chat/queued.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 = {
|
|
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
|
}
|