@saadnvd1/agent-os 0.3.527 → 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 CHANGED
@@ -126,7 +126,8 @@ You need Node.js 20+, tmux, and at least one agent CLI:
126
126
  Pi,
127
127
  [Kilo Code CLI](https://kilo.ai/docs/cli),
128
128
  [Gemini CLI](https://github.com/google-gemini/gemini-cli),
129
- [Aider](https://aider.chat/), [Cursor CLI](https://cursor.com/cli) or Amp.
129
+ [Aider](https://aider.chat/), [Cursor CLI](https://cursor.com/cli), Amp or
130
+ [GitHub Copilot CLI](https://github.com/github/copilot-cli).
130
131
 
131
132
  ```bash
132
133
  npm install -g @saadnvd1/agent-os
@@ -229,18 +230,19 @@ CLI) with the fix for anything missing (`/api/doctor`).
229
230
 
230
231
  ### Supported agents
231
232
 
232
- | Agent | Chat | Resume | Fork | Auto-approve |
233
- | ----------- | ---- | ------ | ---- | -------------------------------------------- |
234
- | Claude Code | ✅ | ✅ | ✅ | `--dangerously-skip-permissions` |
235
- | Codex | ✅ | ✅ | ✅ | `--dangerously-bypass-approvals-and-sandbox` |
236
- | OpenCode | ✅ | ✅ | ✅ | `--auto` |
237
- | Pi | ✅ | ✅ | ✅ | Never asks |
238
- | Kilo Code | ❌ | ✅ | ✅ | Config file |
239
- | Gemini CLI | ❌ | ❌ | ❌ | `--yolo` |
240
- | Aider | ❌ | ❌ | ❌ | `--yes` |
241
- | Cursor CLI | ❌ | ✅ | ❌ | `--force` |
242
- | Amp | ❌ | ✅ | ❌ | `--dangerously-allow-all` |
243
- | Oh My Pi | ❌ | ❌ | ❌ | N/A |
233
+ | Agent | Chat | Resume | Fork | Auto-approve |
234
+ | -------------- | ---- | ------ | ---- | -------------------------------------------- |
235
+ | Claude Code | ✅ | ✅ | ✅ | `--dangerously-skip-permissions` |
236
+ | Codex | ✅ | ✅ | ✅ | `--dangerously-bypass-approvals-and-sandbox` |
237
+ | OpenCode | ✅ | ✅ | ✅ | `--auto` |
238
+ | Pi | ✅ | ✅ | ✅ | Never asks |
239
+ | Kilo Code | ❌ | ✅ | ✅ | Config file |
240
+ | Gemini CLI | ❌ | ❌ | ❌ | `--yolo` |
241
+ | Aider | ❌ | ❌ | ❌ | `--yes` |
242
+ | Cursor CLI | ❌ | ✅ | ❌ | `--force` |
243
+ | Amp | ❌ | ✅ | ❌ | `--dangerously-allow-all` |
244
+ | Oh My Pi | ❌ | ❌ | ❌ | N/A |
245
+ | GitHub Copilot | ✅ | ✅ | ❌ | `--allow-all` |
244
246
 
245
247
  Agents without chat run in the terminal. The new-session picker says when an
246
248
  agent isn't installed or needs a sign-in first, and what to run.
@@ -275,10 +277,16 @@ agent as the terminal, with your own logins: Claude Code through the Agent
275
277
  SDK, Codex through `codex app-server`, OpenCode through its own `opencode
276
278
  serve` (loopback only, behind a per-conversation password) and Pi through its
277
279
  RPC mode. Pi never asks before a tool, so AgentOS loads a small Pi extension
278
- that asks the chat as the access setting says. The **Chat /
280
+ that asks the chat as the access setting says. GitHub Copilot runs through
281
+ `copilot --acp` (the Agent Client Protocol): its permission prompts become
282
+ approval cards under the same access setting, and a conversation resumes with
283
+ `session/load` after a restart. It signs in with `copilot login` or a
284
+ `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`; it has no plan mode,
285
+ undo or in-chat model list yet, so it runs on its own default model unless one
286
+ is named. The **Chat /
279
287
  Terminal** switch in the tab bar hands the same conversation between the two:
280
288
  the terminal resumes it (`claude --resume`, `codex resume`, `opencode
281
- --session`, `pi --session`), and switching back closes the
289
+ --session`, `pi --session`, `copilot --resume`), and switching back closes the
282
290
  terminal so only one side drives it. Each chat conversation runs in its own
283
291
  worker process (in tmux, like terminal sessions), so restarting or updating
284
292
  AgentOS never cuts off a turn: the server reconnects to running workers when
@@ -491,7 +499,10 @@ orchestrator's `start_task` with `after`) holds a task until a named task
491
499
  finishes, or until any task still working now does (a running task whose PR
492
500
  was closed or merged doesn't count; with none, it starts at once). The
493
501
  orchestrator's `drop` removes a queued task by its id. Pause stops automatic starts,
494
- and the orchestrator's brakes still apply when its queued tasks start. The
502
+ and the orchestrator's brakes still apply when its queued tasks start. A task runs on Claude Code unless
503
+ `POST /api/tasks` names another `agent` (`"copilot"`): that one runs as a chat on
504
+ this machine, with no plan-first step or role models, and is never picked for
505
+ you. The
495
506
  line is kept in the database across restarts (`GET /api/tasks/queue`; start
496
507
  now, move up or down, or remove with `/api/tasks/queue/<id>`). The sidebar
497
508
  shows them under **Queued** with their place in line or what they wait on,
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { POST } from "./route";
4
+
5
+ const post = (body: object) =>
6
+ POST(
7
+ new NextRequest("http://localhost/api/tasks", {
8
+ method: "POST",
9
+ body: JSON.stringify({ projectId: "none", prompt: "x", ...body }),
10
+ })
11
+ );
12
+
13
+ describe("POST /api/tasks with another agent's model", () => {
14
+ it.each(["--allow-all-tools", "-m", "a b", "x\ny", "m".repeat(101), 7])(
15
+ "refuses %j before anything is queued",
16
+ async (model) => {
17
+ const res = await post({ agent: "copilot", model });
18
+ expect(res.status).toBe(400);
19
+ expect((await res.json()).error).toBe("Bad model name");
20
+ }
21
+ );
22
+ });
@@ -6,6 +6,8 @@ import { TASK_CAPABILITIES } from "@/lib/tasks/remote";
6
6
  import { ensureProject, type ProjectRef } from "@/lib/tasks/project-ref";
7
7
  import { isSessionId, statusFor } from "@/lib/tasks/move-bundle";
8
8
  import { startView } from "@/lib/sessions/launch";
9
+ import { isValidAgentType } from "@/lib/providers";
10
+ import { isAgentModelName } from "@/lib/providers/registry";
9
11
 
10
12
  // moved and capabilities are for another machine that mirrors these tasks.
11
13
  export async function GET() {
@@ -34,6 +36,7 @@ export async function POST(request: NextRequest) {
34
36
  baseBranch,
35
37
  after,
36
38
  view,
39
+ agent,
37
40
  plan,
38
41
  plan_reason,
39
42
  plan_only,
@@ -43,11 +46,20 @@ export async function POST(request: NextRequest) {
43
46
  project && typeof project === "object"
44
47
  ? (await ensureProject(project as ProjectRef)).id
45
48
  : projectId;
49
+ if (agent !== undefined && !isValidAgentType(agent))
50
+ throw new Error("Unknown agent");
51
+ if (agent && agent !== "claude" && model != null && model !== "")
52
+ if (!isAgentModelName(model)) throw new Error("Bad model name");
46
53
  const opts = {
47
54
  projectId: pid,
48
55
  prompt: String(prompt ?? ""),
49
- // Checked now, so a queued task can't fail on it later.
50
- model: parseOverride(model) ?? undefined,
56
+ agent: agent && agent !== "claude" ? agent : undefined,
57
+ // Checked now, so a queued task can't fail on it later. Another
58
+ // agent's model is its own name for one.
59
+ model:
60
+ agent && agent !== "claude" && typeof model === "string"
61
+ ? model
62
+ : (parseOverride(model) ?? undefined),
51
63
  plan: typeof plan === "boolean" ? plan : undefined,
52
64
  planOnly: plan_only === true ? true : undefined,
53
65
  planReason:
@@ -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
+ }
@@ -0,0 +1,203 @@
1
+ import type { ChatContext, DriverEvent, UsageTotals } from "../events";
2
+ import { toolTitle } from "../tools";
3
+ import { TurnItems, type ToolStart } from "./turn-items";
4
+
5
+ type P = Record<string, unknown>;
6
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
7
+
8
+ const TODO_STATUS = {
9
+ pending: "pending",
10
+ in_progress: "in_progress",
11
+ completed: "completed",
12
+ } as const;
13
+
14
+ // "*** Update File: f.txt" in an apply_patch the agent made.
15
+ const patchFile = (patch: string) =>
16
+ patch.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/m)?.[1]?.trim();
17
+
18
+ export function toolStart(update: P): ToolStart {
19
+ const raw = update.rawInput;
20
+ const input = (typeof raw === "object" && raw ? raw : {}) as P;
21
+ const file =
22
+ str(input.path) ||
23
+ str(input.fileName) ||
24
+ str((update.locations as P[] | undefined)?.[0]?.path) ||
25
+ (typeof raw === "string" ? patchFile(raw) : undefined) ||
26
+ "";
27
+ const title = str(update.title);
28
+ switch (str(update.kind)) {
29
+ case "execute": {
30
+ const command = str(input.command);
31
+ return {
32
+ name: "Bash",
33
+ title: toolTitle("Bash", { command, description: input.description }),
34
+ input: { command, description: input.description },
35
+ };
36
+ }
37
+ case "edit":
38
+ case "delete":
39
+ case "move":
40
+ return {
41
+ name: "Edit",
42
+ title: file ? toolTitle("Edit", { file_path: file }) : title,
43
+ input: { file_path: file },
44
+ };
45
+ case "read":
46
+ if (input.pattern)
47
+ return {
48
+ name: "Glob",
49
+ title: toolTitle("Glob", { pattern: input.pattern }),
50
+ input,
51
+ };
52
+ return {
53
+ name: "Read",
54
+ title: toolTitle("Read", { file_path: file }),
55
+ input: { file_path: file },
56
+ };
57
+ case "search":
58
+ return {
59
+ name: "Grep",
60
+ title: toolTitle("Grep", { pattern: input.pattern ?? input.query }),
61
+ input,
62
+ };
63
+ case "fetch":
64
+ return {
65
+ name: "WebFetch",
66
+ title: toolTitle("WebFetch", { url: input.url }),
67
+ input,
68
+ };
69
+ default:
70
+ return { name: title || "Tool", title: title || "Tool", input: raw };
71
+ }
72
+ }
73
+
74
+ function contentText(update: P): string {
75
+ const parts = (update.content as P[] | undefined) ?? [];
76
+ const text = parts
77
+ .map((c) => (c.type === "content" ? str((c.content as P)?.text) : ""))
78
+ .filter(Boolean)
79
+ .join("\n");
80
+ if (text) return text;
81
+ const raw = (update.rawOutput ?? {}) as P;
82
+ return str(raw.content);
83
+ }
84
+
85
+ // Copilot's session updates as chat items. One per conversation.
86
+ export class CopilotMapper {
87
+ readonly items: TurnItems;
88
+ readonly totals: UsageTotals = {
89
+ costUsd: 0,
90
+ inputTokens: 0,
91
+ outputTokens: 0,
92
+ cacheReadTokens: 0,
93
+ cacheWriteTokens: 0,
94
+ };
95
+ // Text before and after a tool call are separate bubbles.
96
+ private block = 0;
97
+ // A resumed conversation replays its history before it's ready: not ours.
98
+ replaying = false;
99
+
100
+ constructor(private emit: (e: DriverEvent) => void) {
101
+ this.items = new TurnItems(emit);
102
+ }
103
+
104
+ map(update: P): void {
105
+ if (this.replaying) return;
106
+ const text = str((update.content as P | undefined)?.text);
107
+ switch (str(update.sessionUpdate)) {
108
+ case "agent_message_chunk":
109
+ this.items.startTurn();
110
+ return this.items.delta(`m${this.block}`, "assistant", text);
111
+ case "agent_thought_chunk":
112
+ this.items.startTurn();
113
+ return this.items.delta(`r${this.block}`, "reasoning", text);
114
+ case "tool_call":
115
+ return this.started(update);
116
+ case "tool_call_update":
117
+ return this.updated(update);
118
+ case "plan":
119
+ return this.items.todos(
120
+ ((update.entries as P[]) ?? []).map((e) => ({
121
+ text: str(e.content),
122
+ status:
123
+ TODO_STATUS[str(e.status) as keyof typeof TODO_STATUS] ??
124
+ "pending",
125
+ }))
126
+ );
127
+ case "usage_update":
128
+ return this.context(update);
129
+ }
130
+ }
131
+
132
+ private close(): void {
133
+ this.items.finishText(`m${this.block}`, "assistant");
134
+ this.items.finishText(`r${this.block}`, "reasoning");
135
+ this.block++;
136
+ }
137
+
138
+ private started(update: P): void {
139
+ this.items.startTurn();
140
+ this.close();
141
+ this.items.tool(str(update.toolCallId), toolStart(update));
142
+ }
143
+
144
+ private updated(update: P): void {
145
+ const id = str(update.toolCallId);
146
+ const status = str(update.status);
147
+ if (status !== "completed" && status !== "failed") return;
148
+ const start = toolStart(update);
149
+ if (!this.items.hasTool(id)) this.items.tool(id, start);
150
+ const diff = ((update.content as P[]) ?? []).find((c) => c.type === "diff");
151
+ const changed = diff
152
+ ? {
153
+ path: str(diff.path),
154
+ before: str(diff.oldText),
155
+ after: str(diff.newText),
156
+ }
157
+ : undefined;
158
+ this.items.toolDone(
159
+ id,
160
+ status === "failed" ? "error" : "done",
161
+ contentText(update),
162
+ changed
163
+ ? {
164
+ name: "Edit",
165
+ title: toolTitle("Edit", { file_path: changed.path }),
166
+ diff: changed,
167
+ }
168
+ : undefined
169
+ );
170
+ }
171
+
172
+ private context(update: P): void {
173
+ const used = Number(update.used);
174
+ const max = Number(update.size);
175
+ if (!max || !Number.isFinite(used)) return;
176
+ const context: ChatContext = {
177
+ usedTokens: used,
178
+ maxTokens: max,
179
+ percentage: Math.round((used / max) * 1000) / 10,
180
+ categories: [],
181
+ at: Date.now(),
182
+ };
183
+ this.emit({ type: "context", context });
184
+ }
185
+
186
+ // What a finished prompt spent. The CLI counts cached tokens in its input.
187
+ spent(usage: P | undefined): void {
188
+ if (!usage) return;
189
+ const read = Number(usage.cachedReadTokens) || 0;
190
+ const written = Number(usage.cachedWriteTokens) || 0;
191
+ const input = Number(usage.inputTokens) || 0;
192
+ this.totals.inputTokens += Math.max(0, input - read - written);
193
+ this.totals.outputTokens += Number(usage.outputTokens) || 0;
194
+ this.totals.cacheReadTokens += read;
195
+ this.totals.cacheWriteTokens += written;
196
+ }
197
+
198
+ endTurn(end: { interrupted?: boolean; error?: string }): void {
199
+ this.close();
200
+ this.emit({ type: "usage", totals: { ...this.totals } });
201
+ this.items.endTurn(end);
202
+ }
203
+ }
@@ -0,0 +1,126 @@
1
+ import { AgentChild } from "./child";
2
+
3
+ export interface AcpMessage {
4
+ id?: number | string;
5
+ method?: string;
6
+ params?: Record<string, unknown>;
7
+ result?: unknown;
8
+ error?: { code?: number; message?: string };
9
+ }
10
+
11
+ const REQUEST_MS = 60_000;
12
+
13
+ // The GitHub Copilot CLI as an Agent Client Protocol server: JSON-RPC 2.0
14
+ // over the stdio of `copilot --acp`. Its own requests to us (permission
15
+ // prompts) count their ids separately from ours.
16
+ export class CopilotRpc {
17
+ private child: AgentChild;
18
+ private nextId = 1;
19
+ private calls = new Map<
20
+ number,
21
+ { resolve: (r: unknown) => void; reject: (e: Error) => void }
22
+ >();
23
+
24
+ constructor(
25
+ args: string[],
26
+ options: { cwd: string; env: NodeJS.ProcessEnv },
27
+ handlers: {
28
+ notify: (method: string, params: Record<string, unknown>) => void;
29
+ request: (
30
+ id: number | string,
31
+ method: string,
32
+ params: Record<string, unknown>
33
+ ) => void;
34
+ exit: (error: string) => void;
35
+ }
36
+ ) {
37
+ this.child = new AgentChild(
38
+ "copilot",
39
+ ["--acp", "--no-auto-update", ...args],
40
+ {
41
+ ...options,
42
+ onRecord: (r) => {
43
+ const m = r as AcpMessage;
44
+ if (m.method && m.id !== undefined)
45
+ handlers.request(m.id, m.method, m.params ?? {});
46
+ else if (m.method) handlers.notify(m.method, m.params ?? {});
47
+ else if (typeof m.id === "number") this.settle(m);
48
+ },
49
+ onExit: (code, error) => {
50
+ const why =
51
+ error ??
52
+ `GitHub Copilot stopped (exit ${code ?? "signal"})${this.child.errorTail ? `: ${this.child.errorTail}` : ""}`;
53
+ for (const call of this.calls.values()) call.reject(new Error(why));
54
+ this.calls.clear();
55
+ handlers.exit(why);
56
+ },
57
+ }
58
+ );
59
+ }
60
+
61
+ private settle(m: AcpMessage): void {
62
+ const call = this.calls.get(m.id as number);
63
+ if (!call) return;
64
+ this.calls.delete(m.id as number);
65
+ if (m.error)
66
+ call.reject(new Error(m.error.message ?? "GitHub Copilot error"));
67
+ else call.resolve(m.result);
68
+ }
69
+
70
+ // A timeout of 0 waits as long as it takes (a prompt runs a whole turn).
71
+ request<T>(
72
+ method: string,
73
+ params: unknown = {},
74
+ timeoutMs = REQUEST_MS
75
+ ): Promise<T> {
76
+ const id = this.nextId++;
77
+ return new Promise<T>((resolve, reject) => {
78
+ const timer = timeoutMs
79
+ ? setTimeout(() => {
80
+ this.calls.delete(id);
81
+ reject(new Error(`GitHub Copilot didn't answer ${method}`));
82
+ }, timeoutMs)
83
+ : undefined;
84
+ this.calls.set(id, {
85
+ resolve: (r) => {
86
+ clearTimeout(timer);
87
+ resolve(r as T);
88
+ },
89
+ reject: (e) => {
90
+ clearTimeout(timer);
91
+ reject(e);
92
+ },
93
+ });
94
+ this.child.write({ jsonrpc: "2.0", id, method, params });
95
+ });
96
+ }
97
+
98
+ notify(method: string, params?: unknown): void {
99
+ this.child.write({ jsonrpc: "2.0", method, params });
100
+ }
101
+
102
+ reply(id: number | string, result: unknown): void {
103
+ this.child.write({ jsonrpc: "2.0", id, result });
104
+ }
105
+
106
+ replyError(id: number | string, message: string): void {
107
+ this.child.write({
108
+ jsonrpc: "2.0",
109
+ id,
110
+ error: { code: -32601, message },
111
+ });
112
+ }
113
+
114
+ // No file or terminal access offered: the agent uses its own.
115
+ async initialize(): Promise<void> {
116
+ await this.request("initialize", {
117
+ protocolVersion: 1,
118
+ clientCapabilities: {},
119
+ clientInfo: { name: "agentos", title: "AgentOS", version: "1.0.0" },
120
+ });
121
+ }
122
+
123
+ close(): void {
124
+ this.child.kill();
125
+ }
126
+ }
@@ -0,0 +1,174 @@
1
+ import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
2
+ import type { ChatImage, DriverEvent } from "../events";
3
+ import { InputQueue } from "../queue";
4
+ import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
5
+ import { CopilotApprovals } from "./copilot-approvals";
6
+ import { CopilotMapper } from "./copilot-mapper";
7
+ import { CopilotRpc } from "./copilot-rpc";
8
+
9
+ type P = Record<string, unknown>;
10
+
11
+ export function copilotArgs(o: ChatStartOptions): string[] {
12
+ const args: string[] = [];
13
+ if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
14
+ return args;
15
+ }
16
+
17
+ export function copilotPrompt(text: string, images?: ChatImage[]) {
18
+ return [
19
+ { type: "text", text },
20
+ ...(images ?? []).map((i) => ({
21
+ type: "image",
22
+ data: i.data,
23
+ mimeType: i.mediaType,
24
+ })),
25
+ ];
26
+ }
27
+
28
+ // The GitHub Copilot CLI over the Agent Client Protocol (`copilot --acp`).
29
+ // It has no system prompt to append to, so the instructions go ahead of the
30
+ // first message of a new conversation.
31
+ export const copilotDriver: ChatDriver = {
32
+ id: "copilot",
33
+ plan: false,
34
+ inProcessTools: false,
35
+ atRest: false,
36
+
37
+ // The ACP server lists no models or commands without a session; the agent
38
+ // picks its own model unless one is chosen.
39
+ async discover() {
40
+ return { commands: [], models: [] };
41
+ },
42
+
43
+ start(options): ChatConversation {
44
+ const out = new InputQueue<DriverEvent>();
45
+ const emit = (e: DriverEvent) => out.push(e);
46
+ const mapper = new CopilotMapper(emit);
47
+ const approvals = new CopilotApprovals(emit, options.access);
48
+ let sessionId: string | null = null;
49
+ let busy = false;
50
+ let intro = options.resumeId ? "" : (options.systemAppend ?? "").trim();
51
+ // Messages that can't join the running turn wait for it to end, in order.
52
+ const held: { text: string; images?: ChatImage[] }[] = [];
53
+ const rpc: CopilotRpc = new CopilotRpc(
54
+ copilotArgs(options),
55
+ { cwd: options.cwd, env: { ...process.env, ...options.env } },
56
+ {
57
+ notify(method, params) {
58
+ if (method === "session/update")
59
+ mapper.map((params.update ?? {}) as P);
60
+ },
61
+ request(id, method, params) {
62
+ if (method !== "session/request_permission")
63
+ return rpc.replyError(id, `${method} isn't supported`);
64
+ void approvals.handle(params).then((result) => rpc.reply(id, result));
65
+ },
66
+ exit(error) {
67
+ approvals.pending.expireAll();
68
+ mapper.endTurn({ error });
69
+ out.end();
70
+ },
71
+ }
72
+ );
73
+ emit({ type: "usage_start", totals: { ...mapper.totals } });
74
+
75
+ const ready = rpc.initialize().then(async () => {
76
+ const params = { cwd: options.cwd, mcpServers: [] };
77
+ if (options.resumeId) {
78
+ mapper.replaying = true;
79
+ try {
80
+ await rpc.request("session/load", {
81
+ ...params,
82
+ sessionId: options.resumeId,
83
+ });
84
+ sessionId = options.resumeId;
85
+ } catch {
86
+ mapper.items.error(
87
+ "That conversation is gone, so this one starts over"
88
+ );
89
+ intro = (options.systemAppend ?? "").trim();
90
+ } finally {
91
+ mapper.replaying = false;
92
+ }
93
+ }
94
+ if (!sessionId)
95
+ sessionId = (
96
+ await rpc.request<{ sessionId: string }>("session/new", params)
97
+ ).sessionId;
98
+ emit({ type: "resume_id", id: sessionId });
99
+ });
100
+ ready.catch((error: Error) => {
101
+ mapper.items.error(`GitHub Copilot couldn't start: ${error.message}`);
102
+ mapper.endTurn({});
103
+ out.end();
104
+ rpc.close();
105
+ });
106
+
107
+ async function run(text: string, images?: ChatImage[]) {
108
+ busy = true;
109
+ mapper.items.startTurn();
110
+ let end: { interrupted?: boolean; error?: string } = {};
111
+ try {
112
+ await ready;
113
+ const first = intro ? `${intro}\n\n${text}` : text;
114
+ intro = "";
115
+ const r = await rpc.request<P>(
116
+ "session/prompt",
117
+ { sessionId, prompt: copilotPrompt(first, images) },
118
+ 0
119
+ );
120
+ mapper.spent(r.usage as P | undefined);
121
+ end = { interrupted: r.stopReason === "cancelled" };
122
+ } catch (error) {
123
+ end = { error: (error as Error).message };
124
+ }
125
+ busy = false;
126
+ mapper.endTurn(end);
127
+ const next = held.shift();
128
+ if (next) void run(next.text, next.images);
129
+ }
130
+
131
+ function stop() {
132
+ approvals.pending.expireAll();
133
+ if (busy && sessionId) rpc.notify("session/cancel", { sessionId });
134
+ }
135
+
136
+ return {
137
+ send(text, images, sendOptions) {
138
+ if (!busy) void run(text, images);
139
+ else {
140
+ held.push({ text, images });
141
+ if (sendOptions?.now) stop();
142
+ }
143
+ return undefined;
144
+ },
145
+ // Stops the turn only: a message already sent still goes after it.
146
+ async interrupt() {
147
+ stop();
148
+ },
149
+ // The model is chosen when the CLI starts.
150
+ async setModel() {},
151
+ async setAccess(next) {
152
+ approvals.access = next;
153
+ },
154
+ async setPlan() {},
155
+ respond(id, answer) {
156
+ approvals.pending.respond(id, answer);
157
+ },
158
+ async stopTask() {},
159
+ async undo() {
160
+ return {
161
+ canUndo: false,
162
+ error: "GitHub Copilot can't undo from chat",
163
+ files: [],
164
+ };
165
+ },
166
+ close() {
167
+ approvals.pending.expireAll();
168
+ rpc.close();
169
+ out.end();
170
+ },
171
+ events: out,
172
+ };
173
+ },
174
+ };
@@ -4,6 +4,7 @@ import { claudeDriver } from "./claude";
4
4
  import { codexDriver } from "./codex";
5
5
  import { openCodeDriver } from "./opencode";
6
6
  import { piDriver } from "./pi";
7
+ import { copilotDriver } from "./copilot";
7
8
  import { demoDriver } from "./demo";
8
9
  import { demoMode } from "../../security/demo";
9
10
 
@@ -14,6 +15,7 @@ const DRIVERS: Record<(typeof CHAT_AGENT_TYPES)[number], ChatDriver> = {
14
15
  codex: codexDriver,
15
16
  pi: piDriver,
16
17
  opencode: openCodeDriver,
18
+ copilot: copilotDriver,
17
19
  };
18
20
 
19
21
  export function chatDriverFor(agentType: string): ChatDriver | null {
@@ -1819,6 +1819,17 @@ const migrations: Migration[] = [
1819
1819
  installChangeTriggers(db, TABLES_WATCHED_78);
1820
1820
  },
1821
1821
  },
1822
+ {
1823
+ id: 79,
1824
+ name: "task_queue_agent",
1825
+ up: (db) => {
1826
+ // The agent CLI a queued task runs with; null is Claude Code.
1827
+ const has = (
1828
+ db.prepare(`PRAGMA table_info(task_queue)`).all() as { name: string }[]
1829
+ ).some((c) => c.name === "agent_type");
1830
+ if (!has) db.exec(`ALTER TABLE task_queue ADD COLUMN agent_type TEXT`);
1831
+ },
1832
+ },
1822
1833
  ];
1823
1834
 
1824
1835
  // `upTo`: stop after this id (tests that start from an older database).
@@ -15,6 +15,7 @@ export const PROVIDER_IDS = [
15
15
  "amp",
16
16
  "pi",
17
17
  "omp",
18
+ "copilot",
18
19
  "shell",
19
20
  ] as const;
20
21
 
@@ -57,6 +58,11 @@ export interface ProviderDefinition {
57
58
  // The model value that means "whatever the agent itself defaults to".
58
59
  export const AGENT_DEFAULT_MODEL = "default";
59
60
 
61
+ // A model name handed to another agent's CLI as an argument: printable, no
62
+ // leading dash (it would read as a flag), bounded.
63
+ export const isAgentModelName = (v: unknown): v is string =>
64
+ typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,99}$/.test(v);
65
+
60
66
  /**
61
67
  * Provider Registry
62
68
  * All supported agent providers with their configurations
@@ -192,6 +198,19 @@ export const PROVIDERS: ProviderDefinition[] = [
192
198
  modelFlag: "--model",
193
199
  initialPromptFlag: "", // Positional argument
194
200
  },
201
+ {
202
+ id: "copilot",
203
+ name: "GitHub Copilot",
204
+ description: "GitHub's Copilot CLI",
205
+ cli: "copilot",
206
+ configDir: "~/.copilot",
207
+ autoApproveFlag: "--allow-all",
208
+ supportsResume: true,
209
+ supportsFork: false,
210
+ resumeArgs: (id) => [`--resume=${id}`],
211
+ modelFlag: "--model",
212
+ initialPromptFlag: "-i",
213
+ },
195
214
  {
196
215
  id: "shell",
197
216
  name: "Terminal",
@@ -154,6 +154,35 @@ export function opencodeResumeId(cwd: string, now = Date.now()): string | null {
154
154
  }
155
155
  }
156
156
 
157
+ // ~/.copilot/session-state/<id>/workspace.yaml, whose `cwd:` line names the
158
+ // folder the conversation ran in.
159
+ export function copilotResumeId(cwd: string, now = Date.now()): string | null {
160
+ const root = path.join(
161
+ process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"),
162
+ "session-state"
163
+ );
164
+ let ids: string[];
165
+ try {
166
+ ids = fs.readdirSync(root);
167
+ } catch {
168
+ return null;
169
+ }
170
+ let best: { id: string; mtimeMs: number } | null = null;
171
+ for (const id of ids) {
172
+ if (!/^[0-9a-f-]{36}$/.test(id)) continue;
173
+ const file = path.join(root, id, "workspace.yaml");
174
+ try {
175
+ const { mtimeMs } = fs.statSync(file);
176
+ if (!recent(mtimeMs, now) || (best && best.mtimeMs >= mtimeMs)) continue;
177
+ const folder = fs.readFileSync(file, "utf8").match(/^cwd: (.+)$/m)?.[1];
178
+ if (folder?.trim() === cwd) best = { id, mtimeMs };
179
+ } catch {
180
+ // Gone since the listing.
181
+ }
182
+ }
183
+ return best?.id ?? null;
184
+ }
185
+
157
186
  const FINDERS: Partial<
158
187
  Record<ProviderId, (cwd: string, now?: number) => string | null>
159
188
  > = {
@@ -161,6 +190,7 @@ const FINDERS: Partial<
161
190
  codex: codexResumeId,
162
191
  pi: piResumeId,
163
192
  opencode: opencodeResumeId,
193
+ copilot: copilotResumeId,
164
194
  };
165
195
 
166
196
  export function findResumeId(agent: ProviderId, cwd: string): string | null {
@@ -19,15 +19,18 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
19
19
  // prose that quotes the footer ("the 'Generated with Claude Code' footer")
20
20
  // isn't one.
21
21
  export const ATTRIBUTION =
22
- /^(?:co-authored-by:.*(?:claude|anthropic)|claude-session:|🤖 generated with)|(?:^|[^\p{L}\p{N}"'`])generated (?:with|by) \[?claude code/iu;
22
+ /^(?:co-authored-by:.*(?:claude|anthropic|copilot)|claude-session:|🤖 generated with)|(?:^|[^\p{L}\p{N}"'`])generated (?:with|by) \[?claude code/iu;
23
23
  export type AttributionKind =
24
24
  | "a Co-Authored-By trailer naming Claude"
25
+ | "a Co-Authored-By trailer naming GitHub Copilot"
25
26
  | "a Claude-Session link"
26
27
  | 'a "Generated with" AI footer';
27
28
  function attributionKind(line: string): AttributionKind | undefined {
28
29
  if (!ATTRIBUTION.test(line)) return;
29
30
  if (/^co-authored-by:/i.test(line))
30
- return "a Co-Authored-By trailer naming Claude";
31
+ return /^co-authored-by:.*(?:claude|anthropic)/i.test(line)
32
+ ? "a Co-Authored-By trailer naming Claude"
33
+ : "a Co-Authored-By trailer naming GitHub Copilot";
31
34
  if (/^claude-session:/i.test(line)) return "a Claude-Session link";
32
35
  return 'a "Generated with" AI footer';
33
36
  }
@@ -28,7 +28,10 @@ import {
28
28
  isBranchName,
29
29
  slugify,
30
30
  } from "../git";
31
- import { getProvider } from "../providers";
31
+ import { getProvider, type AgentType } from "../providers";
32
+ import { agentLabel } from "../agent-options";
33
+ import { supportsChat } from "../chat/capabilities";
34
+ import { AGENT_DEFAULT_MODEL } from "../providers/registry";
32
35
  import { statusDetector } from "../status-detector";
33
36
  import { buildTaskBrief, type StackedOn } from "./brief";
34
37
  import {
@@ -140,6 +143,11 @@ export async function createTask(opts: {
140
143
  base?: { branch: string; tip: string; stack: StackedOn };
141
144
  // Cut from this branch instead of the default one.
142
145
  baseBranch?: string;
146
+ // The agent CLI that does the work (Claude Code unless a person picks
147
+ // another). Another agent runs as a chat on this machine, on its own
148
+ // default model unless `model` names one, without plan-first or role
149
+ // models: those are Claude Code's.
150
+ agent?: AgentType;
143
151
  // Called once the session row exists, before the agent launches.
144
152
  onCreated?: (sessionId: string) => void;
145
153
  // Run it on this machine (another one's own AgentOS); default: the project's.
@@ -201,6 +209,17 @@ async function startTask(
201
209
  if (!project || project.is_uncategorized) throw new Error("Pick a project");
202
210
  const hostId = opts.hostId ?? project.host_id;
203
211
  const view = opts.view ?? DEFAULT_START_VIEW;
212
+ const agent = opts.agent ?? "claude";
213
+ if (agent !== "claude") {
214
+ if (!supportsChat(agent))
215
+ throw new Error(`${agentLabel(agent)} can't run a task yet`);
216
+ if (view !== "chat")
217
+ throw new Error(`${agentLabel(agent)} runs a task as a chat`);
218
+ if (opts.plan || opts.planOnly)
219
+ throw new Error(`${agentLabel(agent)} doesn't plan first yet`);
220
+ if (isRemoteHost(hostId))
221
+ throw new Error(`${agentLabel(agent)} tasks run on this machine for now`);
222
+ }
204
223
  if (isRemoteHost(hostId)) {
205
224
  if (opts.planOnly)
206
225
  throw new Error("A plan-only task runs on this machine for now");
@@ -228,13 +247,16 @@ async function startTask(
228
247
  );
229
248
  if (opts.planOnly && view !== "chat")
230
249
  throw new Error("A plan-only task needs a chat, not a terminal");
231
- const routing = taskRouting(
232
- project.workspace_id,
233
- view,
234
- opts.model,
235
- opts.plan,
236
- opts.planOnly
237
- );
250
+ const routing =
251
+ agent === "claude"
252
+ ? taskRouting(
253
+ project.workspace_id,
254
+ view,
255
+ opts.model,
256
+ opts.plan,
257
+ opts.planOnly
258
+ )
259
+ : otherAgentRouting(opts.model);
238
260
  const projectPath = expandHome(project.working_directory);
239
261
  const naming = await nameFor(prompt, projectPath, opts.name);
240
262
  const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
@@ -253,7 +275,7 @@ async function startTask(
253
275
  startPoint: opts.base?.tip,
254
276
  });
255
277
 
256
- const provider = getProvider("claude");
278
+ const provider = getProvider(agent);
257
279
  const model = routing.first.model;
258
280
  const tmuxName = `${provider.id}-${id}`;
259
281
 
@@ -268,7 +290,7 @@ async function startTask(
268
290
  model,
269
291
  null,
270
292
  "sessions",
271
- "claude",
293
+ agent,
272
294
  1,
273
295
  project.id,
274
296
  "local"
@@ -453,6 +475,19 @@ export async function listTasks(): Promise<TaskView[]> {
453
475
  );
454
476
  }
455
477
 
478
+ // Another agent's task: its own model (or its default), never planned first.
479
+ function otherAgentRouting(model: unknown) {
480
+ return {
481
+ override: null,
482
+ plan: false,
483
+ planOnly: false,
484
+ first: {
485
+ model: typeof model === "string" && model ? model : AGENT_DEFAULT_MODEL,
486
+ provider: { id: ANTHROPIC },
487
+ },
488
+ };
489
+ }
490
+
456
491
  // The model a task starts on, and whether it plans first: only as a chat,
457
492
  // where the handoff can follow the end of its plan turn, and only when
458
493
  // planning and implementing are on different models. Both roles resolve
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ModelOverride } from "../models/resolve";
15
+ import type { AgentType } from "../providers";
15
16
  import { randomUUID } from "crypto";
16
17
  import { db, type Session } from "../db";
17
18
  import { getProject } from "../projects";
@@ -51,6 +52,7 @@ export interface QueueRow {
51
52
  plan_only: number | null;
52
53
  started_by: string | null;
53
54
  view: "chat" | "terminal" | null;
55
+ agent_type: string | null;
54
56
  base_branch: string | null;
55
57
  host_id: string | null;
56
58
  // A task (or queued task) it waits on, by id.
@@ -226,6 +228,7 @@ export interface QueueRequest {
226
228
  planOnly?: boolean;
227
229
  startedBy?: string | null;
228
230
  view?: "chat" | "terminal";
231
+ agent?: AgentType;
229
232
  baseBranch?: string;
230
233
  hostId?: string;
231
234
  // A task's id or name, or "any" for whichever running task finishes first.
@@ -279,9 +282,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
279
282
  if (!req.prompt.trim()) throw new Error("Describe the task");
280
283
  const id = randomUUID();
281
284
  db.prepare(
282
- `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, base_branch,
285
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, agent_type, base_branch,
283
286
  host_id, after_task, after_any, origin_workspace_id, position)
284
- VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @base_branch,
287
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @agent_type, @base_branch,
285
288
  @host_id, @after_task, @after_any, @origin,
286
289
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
287
290
  ).run({
@@ -299,6 +302,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
299
302
  plan_only: req.planOnly ? 1 : null,
300
303
  started_by: req.startedBy ?? null,
301
304
  view: req.view ?? null,
305
+ agent_type: req.agent ?? null,
302
306
  base_branch: req.baseBranch || null,
303
307
  host_id: req.hostId || null,
304
308
  after_task: afterTask,
@@ -478,6 +482,7 @@ async function startRow(
478
482
  planOnly: !!r.plan_only,
479
483
  startedBy: r.started_by,
480
484
  view: r.view ?? undefined,
485
+ agent: (r.agent_type as AgentType | null) ?? undefined,
481
486
  baseBranch: r.base_branch ?? undefined,
482
487
  hostId: r.host_id ?? undefined,
483
488
  reclaim: true,
@@ -28,7 +28,7 @@ const git = (cwd: string, args: string[]) =>
28
28
  // same rule as lib/tasks/code-review.ts's ATTRIBUTION for PR bodies; a test
29
29
  // keeps the two agreeing.
30
30
  export const ATTRIBUTION_ERE =
31
- "^[[:space:]]*(co-authored-by:.*(claude|anthropic)|claude-session:|🤖 generated with)|(^|[^[:alnum:]\"'`])generated (with|by) \\[?claude code";
31
+ "^[[:space:]]*(co-authored-by:.*(claude|anthropic|copilot)|claude-session:|🤖 generated with)|(^|[^[:alnum:]\"'`])generated (with|by) \\[?claude code";
32
32
 
33
33
  // The hooks husky installs; a project's own hook of any of these keeps
34
34
  // running. Not post-index-change or reference-transaction: those run on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.527",
3
+ "version": "0.3.528",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/setup.sh CHANGED
@@ -29,7 +29,7 @@ echo "tmux: $(tmux -V)"
29
29
 
30
30
  # Check AI coding CLI
31
31
  AI_CLI_FOUND=""
32
- for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp; do
32
+ for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp copilot; do
33
33
  if command -v "$cli" &> /dev/null; then
34
34
  AI_CLI_FOUND="$AI_CLI_FOUND $cli"
35
35
  fi