@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 +27 -16
- package/app/api/tasks/route.test.ts +22 -0
- package/app/api/tasks/route.ts +14 -2
- 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/db/migrations.ts +11 -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/worktree-hooks.ts +1 -1
- package/package.json +1 -1
- package/scripts/setup.sh +1 -1
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
|
|
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
|
|
233
|
-
|
|
|
234
|
-
| Claude Code
|
|
235
|
-
| Codex
|
|
236
|
-
| OpenCode
|
|
237
|
-
| Pi
|
|
238
|
-
| Kilo Code
|
|
239
|
-
| Gemini CLI
|
|
240
|
-
| Aider
|
|
241
|
-
| Cursor CLI
|
|
242
|
-
| Amp
|
|
243
|
-
| Oh My Pi
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
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:
|
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
|
+
}
|
|
@@ -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 {
|
package/lib/db/migrations.ts
CHANGED
|
@@ -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 {
|
package/lib/tasks/code-review.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/tasks/index.ts
CHANGED
|
@@ -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 =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -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,
|
package/lib/worktree-hooks.ts
CHANGED
|
@@ -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
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
|