@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.
Files changed (72) hide show
  1. package/README.md +52 -19
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/tasks/route.test.ts +22 -0
  4. package/app/api/tasks/route.ts +14 -2
  5. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  6. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  7. package/app/page.tsx +8 -3
  8. package/components/Chat/ChatPanel.tsx +96 -8
  9. package/components/Chat/EventRow.tsx +2 -2
  10. package/components/Chat/EventTurns.test.ts +56 -0
  11. package/components/Chat/EventTurns.tsx +141 -0
  12. package/components/Chat/useFind.ts +5 -3
  13. package/components/Chat/useGoToItem.ts +71 -0
  14. package/components/Orchestrator/AsksList.tsx +16 -11
  15. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  16. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  17. package/components/Settings/SettingsDialog.tsx +2 -2
  18. package/components/Settings/SetupSection.tsx +6 -1
  19. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  20. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  21. package/components/Simulator/index.ts +1 -0
  22. package/components/Tasks/TaskRow.tsx +2 -0
  23. package/components/Welcome/DevicesStep.tsx +90 -0
  24. package/components/Welcome/HealthList.tsx +69 -0
  25. package/components/Welcome/ProjectStep.tsx +2 -8
  26. package/components/Welcome/WelcomeDialog.tsx +31 -7
  27. package/data/doctor/index.ts +29 -0
  28. package/data/orchestrators/index.ts +59 -0
  29. package/data/push/topics.ts +2 -0
  30. package/data/simulator/index.test.ts +14 -0
  31. package/data/simulator/index.ts +33 -0
  32. package/data/simulator/keys.ts +5 -0
  33. package/hooks/useNeedsYou.ts +53 -0
  34. package/lib/agent-options.ts +1 -0
  35. package/lib/agents/probe.ts +21 -0
  36. package/lib/agents/setup-commands.ts +4 -0
  37. package/lib/chat/capabilities.ts +7 -1
  38. package/lib/chat/drivers/copilot-approvals.ts +99 -0
  39. package/lib/chat/drivers/copilot-mapper.ts +203 -0
  40. package/lib/chat/drivers/copilot-rpc.ts +126 -0
  41. package/lib/chat/drivers/copilot.ts +174 -0
  42. package/lib/chat/drivers/index.ts +2 -0
  43. package/lib/chat/events.ts +2 -0
  44. package/lib/chat/find.ts +25 -4
  45. package/lib/chat/group.ts +50 -2
  46. package/lib/chat/turns.ts +111 -0
  47. package/lib/db/changes.ts +6 -0
  48. package/lib/db/migrations.ts +43 -0
  49. package/lib/doctor/checks.ts +222 -0
  50. package/lib/orchestrator/ask-view.ts +13 -0
  51. package/lib/orchestrator/brief.ts +5 -1
  52. package/lib/orchestrator/deliver.ts +3 -0
  53. package/lib/orchestrator/events.ts +19 -0
  54. package/lib/orchestrator/needs-you-items.ts +104 -0
  55. package/lib/orchestrator/overview.ts +3 -0
  56. package/lib/orchestrator/questions.ts +120 -0
  57. package/lib/orchestrator/serve.ts +11 -0
  58. package/lib/orchestrator/tool-names.ts +2 -0
  59. package/lib/orchestrator/tool-schemas.ts +12 -0
  60. package/lib/orchestrator/tools.ts +4 -0
  61. package/lib/providers/registry.ts +19 -0
  62. package/lib/providers/resume-ids.ts +30 -0
  63. package/lib/tasks/code-review.ts +5 -2
  64. package/lib/tasks/index.ts +45 -10
  65. package/lib/tasks/queue.ts +7 -2
  66. package/lib/welcome-url.ts +6 -1
  67. package/lib/worktree-hooks.ts +1 -1
  68. package/package.json +1 -1
  69. package/scripts/setup.sh +1 -1
  70. package/stores/needsYouFocus.ts +13 -0
  71. package/stores/welcomeUi.test.ts +15 -0
  72. 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&apos;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", (id) => {
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 aria-label="Setup steps" className="flex items-center gap-1">
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.5 rounded-lg px-2 text-xs font-medium sm:min-h-8",
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("finish")}
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
+ }
@@ -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,5 @@
1
+ export const simulatorKeys = {
2
+ all: ["simulator"] as const,
3
+ status: (sessionId: string) =>
4
+ [...simulatorKeys.all, "status", sessionId] as const,
5
+ };
@@ -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
+ }
@@ -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) =>
@@ -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
  };
@@ -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 = ["claude", "codex", "opencode", "pi"] as const;
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
+ }