@saadnvd1/agent-os 0.3.524 → 0.3.528
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/README.md +52 -19
- package/app/api/doctor/route.ts +8 -0
- package/app/api/tasks/route.test.ts +22 -0
- package/app/api/tasks/route.ts +14 -2
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/agent-options.ts +1 -0
- package/lib/agents/probe.ts +21 -0
- package/lib/agents/setup-commands.ts +4 -0
- package/lib/chat/capabilities.ts +7 -1
- package/lib/chat/drivers/copilot-approvals.ts +99 -0
- package/lib/chat/drivers/copilot-mapper.ts +203 -0
- package/lib/chat/drivers/copilot-rpc.ts +126 -0
- package/lib/chat/drivers/copilot.ts +174 -0
- package/lib/chat/drivers/index.ts +2 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +43 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/providers/registry.ts +19 -0
- package/lib/providers/resume-ids.ts +30 -0
- package/lib/tasks/code-review.ts +5 -2
- package/lib/tasks/index.ts +45 -10
- package/lib/tasks/queue.ts +7 -2
- package/lib/welcome-url.ts +6 -1
- package/lib/worktree-hooks.ts +1 -1
- package/package.json +1 -1
- package/scripts/setup.sh +1 -1
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- package/stores/welcomeUi.ts +6 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Check, RefreshCw, X } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { useDoctorQuery, useRecheckDoctor } from "@/data/doctor";
|
|
6
|
+
import type { CheckStatus } from "@/lib/doctor/checks";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
const ICON = { ok: Check, warn: AlertTriangle, fail: X } as const;
|
|
10
|
+
const TONE: Record<CheckStatus, string> = {
|
|
11
|
+
ok: "text-primary",
|
|
12
|
+
warn: "text-muted-foreground",
|
|
13
|
+
fail: "text-destructive",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** What this machine needs to run AgentOS, one line per tool. */
|
|
17
|
+
export function HealthList() {
|
|
18
|
+
const { data: checks, isPending, isError } = useDoctorQuery();
|
|
19
|
+
const recheck = useRecheckDoctor();
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-col gap-2">
|
|
22
|
+
{isPending && (
|
|
23
|
+
<div className="bg-muted/40 h-24 animate-pulse rounded-lg" />
|
|
24
|
+
)}
|
|
25
|
+
{isError && (
|
|
26
|
+
<p className="text-destructive text-sm">
|
|
27
|
+
Couldn't run the health checks.
|
|
28
|
+
</p>
|
|
29
|
+
)}
|
|
30
|
+
<ul className="flex flex-col gap-2">
|
|
31
|
+
{checks?.map((c) => {
|
|
32
|
+
const Icon = ICON[c.status];
|
|
33
|
+
return (
|
|
34
|
+
<li
|
|
35
|
+
key={c.id}
|
|
36
|
+
className="bg-muted/40 flex min-h-11 items-start gap-3 rounded-lg px-3 py-2"
|
|
37
|
+
>
|
|
38
|
+
<Icon
|
|
39
|
+
aria-label={c.status}
|
|
40
|
+
className={cn("mt-0.5 size-4 shrink-0", TONE[c.status])}
|
|
41
|
+
/>
|
|
42
|
+
<div className="min-w-0 flex-1">
|
|
43
|
+
<p className="text-sm font-medium">{c.label}</p>
|
|
44
|
+
<p className="text-muted-foreground text-xs break-words">
|
|
45
|
+
{c.detail}
|
|
46
|
+
</p>
|
|
47
|
+
{c.fix && c.status !== "ok" && (
|
|
48
|
+
<code className="bg-background mt-1 block rounded px-2 py-1 text-xs break-all">
|
|
49
|
+
{c.fix}
|
|
50
|
+
</code>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
</li>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</ul>
|
|
57
|
+
<Button
|
|
58
|
+
size="sm"
|
|
59
|
+
variant="ghost"
|
|
60
|
+
className="self-end"
|
|
61
|
+
disabled={recheck.isPending}
|
|
62
|
+
onClick={() => recheck.mutate()}
|
|
63
|
+
>
|
|
64
|
+
<RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
|
|
65
|
+
Check again
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -41,10 +41,7 @@ export function ProjectStep() {
|
|
|
41
41
|
const own = projects.filter((p) => !p.is_uncategorized);
|
|
42
42
|
|
|
43
43
|
const add = (kind: AddProjectKind) =>
|
|
44
|
-
addProjectActions.show(kind, "local",
|
|
45
|
-
welcomeUiActions.setProject(id);
|
|
46
|
-
welcomeUiActions.go("finish");
|
|
47
|
-
});
|
|
44
|
+
addProjectActions.show(kind, "local", welcomeUiActions.chooseProject);
|
|
48
45
|
|
|
49
46
|
return (
|
|
50
47
|
<div className="flex flex-col gap-3">
|
|
@@ -54,10 +51,7 @@ export function ProjectStep() {
|
|
|
54
51
|
<li key={p.id}>
|
|
55
52
|
<button
|
|
56
53
|
type="button"
|
|
57
|
-
onClick={() =>
|
|
58
|
-
welcomeUiActions.setProject(p.id);
|
|
59
|
-
welcomeUiActions.go("finish");
|
|
60
|
-
}}
|
|
54
|
+
onClick={() => welcomeUiActions.chooseProject(p.id)}
|
|
61
55
|
className={cn(
|
|
62
56
|
"bg-muted/40 hover:bg-accent flex min-h-11 w-full items-center gap-3 rounded-lg px-3 py-2 text-left",
|
|
63
57
|
projectId === p.id && "ring-primary/40 ring-2"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo } from "react";
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import { Check } from "lucide-react";
|
|
6
6
|
import {
|
|
@@ -16,6 +16,9 @@ import { cn } from "@/lib/utils";
|
|
|
16
16
|
import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
|
|
17
17
|
import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
|
|
18
18
|
import { AgentList } from "./AgentList";
|
|
19
|
+
import type { BrowserNotifications } from "@/components/Settings/NotificationsSection";
|
|
20
|
+
import { HostsDialog } from "@/components/Hosts";
|
|
21
|
+
import { DevicesStep } from "./DevicesStep";
|
|
19
22
|
import { ProjectStep } from "./ProjectStep";
|
|
20
23
|
|
|
21
24
|
const STEPS: Record<
|
|
@@ -32,6 +35,11 @@ const STEPS: Record<
|
|
|
32
35
|
title: "Add your first project",
|
|
33
36
|
hint: "A folder or repo for the first session to work in.",
|
|
34
37
|
},
|
|
38
|
+
devices: {
|
|
39
|
+
label: "Devices",
|
|
40
|
+
title: "Use it from your phone",
|
|
41
|
+
hint: "All optional. Each can be done later from Settings.",
|
|
42
|
+
},
|
|
35
43
|
finish: {
|
|
36
44
|
label: "Start",
|
|
37
45
|
title: "You're set",
|
|
@@ -61,8 +69,14 @@ function FinishStep() {
|
|
|
61
69
|
* First-run setup: agents, then a first project, then a session. It stays
|
|
62
70
|
* open until finished or skipped, and every step after the first can be skipped.
|
|
63
71
|
*/
|
|
64
|
-
export const WelcomeDialog = memo(function WelcomeDialog(
|
|
72
|
+
export const WelcomeDialog = memo(function WelcomeDialog({
|
|
73
|
+
notifications,
|
|
74
|
+
}: {
|
|
75
|
+
notifications: BrowserNotifications;
|
|
76
|
+
}) {
|
|
65
77
|
useWelcomeUrl();
|
|
78
|
+
// Held here so the machines dialog outlives a step change.
|
|
79
|
+
const [hostsOpen, setHostsOpen] = useState(false);
|
|
66
80
|
const { open, step, projectId } = useSnapshot(welcomeUi);
|
|
67
81
|
const end = useEndWelcome();
|
|
68
82
|
const at = WELCOME_STEPS.indexOf(step);
|
|
@@ -77,7 +91,10 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
77
91
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
78
92
|
className="max-h-[100dvh] overflow-y-auto max-sm:h-[100dvh] max-sm:max-w-full max-sm:rounded-none sm:max-h-[90dvh] sm:max-w-xl"
|
|
79
93
|
>
|
|
80
|
-
<nav
|
|
94
|
+
<nav
|
|
95
|
+
aria-label="Setup steps"
|
|
96
|
+
className="flex min-w-0 items-center gap-1"
|
|
97
|
+
>
|
|
81
98
|
{WELCOME_STEPS.map((s, i) => (
|
|
82
99
|
<button
|
|
83
100
|
key={s}
|
|
@@ -86,7 +103,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
86
103
|
onClick={() => welcomeUiActions.go(s)}
|
|
87
104
|
aria-current={i === at ? "step" : undefined}
|
|
88
105
|
className={cn(
|
|
89
|
-
"flex min-h-11 flex-1 items-center justify-center gap-1
|
|
106
|
+
"flex min-h-11 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-1 text-xs font-medium sm:min-h-8 sm:gap-1.5 sm:px-2",
|
|
90
107
|
i === at
|
|
91
108
|
? "bg-foreground/[0.08] text-foreground"
|
|
92
109
|
: "text-muted-foreground hover:bg-foreground/[0.06]"
|
|
@@ -99,7 +116,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
99
116
|
<Button
|
|
100
117
|
variant="ghost"
|
|
101
118
|
size="sm"
|
|
102
|
-
className="min-h-11 sm:min-h-8"
|
|
119
|
+
className="min-h-11 shrink-0 px-2 sm:min-h-8"
|
|
103
120
|
onClick={() => end()}
|
|
104
121
|
>
|
|
105
122
|
Skip setup
|
|
@@ -111,6 +128,12 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
111
128
|
</div>
|
|
112
129
|
{step === "agents" && <AgentList />}
|
|
113
130
|
{step === "project" && <ProjectStep />}
|
|
131
|
+
{step === "devices" && (
|
|
132
|
+
<DevicesStep
|
|
133
|
+
notifications={notifications}
|
|
134
|
+
onLinkMachine={() => setHostsOpen(true)}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
114
137
|
{step === "finish" && <FinishStep />}
|
|
115
138
|
<div className="flex items-center justify-between gap-2">
|
|
116
139
|
<Button
|
|
@@ -122,11 +145,11 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
122
145
|
Back
|
|
123
146
|
</Button>
|
|
124
147
|
<div className="flex items-center gap-2">
|
|
125
|
-
{step === "project" && (
|
|
148
|
+
{(step === "project" || step === "devices") && (
|
|
126
149
|
<Button
|
|
127
150
|
variant="ghost"
|
|
128
151
|
className="min-h-11 sm:min-h-9"
|
|
129
|
-
onClick={() => welcomeUiActions.go(
|
|
152
|
+
onClick={() => welcomeUiActions.go(WELCOME_STEPS[at + 1])}
|
|
130
153
|
>
|
|
131
154
|
Skip
|
|
132
155
|
</Button>
|
|
@@ -148,6 +171,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
148
171
|
</div>
|
|
149
172
|
</div>
|
|
150
173
|
</DialogContent>
|
|
174
|
+
<HostsDialog open={hostsOpen} onClose={() => setHostsOpen(false)} />
|
|
151
175
|
</Dialog>
|
|
152
176
|
);
|
|
153
177
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { DoctorCheck } from "@/lib/doctor/checks";
|
|
3
|
+
|
|
4
|
+
export const doctorKeys = {
|
|
5
|
+
all: ["doctor"] as const,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
async function fetchChecks(fresh: boolean) {
|
|
9
|
+
const res = await fetch(fresh ? "/api/doctor?fresh" : "/api/doctor");
|
|
10
|
+
if (!res.ok) throw new Error("Couldn't run the health checks");
|
|
11
|
+
return ((await res.json()) as { checks: DoctorCheck[] }).checks;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useDoctorQuery() {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: doctorKeys.all,
|
|
17
|
+
queryFn: () => fetchChecks(false),
|
|
18
|
+
staleTime: 30_000,
|
|
19
|
+
refetchOnMount: "always",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useRecheckDoctor() {
|
|
24
|
+
const client = useQueryClient();
|
|
25
|
+
return useMutation({
|
|
26
|
+
mutationFn: () => fetchChecks(true),
|
|
27
|
+
onSuccess: (checks) => client.setQueryData(doctorKeys.all, checks),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -162,3 +162,62 @@ export function usePauseOrchestrator(workspaceId: string) {
|
|
|
162
162
|
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
function patchOverview(
|
|
167
|
+
queryClient: ReturnType<typeof useQueryClient>,
|
|
168
|
+
workspaceId: string,
|
|
169
|
+
change: (o: OrchestratorOverview) => OrchestratorOverview
|
|
170
|
+
) {
|
|
171
|
+
queryClient.setQueryData<OrchestratorOverview[]>(
|
|
172
|
+
orchestratorKeys.all,
|
|
173
|
+
(prev) => prev?.map((o) => (o.workspaceId === workspaceId ? change(o) : o))
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Hiding an item from the Needs-you strip (and showing it again). It never
|
|
178
|
+
// answers an ask or a question; it is shared by his devices.
|
|
179
|
+
export function useDismissNeedsYou(workspaceId: string) {
|
|
180
|
+
const queryClient = useQueryClient();
|
|
181
|
+
const url = `/api/workspaces/${workspaceId}/orchestrator/needs-you/dismiss`;
|
|
182
|
+
return useMutation({
|
|
183
|
+
mutationFn: async ({ key, undo }: { key: string; undo?: boolean }) => {
|
|
184
|
+
if (!undo) return post(url, { key });
|
|
185
|
+
return json(
|
|
186
|
+
await fetch(url, {
|
|
187
|
+
method: "DELETE",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ key }),
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
onMutate: async ({ key, undo }) => {
|
|
194
|
+
await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
|
|
195
|
+
patchOverview(queryClient, workspaceId, (o) => ({
|
|
196
|
+
...o,
|
|
197
|
+
dismissed: undo
|
|
198
|
+
? o.dismissed.filter((k) => k !== key)
|
|
199
|
+
: [...o.dismissed, key],
|
|
200
|
+
}));
|
|
201
|
+
},
|
|
202
|
+
onSettled: () =>
|
|
203
|
+
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Saad answered a question the orchestrator put to him.
|
|
208
|
+
export function useResolveQuestion(workspaceId: string) {
|
|
209
|
+
const queryClient = useQueryClient();
|
|
210
|
+
return useMutation({
|
|
211
|
+
mutationFn: (id: number) =>
|
|
212
|
+
post(`/api/workspaces/${workspaceId}/orchestrator/questions/${id}`, {}),
|
|
213
|
+
onMutate: async (id) => {
|
|
214
|
+
await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
|
|
215
|
+
patchOverview(queryClient, workspaceId, (o) => ({
|
|
216
|
+
...o,
|
|
217
|
+
questions: o.questions.filter((q) => q.id !== id),
|
|
218
|
+
}));
|
|
219
|
+
},
|
|
220
|
+
onSettled: () =>
|
|
221
|
+
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
222
|
+
});
|
|
223
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -55,6 +55,8 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
55
55
|
case "schedule_runs":
|
|
56
56
|
return [scheduleKeys.all];
|
|
57
57
|
case "orchestrator_asks":
|
|
58
|
+
case "orchestrator_questions":
|
|
59
|
+
case "needs_you_dismissed":
|
|
58
60
|
return [orchestratorKeys.all];
|
|
59
61
|
case "orchestrator_rules":
|
|
60
62
|
return [ruleKeys.all];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { simulatorPollInterval } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("simulatorPollInterval", () => {
|
|
5
|
+
it("polls while a lease is coming up or booted", () => {
|
|
6
|
+
for (const s of ["creating", "starting", "ready"] as const)
|
|
7
|
+
expect(simulatorPollInterval(s)).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("does not poll with no lease, an idle-shutdown one, a failed one, or before the first read", () => {
|
|
11
|
+
for (const s of ["none", "shutdown", "failed", undefined] as const)
|
|
12
|
+
expect(simulatorPollInterval(s)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { SimulatorStatus } from "@/lib/simulator";
|
|
3
|
+
import { simulatorKeys } from "./keys";
|
|
4
|
+
|
|
5
|
+
export { simulatorKeys };
|
|
6
|
+
export type { SimulatorStatus };
|
|
7
|
+
|
|
8
|
+
const POLL_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
// A lease being brought up or booted changes under us (idle shutdown, a new
|
|
11
|
+
// screenshot); anything else only changes when the agent opens one, which the
|
|
12
|
+
// next mount picks up.
|
|
13
|
+
export function simulatorPollInterval(
|
|
14
|
+
state: SimulatorStatus["state"] | undefined
|
|
15
|
+
): number | false {
|
|
16
|
+
return state === "creating" || state === "starting" || state === "ready"
|
|
17
|
+
? POLL_MS
|
|
18
|
+
: false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useSimulatorStatus(sessionId: string, enabled = true) {
|
|
22
|
+
return useQuery({
|
|
23
|
+
queryKey: simulatorKeys.status(sessionId),
|
|
24
|
+
queryFn: async (): Promise<SimulatorStatus> => {
|
|
25
|
+
const res = await fetch(`/api/sessions/${sessionId}/simulator`);
|
|
26
|
+
if (!res.ok) throw new Error("Could not read the simulator");
|
|
27
|
+
return res.json();
|
|
28
|
+
},
|
|
29
|
+
enabled,
|
|
30
|
+
staleTime: 30_000,
|
|
31
|
+
refetchInterval: (query) => simulatorPollInterval(query.state.data?.state),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { useSessionsQuery } from "@/data/sessions/queries";
|
|
6
|
+
import { useProjectsQuery } from "@/data/projects/queries";
|
|
7
|
+
import { fetchStatuses } from "@/data/statuses/queries";
|
|
8
|
+
import { statusKeys } from "@/data/sessions/keys";
|
|
9
|
+
import { useOrchestratorOverview } from "@/data/orchestrators";
|
|
10
|
+
import {
|
|
11
|
+
needsYouItems,
|
|
12
|
+
type NeedsYouItem,
|
|
13
|
+
type TaskNeed,
|
|
14
|
+
} from "@/lib/orchestrator/needs-you-items";
|
|
15
|
+
|
|
16
|
+
// Everything waiting on Saad in a workspace: its open asks and questions, the
|
|
17
|
+
// tasks the sidebar marks as needing him, and its chat waiting on an answer.
|
|
18
|
+
export function useNeedsYou(
|
|
19
|
+
workspaceId: string,
|
|
20
|
+
chatWaiting: boolean
|
|
21
|
+
): NeedsYouItem[] {
|
|
22
|
+
const overview = useOrchestratorOverview(workspaceId);
|
|
23
|
+
const { data: sessions } = useSessionsQuery();
|
|
24
|
+
const { data: projects = [] } = useProjectsQuery();
|
|
25
|
+
const { data: statuses } = useQuery({
|
|
26
|
+
queryKey: statusKeys.all,
|
|
27
|
+
queryFn: fetchStatuses,
|
|
28
|
+
staleTime: 2000,
|
|
29
|
+
});
|
|
30
|
+
const tasks = useMemo(() => {
|
|
31
|
+
const members = new Set(
|
|
32
|
+
projects.filter((p) => p.workspace_id === workspaceId).map((p) => p.id)
|
|
33
|
+
);
|
|
34
|
+
return (sessions?.sessions ?? []).flatMap((s): TaskNeed[] => {
|
|
35
|
+
const need = statuses?.statuses[s.id]?.need;
|
|
36
|
+
return !s.role && s.project_id && members.has(s.project_id) && need
|
|
37
|
+
? [{ sessionId: s.id, name: s.name, need }]
|
|
38
|
+
: [];
|
|
39
|
+
});
|
|
40
|
+
}, [sessions, projects, statuses, workspaceId]);
|
|
41
|
+
return useMemo(
|
|
42
|
+
() =>
|
|
43
|
+
needsYouItems({
|
|
44
|
+
asks: overview?.asks ?? [],
|
|
45
|
+
questions: overview?.questions ?? [],
|
|
46
|
+
dismissed: overview?.dismissed ?? [],
|
|
47
|
+
tasks,
|
|
48
|
+
chatWaiting,
|
|
49
|
+
chatKey: `approval:${workspaceId}`,
|
|
50
|
+
}),
|
|
51
|
+
[overview, tasks, chatWaiting, workspaceId]
|
|
52
|
+
);
|
|
53
|
+
}
|
package/lib/agent-options.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
|
|
12
12
|
{ value: "amp", label: "Amp" },
|
|
13
13
|
{ value: "pi", label: "Pi" },
|
|
14
14
|
{ value: "omp", label: "Oh My Pi" },
|
|
15
|
+
{ value: "copilot", label: "GitHub Copilot" },
|
|
15
16
|
];
|
|
16
17
|
|
|
17
18
|
export const agentLabel = (agent: AgentType) =>
|
package/lib/agents/probe.ts
CHANGED
|
@@ -116,6 +116,27 @@ const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
|
|
|
116
116
|
: "needs-login",
|
|
117
117
|
hint: "Run `pi` in a terminal and use /login, or set a provider API key",
|
|
118
118
|
},
|
|
119
|
+
// The CLI also signs in through the GitHub CLI's login, which no file of
|
|
120
|
+
// its own shows: without a trace of a sign-in, it's not known to need one.
|
|
121
|
+
copilot: {
|
|
122
|
+
check: (_cli, env) => {
|
|
123
|
+
if (anyEnv(env, ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]))
|
|
124
|
+
return "ready";
|
|
125
|
+
try {
|
|
126
|
+
const config = fs.readFileSync(
|
|
127
|
+
path.join(
|
|
128
|
+
env.COPILOT_HOME?.trim() || home(".copilot"),
|
|
129
|
+
"config.json"
|
|
130
|
+
),
|
|
131
|
+
"utf8"
|
|
132
|
+
);
|
|
133
|
+
return /logged.?in.?user/i.test(config) ? "ready" : "unknown";
|
|
134
|
+
} catch {
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
hint: "Run `copilot login` in a terminal",
|
|
139
|
+
},
|
|
119
140
|
gemini: {
|
|
120
141
|
check: (_cli, env) =>
|
|
121
142
|
fs.existsSync(home(".gemini", "oauth_creds.json")) ||
|
|
@@ -30,6 +30,10 @@ export const SETUP_COMMANDS: Partial<Record<ProviderId, SetupCommands>> = {
|
|
|
30
30
|
cursor: { signIn: "cursor-agent login", docs: "https://cursor.com/cli" },
|
|
31
31
|
amp: { signIn: "amp login", docs: "https://ampcode.com" },
|
|
32
32
|
pi: { signIn: "pi" },
|
|
33
|
+
copilot: {
|
|
34
|
+
install: "npm install -g @github/copilot",
|
|
35
|
+
signIn: "copilot login",
|
|
36
|
+
},
|
|
33
37
|
// No installer line we can vouch for from here; its own site has it.
|
|
34
38
|
opencode: { docs: "https://opencode.ai/docs" },
|
|
35
39
|
};
|
package/lib/chat/capabilities.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// Agent types that can run as chat. Safe to import from the browser: the
|
|
2
2
|
// drivers themselves (and their SDKs) live in lib/chat/drivers.
|
|
3
|
-
export const CHAT_AGENT_TYPES = [
|
|
3
|
+
export const CHAT_AGENT_TYPES = [
|
|
4
|
+
"claude",
|
|
5
|
+
"codex",
|
|
6
|
+
"opencode",
|
|
7
|
+
"pi",
|
|
8
|
+
"copilot",
|
|
9
|
+
] as const;
|
|
4
10
|
|
|
5
11
|
export function supportsChat(agentType: string | null | undefined): boolean {
|
|
6
12
|
return (
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ChatAccess, DriverEvent, FileDiff } from "../events";
|
|
2
|
+
import { unifiedToDiff } from "../diff";
|
|
3
|
+
import { toolTitle } from "../tools";
|
|
4
|
+
import { PendingApprovals } from "./pending-approvals";
|
|
5
|
+
|
|
6
|
+
type P = Record<string, unknown>;
|
|
7
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
8
|
+
|
|
9
|
+
// What an "edits" access lets through without asking: reading, searching
|
|
10
|
+
// and changing files, never a command or a fetch.
|
|
11
|
+
const EDITS_KINDS = new Set(["read", "search", "edit", "delete", "move"]);
|
|
12
|
+
|
|
13
|
+
const WANT = {
|
|
14
|
+
allow: "allow_once",
|
|
15
|
+
always: "allow_always",
|
|
16
|
+
deny: "reject_once",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
interface Option {
|
|
20
|
+
optionId: string;
|
|
21
|
+
kind: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The kind of tool Copilot asks about, as the chat's tool names.
|
|
25
|
+
export function permissionTool(kind: string): string {
|
|
26
|
+
switch (kind) {
|
|
27
|
+
case "execute":
|
|
28
|
+
return "Bash";
|
|
29
|
+
case "edit":
|
|
30
|
+
case "delete":
|
|
31
|
+
case "move":
|
|
32
|
+
return "Edit";
|
|
33
|
+
case "fetch":
|
|
34
|
+
return "WebFetch";
|
|
35
|
+
case "read":
|
|
36
|
+
return "Read";
|
|
37
|
+
default:
|
|
38
|
+
return "Permissions";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function permissionDiff(input: P): FileDiff | undefined {
|
|
43
|
+
const diff = str(input.diff);
|
|
44
|
+
const file = str(input.fileName);
|
|
45
|
+
if (!diff || !file) return undefined;
|
|
46
|
+
return unifiedToDiff(file, diff, /^create file mode/m.test(diff));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Copilot's permission prompts as approval cards, and the cards' answers as
|
|
50
|
+
// the option it offered for each.
|
|
51
|
+
export class CopilotApprovals {
|
|
52
|
+
readonly pending: PendingApprovals;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
emit: (e: DriverEvent) => void,
|
|
56
|
+
public access: ChatAccess
|
|
57
|
+
) {
|
|
58
|
+
this.pending = new PendingApprovals(emit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The reply to one session/request_permission.
|
|
62
|
+
async handle(p: P): Promise<unknown> {
|
|
63
|
+
const options = (p.options as Option[]) ?? [];
|
|
64
|
+
const call = (p.toolCall ?? {}) as P;
|
|
65
|
+
const kind = str(call.kind);
|
|
66
|
+
const pick = (want: string) =>
|
|
67
|
+
options.find((o) => o.kind === want)?.optionId;
|
|
68
|
+
const selected = (id: string | undefined) =>
|
|
69
|
+
id
|
|
70
|
+
? { outcome: { outcome: "selected", optionId: id } }
|
|
71
|
+
: { outcome: { outcome: "cancelled" } };
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
this.access === "full" ||
|
|
75
|
+
(this.access === "edits" && EDITS_KINDS.has(kind))
|
|
76
|
+
)
|
|
77
|
+
return selected(pick("allow_once"));
|
|
78
|
+
|
|
79
|
+
const input = (call.rawInput ?? {}) as P;
|
|
80
|
+
const name = permissionTool(kind);
|
|
81
|
+
const command = str(input.command);
|
|
82
|
+
const file = str(input.fileName) || str((call.locations as P[])?.[0]?.path);
|
|
83
|
+
const o = await this.pending.ask({
|
|
84
|
+
id: `approval-${str(call.toolCallId)}`,
|
|
85
|
+
toolName: name,
|
|
86
|
+
title:
|
|
87
|
+
toolTitle(name, { command, file_path: file, url: input.url }) ||
|
|
88
|
+
str(call.title) ||
|
|
89
|
+
"Allow this",
|
|
90
|
+
input: command ? { command } : { file, title: str(call.title) },
|
|
91
|
+
diff: permissionDiff(input),
|
|
92
|
+
canAlways: !!pick("allow_always"),
|
|
93
|
+
});
|
|
94
|
+
if (o.decision === "allow" || o.decision === "always")
|
|
95
|
+
return selected(pick(WANT[o.decision]) ?? pick("allow_once"));
|
|
96
|
+
if (o.decision === "deny") return selected(pick("reject_once"));
|
|
97
|
+
return selected(undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|