Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -624,11 +624,29 @@ computes pure functions. Run instructions: `README.md`.
`-m`; resume is `codex exec resume <sessionId> …`; `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 <id>`/`--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
Expand Down
9 changes: 9 additions & 0 deletions packages/daemon/src/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
Expand All @@ -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");
Expand Down
9 changes: 6 additions & 3 deletions packages/daemon/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ export function idempotencyKey(token: string, resolvedBody: Record<string, unkno
}

/** The coding agents Loopany can record a loop against (TS-only; cheap to widen). */
export type CodingAgent = "claude-code" | "codex" | "grok";
export type CodingAgent = "claude-code" | "codex" | "grok" | "copilot";

/** Coerce an arbitrary declared value (--agent flag / config.agent) to a known
* agent, or null when it's absent/unrecognized (so it can't override a measurement
* and the server falls back to its own default). */
export function coerceAgent(v: unknown): CodingAgent | null {
return v === "claude-code" || v === "codex" || v === "grok" ? v : null;
return v === "claude-code" || v === "codex" || v === "grok" || v === "copilot" ? v : null;
}

/**
Expand All @@ -120,11 +120,14 @@ export function coerceAgent(v: unknown): CodingAgent | null {
* shell tool under the sandbox). We deliberately ignore `CODEX_COMPANION_*`,
* which a Claude Code session can also export and would misattribute.
* - Grok Build: `GROK_AGENT=1` (grok exports it into child processes).
* - GitHub Copilot CLI: `COPILOT_CLI=1` (verified against a live copilot session's
* exported env; it also sets `COPILOT_AGENT_SESSION_ID`/`COPILOT_CLI_BINARY_VERSION`).
*/
export function detectAgentFromEnv(env: NodeJS.ProcessEnv): CodingAgent | null {
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDE_CODE_SESSION_ID) return "claude-code";
if (env.CODEX_SANDBOX || env.CODEX_SANDBOX_NETWORK_DISABLED) return "codex";
if (env.GROK_AGENT) return "grok";
if (env.COPILOT_CLI) return "copilot";
return null;
}

Expand Down Expand Up @@ -155,7 +158,7 @@ export async function runCreate(args: string[], deps: CreateDeps = {}): Promise<
const jsonArg = flag(args, "json");
const dryRun = args.includes("--dry-run");
if (jsonArg === undefined) {
process.stderr.write("loopany: usage: loopany new --json '<config>' [--dry-run] [--connect-key dk_…] [--tz <IANA>] [--agent claude-code|codex|grok]\n");
process.stderr.write("loopany: usage: loopany new --json '<config>' [--dry-run] [--connect-key dk_…] [--tz <IANA>] [--agent claude-code|codex|grok|copilot]\n");
return 2;
}

Expand Down
23 changes: 23 additions & 0 deletions packages/daemon/src/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
38 changes: 30 additions & 8 deletions packages/daemon/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 <id>` 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;
Expand Down Expand Up @@ -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: [
Expand Down
7 changes: 6 additions & 1 deletion packages/daemon/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HookInstaller> = {
"claude-code": installClaudeCodeHook,
codex: installCodexHook,
Expand Down
13 changes: 7 additions & 6 deletions packages/daemon/src/skill-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -32,13 +33,13 @@ async function captureStatus(): Promise<string> {
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 () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/daemon/src/skill-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ describe("installArgs", () => {
// Repeated `-a <id>` 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("*");
Expand All @@ -56,20 +56,23 @@ describe("targetSkillDirs", () => {
expect(targetSkillDirs({ global: true })).toEqual([
"~/.claude/skills/loopany",
"~/.agents/skills/loopany",
"~/.copilot/skills/loopany",
]);
});

test("project cwd → each agent's dir under the cwd", () => {
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"),
]);
});

test("default (no cwd) → cwd-relative, keeps the ./ prefix", () => {
expect(targetSkillDirs()).toEqual([
"./.claude/skills/loopany",
"./.agents/skills/loopany",
"./.copilot/skills/loopany",
]);
});
});
Expand Down
23 changes: 14 additions & 9 deletions packages/daemon/src/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>`), 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 <id>`), 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;
Expand All @@ -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
Expand Down
Loading