diff --git a/AGENTS.md b/AGENTS.md index 9009c25..a836481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -607,8 +607,8 @@ computes pure functions. Run instructions: `README.md`. - `loopany update` hands the running daemon over to the invoking (new) CLI version: `down` then `runEnsure({force:true})` - force skips the still-reported-online short-circuit (server `ONLINE_TTL` 30s outlives the local pidfile clear). -- `loops.agent` (`CodingAgent` enum: `claude-code|codex|grok`) records the loop's host - coding agent AND selects the executor (at create: measured env fingerprint > +- `loops.agent` (`CodingAgent` enum: `claude-code|codex|grok|copilot`) records the loop's + host coding agent AND selects the executor (at create: measured env fingerprint > `--agent` > server default; detection markers in `create.ts detectAgentFromEnv`). Editable afterward on the edit path - in `EDITABLE_LOOP_FIELDS`, `loopany edit --json`/`show` roundtrip, and the web `LoopForm` agent select (the next run picks @@ -624,11 +624,29 @@ computes pure functions. Run instructions: `README.md`. `-m`; resume is `codex exec resume …`; `execEnv("codex")` forwards `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (session/config under `~/.codex` free via `HOME`) + - `copilot` → `copilot` (`LOOPANY_COPILOT_BIN`), flags verified against a live + `copilot --help`: `-p`, `--allow-all` (unattended BYOA, same intent as claude/grok + `bypassPermissions`; also satisfies the `--allow-all-tools` non-interactive + requirement), `--deny-tool SELF_SCHEDULING_TOOLS` (parity with claude/grok's + `--disallowed-tools` — an unattended copilot run must not self-schedule either), + `--no-ask-user` (so an unattended run never blocks on Copilot's `ask_user` tool), + `--output-format json`, optional `-r/--resume `/`--model`; `detectAgentFromEnv` + fingerprints it via `COPILOT_CLI` (verified live in-session); `execEnv("copilot")` + forwards `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN` (auth precedence per + `copilot help environment`) + `COPILOT_HOME`/`COPILOT_MODEL`/`GH_HOST`/ + `COPILOT_GH_HOST` (GitHub Enterprise host targeting, same doc) + the + `COPILOT_PROVIDER_*` BYOK family (OAuth session under `~/.copilot` is free + via `HOME`). Its skill-install target id is `github-copilot` (verified against the + `skills` npm package source, NOT `copilot`), reading `~/.copilot/skills`. It has + NO `HOOK_TARGET_AGENTS` entry yet — Copilot CLI's `hooks` config exists but its + event-name schema (SessionStart equivalent) isn't confirmed in verified docs, so + that's a deliberate gap, not an oversight (`setup.ts`). **Non-Claude telemetry is DEGRADED**: grok's headless stream is grok-native - (`thought`/`text`/`end`, no cost/usage) and codex `--json` is not Claude - stream-json, so the Claude-shaped `makeStreamConsumer` parses nothing — a run - still marks ok on exit 0 and the agent's own `loopany report` persists the - result; daemon-side live-progress/cost/transcript await per-agent stream adapters. + (`thought`/`text`/`end`, no cost/usage), codex `--json` is not Claude stream-json, + and copilot's `--output-format json` is its own JSONL shape too — the Claude-shaped + `makeStreamConsumer` parses nothing from any of them; a run still marks ok on exit 0 + and the agent's own `loopany report` persists the result; daemon-side live-progress/ + cost/transcript await per-agent stream adapters. Grok's SessionStart hook (`setup.ts`, `~/.grok/hooks/loopany.json`, always-trusted) rides `HOOK_TARGET_AGENTS` (a superset of `SKILL_TARGET_AGENTS` — grok reads Claude's skills dir so it is NOT a skill-install target). The enum's single source diff --git a/packages/daemon/src/create.test.ts b/packages/daemon/src/create.test.ts index 1195b55..eba5066 100644 --- a/packages/daemon/src/create.test.ts +++ b/packages/daemon/src/create.test.ts @@ -70,6 +70,10 @@ describe("detectAgentFromEnv", () => { expect(detectAgentFromEnv({ GROK_AGENT: "1" })).toBe("grok"); }); + test("fingerprints GitHub Copilot from its COPILOT_CLI marker (verified live)", () => { + expect(detectAgentFromEnv({ COPILOT_CLI: "1" })).toBe("copilot"); + }); + test("returns null when no host marker is present (undetectable → caller falls back)", () => { expect(detectAgentFromEnv({ PATH: "/usr/bin" })).toBeNull(); }); @@ -84,6 +88,7 @@ describe("coerceAgent", () => { expect(coerceAgent("claude-code")).toBe("claude-code"); expect(coerceAgent("codex")).toBe("codex"); expect(coerceAgent("grok")).toBe("grok"); + expect(coerceAgent("copilot")).toBe("copilot"); expect(coerceAgent("unknown")).toBeNull(); expect(coerceAgent("")).toBeNull(); expect(coerceAgent(undefined)).toBeNull(); @@ -96,6 +101,10 @@ describe("resolveAgent (precedence: measured > declared > undefined)", () => { expect(resolveAgent({ CLAUDECODE: "1" }, "codex")).toBe("claude-code"); }); + test("a measured Copilot host overrides a conflicting declaration", () => { + expect(resolveAgent({ COPILOT_CLI: "1" }, "codex")).toBe("copilot"); + }); + test("falls back to the declared value when the env is undetectable", () => { expect(resolveAgent({ PATH: "/usr/bin" }, "codex")).toBe("codex"); expect(resolveAgent({ PATH: "/usr/bin" }, "claude-code")).toBe("claude-code"); diff --git a/packages/daemon/src/create.ts b/packages/daemon/src/create.ts index f5fe9f7..4792bc4 100644 --- a/packages/daemon/src/create.ts +++ b/packages/daemon/src/create.ts @@ -97,13 +97,13 @@ export function idempotencyKey(token: string, resolvedBody: Record' [--dry-run] [--connect-key dk_…] [--tz ] [--agent claude-code|codex|grok]\n"); + process.stderr.write("loopany: usage: loopany new --json '' [--dry-run] [--connect-key dk_…] [--tz ] [--agent claude-code|codex|grok|copilot]\n"); return 2; } diff --git a/packages/daemon/src/runner.test.ts b/packages/daemon/src/runner.test.ts index 2c352d1..1aaa6b8 100644 --- a/packages/daemon/src/runner.test.ts +++ b/packages/daemon/src/runner.test.ts @@ -274,6 +274,29 @@ describe("buildAgentSpawn", () => { expect(args.slice(-2)).toEqual(["--model", "grok-4"]); }); + test("copilot: copilot bin + copilot arg vector — allow-all, no-ask-user, json, NO sys-file flag", () => { + // A sysFile is passed but copilot has no Claude sys-prompt-file flag — it must be dropped. + const { bin, args } = buildAgentSpawn({ agent: "copilot", prompt: "do it", sysFile: "/tmp/sys.md" }); + expect(bin).toBe("copilot"); + expect(args).toEqual([ + "-p", "do it", + "--allow-all", + "--deny-tool", "ScheduleWakeup,CronCreate,CronList,CronDelete", + "--no-ask-user", + "--output-format", "json", + ]); + expect(args).not.toContain("--append-system-prompt-file"); + expect(args).not.toContain("--verbose"); + }); + + test("copilot: LOOPANY_COPILOT_BIN escape hatch + resume + model", () => { + process.env.LOOPANY_COPILOT_BIN = "/opt/copilot"; + const { bin, args } = buildAgentSpawn({ agent: "copilot", prompt: "p", resumeSessionId: "c-1", model: "gpt-5" }); + expect(bin).toBe("/opt/copilot"); + expect(args.slice(0, 4)).toEqual(["-p", "p", "--resume", "c-1"]); + expect(args.slice(-2)).toEqual(["--model", "gpt-5"]); + }); + test("codex: codex exec arm — not claude flags; unattended + json + skip-git", () => { // A sysFile is passed but codex has no Claude sys-prompt-file flag — drop it. const { bin, args } = buildAgentSpawn({ agent: "codex", prompt: "do it", sysFile: "/tmp/sys.md" }); diff --git a/packages/daemon/src/runner.ts b/packages/daemon/src/runner.ts index f131555..b196450 100644 --- a/packages/daemon/src/runner.ts +++ b/packages/daemon/src/runner.ts @@ -97,7 +97,7 @@ export interface AgentSpawn { /** * Build the coding-agent spawn command (bin + argv) for one run pass. * - * Three arms (BYOA — each agent's real CLI surface): + * Four arms (BYOA — each agent's real CLI surface): * - `claude-code`: `claude -p … --output-format stream-json --verbose …` * - `grok`: mirrors Claude's shape but uses `streaming-json`, drops `--verbose` * (exit 2) and `--append-system-prompt-file` (no file form). @@ -106,15 +106,23 @@ export interface AgentSpawn { * `--dangerously-bypass-approvals-and-sandbox` (unattended BYOA, same intent * as claude/grok `bypassPermissions`), optional `-m` / `--model`, and * `--skip-git-repo-check` so non-git loop workdirs are not rejected. + * - `copilot`: GitHub Copilot CLI's own `-p`/`--prompt` non-interactive surface + * (flags verified against a live `copilot --help`). `--allow-all` is the + * unattended-BYOA equivalent of claude/grok's `bypassPermissions` (it also + * satisfies `--allow-all-tools`, required for non-interactive mode); + * `--no-ask-user` disables the `ask_user` tool so an unattended run can never + * block waiting on a question; `-r/--resume ` resumes a session; no Claude + * sys-prompt-file flag, so `sysFile` is dropped like grok/codex. * - * Escape hatches: `LOOPANY_CLAUDE_BIN` / `LOOPANY_GROK_BIN` / `LOOPANY_CODEX_BIN`. + * Escape hatches: `LOOPANY_CLAUDE_BIN` / `LOOPANY_GROK_BIN` / `LOOPANY_CODEX_BIN` / + * `LOOPANY_COPILOT_BIN`. * - * Telemetry note: grok's headless stream is grok-native (`thought`/`text`/`end`) - * and codex `--json` is not Claude stream-json either — the Claude-shaped - * `makeStreamConsumer` parses nothing from either. Both still mark OK on exit 0; - * the agent's own `loopany report` persists the result. Daemon-side live- - * progress/cost/transcript for non-Claude agents is degraded until a per-agent - * stream adapter lands. + * Telemetry note: grok's headless stream is grok-native (`thought`/`text`/`end`), + * codex `--json` is not Claude stream-json, and copilot's `--output-format json` + * is its own JSONL shape too — the Claude-shaped `makeStreamConsumer` parses + * nothing from any of them. All three still mark OK on exit 0; the agent's own + * `loopany report` persists the result. Daemon-side live-progress/cost/transcript + * for non-Claude agents is degraded until a per-agent stream adapter lands. */ export function buildAgentSpawn(opts: { agent: CodingAgent; @@ -161,6 +169,20 @@ export function buildAgentSpawn(opts: { ], }; } + if (agent === "copilot") { + return { + bin: process.env.LOOPANY_COPILOT_BIN || "copilot", + args: [ + "-p", prompt, + ...resume, + "--allow-all", + "--deny-tool", SELF_SCHEDULING_TOOLS, + "--no-ask-user", + "--output-format", "json", + ...modelArgs, + ], + }; + } return { bin: process.env.LOOPANY_CLAUDE_BIN || "claude", args: [ diff --git a/packages/daemon/src/setup.ts b/packages/daemon/src/setup.ts index d90dd4e..137742b 100644 --- a/packages/daemon/src/setup.ts +++ b/packages/daemon/src/setup.ts @@ -181,7 +181,12 @@ const installGrokHook: HookInstaller = (s, remove) => { }; /** Installers keyed by `CodingAgent` id. An agent in `HOOK_TARGET_AGENTS` with no - * entry is reported `skipped (no session-hook integration yet)`. */ + * entry is reported `skipped (no session-hook integration yet)` — this currently + * covers `github-copilot`: Copilot CLI's `~/.copilot/config.json` does have a + * `hooks` key, but the verified `copilot help config`/`--help` output never + * enumerates its event names (no confirmed SessionStart equivalent), so wiring + * an installer here would risk shipping a malformed hook config. Deliberately + * left as a documented gap until that's verified, not an oversight. */ const HOOK_INSTALLERS: Record = { "claude-code": installClaudeCodeHook, codex: installCodexHook, diff --git a/packages/daemon/src/skill-cli.test.ts b/packages/daemon/src/skill-cli.test.ts index efe0db6..b6c7dd2 100644 --- a/packages/daemon/src/skill-cli.test.ts +++ b/packages/daemon/src/skill-cli.test.ts @@ -1,8 +1,9 @@ /** * `loopany skill status` — honest, per-agent install reporting. The install now - * targets every agent in `SKILL_TARGET_AGENTS` (Claude Code + Codex today), so - * status must report each one's location for BOTH scopes (user + project), derived - * from the same target list as the installer so the two surfaces cannot drift. + * targets every agent in `SKILL_TARGET_AGENTS` (Claude Code, Codex, GitHub Copilot + * today), so status must report each one's location for BOTH scopes (user + + * project), derived from the same target list as the installer so the two + * surfaces cannot drift. * Nothing here spawns npx or hits the network — status is pure filesystem reads. */ import fs from "node:fs"; @@ -32,13 +33,13 @@ async function captureStatus(): Promise { afterEach(() => vi.restoreAllMocks()); describe("loopany skill status — multi-agent", () => { - test("reports every targeted agent (Claude Code + Codex) by label", async () => { + test("reports every targeted agent (Claude Code + Codex + GitHub Copilot) by label", async () => { const out = await captureStatus(); for (const t of SKILL_TARGET_AGENTS) { expect(out).toContain(t.label); } - // The two CodingAgent values are exactly what we target today. - expect(SKILL_TARGET_AGENTS.map((t) => t.id)).toEqual(["claude-code", "codex"]); + // The CodingAgent values we target today. + expect(SKILL_TARGET_AGENTS.map((t) => t.id)).toEqual(["claude-code", "codex", "github-copilot"]); }); test("reports each agent × scope (user + project) with a real installed/not-installed verdict", async () => { diff --git a/packages/daemon/src/skill-install.test.ts b/packages/daemon/src/skill-install.test.ts index 153db8b..585c070 100644 --- a/packages/daemon/src/skill-install.test.ts +++ b/packages/daemon/src/skill-install.test.ts @@ -32,19 +32,19 @@ describe("installArgs", () => { // Repeated `-a ` flags, one per known agent (the comma form `-a a,b` is // rejected by the `skills` CLI as a single bogus agent name). expect(installArgs("/b/skill")).toEqual([ - "--yes", "skills", "add", "/b/skill", "-a", "claude-code", "-a", "codex", "-y", "--copy", + "--yes", "skills", "add", "/b/skill", "-a", "claude-code", "-a", "codex", "-a", "github-copilot", "-y", "--copy", ]); }); test("global appends -g", () => { expect(installArgs("/b/skill", true)).toEqual([ - "--yes", "skills", "add", "/b/skill", "-a", "claude-code", "-a", "codex", "-y", "--copy", "-g", + "--yes", "skills", "add", "/b/skill", "-a", "claude-code", "-a", "codex", "-a", "github-copilot", "-y", "--copy", "-g", ]); }); - test("targets exactly the two CodingAgent values, never `-a '*'`", () => { + test("targets exactly the known CodingAgent skill-CLI ids, never `-a '*'`", () => { const args = installArgs("/b/skill"); - expect(SKILL_TARGET_AGENTS.map((t) => t.id)).toEqual(["claude-code", "codex"]); + expect(SKILL_TARGET_AGENTS.map((t) => t.id)).toEqual(["claude-code", "codex", "github-copilot"]); // one `-a` per agent, and the litter-everything wildcard never appears expect(args.filter((a) => a === "-a")).toHaveLength(SKILL_TARGET_AGENTS.length); expect(args).not.toContain("*"); @@ -56,6 +56,7 @@ describe("targetSkillDirs", () => { expect(targetSkillDirs({ global: true })).toEqual([ "~/.claude/skills/loopany", "~/.agents/skills/loopany", + "~/.copilot/skills/loopany", ]); }); @@ -63,6 +64,7 @@ describe("targetSkillDirs", () => { expect(targetSkillDirs({ cwd: "/loops/cookie" })).toEqual([ path.join("/loops/cookie", ".claude/skills/loopany"), path.join("/loops/cookie", ".agents/skills/loopany"), + path.join("/loops/cookie", ".copilot/skills/loopany"), ]); }); @@ -70,6 +72,7 @@ describe("targetSkillDirs", () => { expect(targetSkillDirs()).toEqual([ "./.claude/skills/loopany", "./.agents/skills/loopany", + "./.copilot/skills/loopany", ]); }); }); diff --git a/packages/daemon/src/skill-install.ts b/packages/daemon/src/skill-install.ts index 81a87a8..a832eb4 100644 --- a/packages/daemon/src/skill-install.ts +++ b/packages/daemon/src/skill-install.ts @@ -11,8 +11,8 @@ * is the manual escape hatch (`--project` installs into the cwd instead). * * MULTI-AGENT: the skill is installed for every coding agent loopany knows about - * (`SKILL_TARGET_AGENTS` — currently Claude Code and Codex, the two `CodingAgent` - * values), so a Codex user (or any of the two) gets the skill, not just Claude Code. + * (`SKILL_TARGET_AGENTS` — currently Claude Code, Codex, and GitHub Copilot), + * so any of them gets the skill, not just Claude Code. * We deliberately DON'T pass `-a '*'`: empirically the `skills` CLI treats `'*'` as * "install to ALL ~72 supported agents regardless of presence", littering dozens of * home dirs (`~/.aider-desk`, `~/.astrbot`, …). Targeting the known agents explicitly @@ -130,13 +130,17 @@ export interface InstallOpts { } /** - * The coding agents the loopany skill install targets — the two `CodingAgent` - * values. Each carries the `skills` CLI agent id (for `-a `), a human label, - * and the skill dir layout that agent reads (relative to the scope root: `~` for a - * global/user install, the cwd for a project install). Verified empirically against - * the current `skills` CLI: Claude Code reads `.claude/skills`, Codex reads the - * universal `.agents/skills`. Extend this list to cover a new agent (installArgs and - * `loopany skill status` both derive from it — they cannot drift). + * The coding agents the loopany skill install targets. Each carries the `skills` + * CLI agent id (for `-a `), a human label, and the skill dir layout that + * agent reads (relative to the scope root: `~` for a global/user install, the + * cwd for a project install). Verified empirically against the current `skills` + * CLI: Claude Code reads `.claude/skills`, Codex reads the universal + * `.agents/skills`. GitHub Copilot's `skills` CLI agent id is `github-copilot` + * (NOT `copilot` — verified by unpacking the `skills` npm package source), and + * `copilot skill --help` confirms it reads personal skills from `~/.copilot/skills` + * (its `skillsRoot` here), also falling back to the universal `~/.agents/skills`. + * Extend this list to cover a new agent (installArgs and `loopany skill status` + * both derive from it — they cannot drift). */ export const SKILL_TARGET_AGENTS: ReadonlyArray<{ id: string; @@ -146,6 +150,7 @@ export const SKILL_TARGET_AGENTS: ReadonlyArray<{ }> = [ { id: "claude-code", label: "Claude Code", skillsRoot: [".claude", "skills"] }, { id: "codex", label: "Codex", skillsRoot: [".agents", "skills"] }, + { id: "github-copilot", label: "GitHub Copilot", skillsRoot: [".copilot", "skills"] }, ]; /** The argv (after `npx`) for the install — pure, so tests can assert it. Targets diff --git a/packages/daemon/src/spawn.test.ts b/packages/daemon/src/spawn.test.ts index e66a4ff..8b8744e 100644 --- a/packages/daemon/src/spawn.test.ts +++ b/packages/daemon/src/spawn.test.ts @@ -95,6 +95,42 @@ describe("execEnv", () => { expect(env.OPENAI_API_KEY).toBeUndefined(); expect(env.CODEX_API_KEY).toBeUndefined(); }); + + test("copilot path forwards COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN/COPILOT_HOME/COPILOT_MODEL/GH_HOST/COPILOT_GH_HOST + COPILOT_PROVIDER_* BYOK family, not Claude keys", () => { + setEnv("COPILOT_GITHUB_TOKEN", "ghu-secret"); + setEnv("GH_TOKEN", "gh-secret"); + setEnv("GITHUB_TOKEN", "gha-secret"); + setEnv("COPILOT_HOME", "/tmp/copilot-home"); + setEnv("COPILOT_MODEL", "gpt-5"); + setEnv("GH_HOST", "mycompany.ghe.com"); + setEnv("COPILOT_GH_HOST", "github.com"); + setEnv("COPILOT_PROVIDER_BASE_URL", "https://byok.example"); + setEnv("COPILOT_PROVIDER_API_KEY", "byok-secret"); + setEnv("ANTHROPIC_API_KEY", "sk-x"); + setEnv("CLAUDE_CODE_OAUTH_TOKEN", "claude-tok"); + const env = execEnv("copilot"); + expect(env.COPILOT_GITHUB_TOKEN).toBe("ghu-secret"); + expect(env.GH_TOKEN).toBe("gh-secret"); + expect(env.GITHUB_TOKEN).toBe("gha-secret"); + expect(env.COPILOT_HOME).toBe("/tmp/copilot-home"); + expect(env.COPILOT_MODEL).toBe("gpt-5"); + expect(env.GH_HOST).toBe("mycompany.ghe.com"); + expect(env.COPILOT_GH_HOST).toBe("github.com"); + expect(env.COPILOT_PROVIDER_BASE_URL).toBe("https://byok.example"); + expect(env.COPILOT_PROVIDER_API_KEY).toBe("byok-secret"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + // OAuth session under ~/.copilot stays reachable via HOME (BASE_ALLOW). + expect(env.HOME).toBe(process.env.HOME); + }); + + test("claude path does NOT forward Copilot keys", () => { + setEnv("COPILOT_GITHUB_TOKEN", "ghu-secret"); + setEnv("COPILOT_MODEL", "gpt-5"); + const env = execEnv(); + expect(env.COPILOT_GITHUB_TOKEN).toBeUndefined(); + expect(env.COPILOT_MODEL).toBeUndefined(); + }); }); describe("allowlistEnv", () => { diff --git a/packages/daemon/src/spawn.ts b/packages/daemon/src/spawn.ts index c7f95a4..943c07f 100644 --- a/packages/daemon/src/spawn.ts +++ b/packages/daemon/src/spawn.ts @@ -149,6 +149,11 @@ export function allowlistEnv(extra: { keys?: string[]; prefixes?: string[] } = { * `~/.grok` is free via HOME (BASE_ALLOW) * - codex: OPENAI_API_KEY / CODEX_API_KEY (+ optional CODEX_HOME); OAuth / * session files under `~/.codex` are free via HOME + * - copilot: COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN (auth precedence + * per `copilot help environment`) + optional COPILOT_HOME / COPILOT_MODEL / + * GH_HOST / COPILOT_GH_HOST (GitHub Enterprise host targeting) + the + * COPILOT_PROVIDER_* BYOK family; OAuth session under `~/.copilot` is + * free via HOME * Keys ride ONLY their agent's path so a claude run never inherits an unrelated * xAI/OpenAI secret. `agent` defaults to claude-code so existing callers are unchanged. */ export function execEnv(agent: CodingAgent = "claude-code"): NodeJS.ProcessEnv { @@ -162,6 +167,12 @@ export function execEnv(agent: CodingAgent = "claude-code"): NodeJS.ProcessEnv { keys: ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_HOME"], }); } + if (agent === "copilot") { + return allowlistEnv({ + keys: ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "COPILOT_HOME", "COPILOT_MODEL", "GH_HOST", "COPILOT_GH_HOST"], + prefixes: ["COPILOT_PROVIDER_"], + }); + } return allowlistEnv({ keys: ["CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR"], prefixes: ["ANTHROPIC_"], diff --git a/packages/server/src/components/ComposeModal.tsx b/packages/server/src/components/ComposeModal.tsx index d02f5c9..de670a5 100644 --- a/packages/server/src/components/ComposeModal.tsx +++ b/packages/server/src/components/ComposeModal.tsx @@ -16,9 +16,10 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok Build', + copilot: 'Copilot', } -// The three supported coding agents, shown as brand marks on the +// The supported coding agents, shown as brand marks on the // Copy-prompt button (the prompt runs in whichever you use). LobeHub icon set, // decorative (aria-hidden) — the button text is the accessible name. function ClaudeCodeMark({ size = 14 }: { size?: number }) { @@ -61,6 +62,16 @@ function GrokMark({ size = 14 }: { size?: number }) { ) } +/** Copilot mark (Octicon `copilot`, simplified) — monochrome; inherits the button + * text color, matching the Grok mark's treatment. */ +function CopilotMark({ size = 14 }: { size?: number }) { + return ( + + + + ) +} + // The one human-readable instruction the snippet carries. `/api/bootstrap` serves the // BOOTSTRAP doc (skill/bootstrap.md) — it owns ALL first-capture intelligence: it // interprets the pasted values, connects the machine, reads the session to decide @@ -258,6 +269,7 @@ export function ComposeModal({ + {copied ? '✓ Copied' : 'Copy prompt'} diff --git a/packages/server/src/components/LoopDetailView.tsx b/packages/server/src/components/LoopDetailView.tsx index 9169fd8..3584042 100644 --- a/packages/server/src/components/LoopDetailView.tsx +++ b/packages/server/src/components/LoopDetailView.tsx @@ -15,7 +15,7 @@ import { Timeline, WINDOW } from './Timeline' import { ArtifactList, btn, btnCost, btnPrimary, btnQuiet, ErrorBanner, Loading, Pill, Pre, runPulseStyle, sectionHeadCls } from './ui' import { ConfirmBar, FlashLine, LoadErrorCard, useContinueSession, useDeferredDelete, useFlash } from './actionUi' -const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok Build' } +const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok Build', copilot: 'Copilot' } /** Composer starters - one per editable dimension, so a blank box never stalls * the owner. Clicking seeds the instruction; the agent handles the rest. */ diff --git a/packages/server/src/components/LoopForm.tsx b/packages/server/src/components/LoopForm.tsx index 1631c8c..ce043ce 100644 --- a/packages/server/src/components/LoopForm.tsx +++ b/packages/server/src/components/LoopForm.tsx @@ -88,7 +88,7 @@ const hintCls = 'mt-1 text-caption leading-snug text-disabled' /** Display labels for the known agents; an unknown/widened value falls back to its * raw enum token so a newly-added agent (e.g. grok) still renders. */ -const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex' } +const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok Build', copilot: 'Copilot' } // Module-level so identity is stable across renders (an inner component would // remount on each keystroke and drop input focus). diff --git a/packages/server/src/components/actionUi.tsx b/packages/server/src/components/actionUi.tsx index 7c14c48..456e74c 100644 --- a/packages/server/src/components/actionUi.tsx +++ b/packages/server/src/components/actionUi.tsx @@ -88,7 +88,7 @@ export function ConfirmBar({ * orange. Decorative (aria-hidden): the button text stays the accessible name, * keeping generic copy agent-neutral while the LOGO is factual — only Claude * stream-json currently yields a captured session id for this affordance - * (codex/grok execute on their own CLIs; telemetry is still degraded, no + * (codex/grok/copilot execute on their own CLIs; telemetry is still degraded, no * session id). Swap per-agent once another agent yields a resumable session. */ function ClaudeCodeMark({ size = 14 }: { size?: number }) { return ( @@ -124,7 +124,7 @@ function ClaudeCodeMark({ size = 14 }: { size?: number }) { * the hook unconditionally (hooks can't be conditional) while data loads. * Prose stays agent-neutral; the literal `claude` binary in the copied command * is factual (only Claude stream-json currently yields a captured session id — - * codex/grok execute, but daemon telemetry is still degraded for them). + * codex/grok/copilot execute, but daemon telemetry is still degraded for them). */ export function useContinueSession({ sessionId, diff --git a/packages/server/src/gateway/delivery.ts b/packages/server/src/gateway/delivery.ts index d15ca99..d5d239c 100644 --- a/packages/server/src/gateway/delivery.ts +++ b/packages/server/src/gateway/delivery.ts @@ -29,7 +29,7 @@ export interface Delivery { model: string | null; allowControl: boolean; /** Coding agent to EXECUTE this loop with (the daemon branches spawn + - * credentials on this — claude-code | codex | grok). */ + * credentials on this — claude-code | codex | grok | copilot). */ agent: CodingAgent; }; /** Cursor (prev state) for the workflow gate. */ diff --git a/packages/server/src/gateway/index.ts b/packages/server/src/gateway/index.ts index be3a07b..b3ae911 100644 --- a/packages/server/src/gateway/index.ts +++ b/packages/server/src/gateway/index.ts @@ -752,10 +752,11 @@ export class MachineGateway { const notify = body.notify === "always" || body.notify === "never" ? body.notify : "auto"; // Recorded coding agent: trust the daemon's resolved value when it's a known - // agent, else default to claude-code (older daemons omit it; an unrecognized / - // "unknown" value also degrades to the default rather than rejecting the loop). - const agent: CodingAgent = - body.agent === "codex" || body.agent === "grok" ? body.agent : "claude-code"; + // agent (the shared `coerceCodingAgent` validator — same anti-drift discipline + // as the edit path), else default to claude-code (older daemons omit it; an + // unrecognized / "unknown" value also degrades to the default rather than + // rejecting the loop). + const agent: CodingAgent = coerceCodingAgent(body.agent) ?? "claude-code"; const stateSchema = store.coerceStateSchema(body.stateSchema) ?? null; // Optional day-one dashboard — same validate/clip surface as `set-ui` (editLoop). diff --git a/packages/server/src/server/adapters.ts b/packages/server/src/server/adapters.ts index 90e8eb7..dbfd434 100644 --- a/packages/server/src/server/adapters.ts +++ b/packages/server/src/server/adapters.ts @@ -103,7 +103,7 @@ function toJobFull(loop: Loop): JobFull { channelId: loop.channelId ?? null, agent: loop.agent, exec: { - // The coding agent this loop executes with (claude-code | codex | grok). + // The coding agent this loop executes with (claude-code | codex | grok | copilot). // The daemon branches spawn + credentials on this value. executor: loop.agent, workdir: loop.workdir ?? "", diff --git a/packages/server/src/skill/references/create.md b/packages/server/src/skill/references/create.md index 8b1c202..0c80415 100644 --- a/packages/server/src/skill/references/create.md +++ b/packages/server/src/skill/references/create.md @@ -237,7 +237,7 @@ learns the loop was created, and declare which coding agent you are: new \ --json '' \ --connect-key \ - --agent claude-code # which coding agent you are (claude-code | codex | grok); omit to auto-detect + --agent claude-code # which coding agent you are (claude-code | codex | grok | copilot); omit to auto-detect ``` `loopany new` detects the IANA timezone, injects the claim, records the coding agent diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index b6f8610..5329b3d 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -16,7 +16,8 @@ export type { MachinePresence } from './lib/machinePresence' import type { ArtifactMeta } from './server/frontmatter' /** The coding agent a loop is bound to AND executed with (BYOA on the owner's - * machine): `claude-code` → Claude Code, `codex` → `codex exec`, `grok` → Grok. + * machine): `claude-code` → Claude Code, `codex` → `codex exec`, `grok` → Grok, + * `copilot` → GitHub Copilot CLI (`copilot -p`). * Non-Claude agents may still have thinner daemon telemetry until a stream * adapter lands; execution itself is real for every value. * @@ -26,7 +27,7 @@ import type { ArtifactMeta } from './server/frontmatter' * web agent `