Files
multica/server/internal/daemon/execenv/context.go
LinYushen cd50c31201 feat(agent): add GitHub Copilot CLI backend (#1157)
* feat(agent): add GitHub Copilot CLI backend

Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.

Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides

Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path

Frontend:
- Add Copilot logo SVG and provider switch case

Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): add restart subcommand, make daemon uses it

- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): address review nits 1-5

- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
  back to opts.Model, then "copilot"). First-turn tokens now get correct
  model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
  reasoningText in assistant.message → MessageThinking,
  session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
  tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
  dies before assistant.message, output is non-empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:14:56 +08:00

150 lines
4.9 KiB
Go

package execenv
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// writeContextFiles renders and writes .agent_context/issue_context.md and
// skills into the appropriate provider-native location.
//
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
// Codex: skills → handled separately in Prepare via codex-home
// Copilot: skills → {workDir}/.agent_context/skills/{name}/SKILL.md (via AGENTS.md references)
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
if err := os.MkdirAll(contextDir, 0o755); err != nil {
return fmt.Errorf("create .agent_context dir: %w", err)
}
content := renderIssueContext(provider, ctx)
path := filepath.Join(contextDir, "issue_context.md")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write issue_context.md: %w", err)
}
if len(ctx.AgentSkills) > 0 {
skillsDir, err := resolveSkillsDir(workDir, provider)
if err != nil {
return fmt.Errorf("resolve skills dir: %w", err)
}
// Codex skills are written to codex-home in Prepare; skip here.
if provider != "codex" {
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
return fmt.Errorf("write skill files: %w", err)
}
}
}
return nil
}
// resolveSkillsDir returns the directory where skills should be written
// based on the agent provider.
func resolveSkillsDir(workDir, provider string) (string, error) {
var skillsDir string
switch provider {
case "claude":
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".claude", "skills")
case "opencode":
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
case "pi":
// Pi natively discovers skills from .pi/agent/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".pi", "agent", "skills")
case "cursor":
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".cursor", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
}
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return "", err
}
return skillsDir, nil
}
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
// sanitizeSkillName converts a skill name to a safe directory name.
func sanitizeSkillName(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = nonAlphaNum.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "skill"
}
return s
}
// writeSkillFiles writes skill directories into the given parent directory.
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return fmt.Errorf("create skills dir: %w", err)
}
for _, skill := range skills {
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
// Write main SKILL.md
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
return err
}
// Write supporting files
for _, f := range skill.Files {
fpath := filepath.Join(dir, f.Path)
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
return err
}
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
return err
}
}
}
return nil
}
// renderIssueContext builds the markdown content for issue_context.md.
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Task Assignment\n\n")
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
if ctx.TriggerCommentID != "" {
b.WriteString("**Trigger:** Comment Reply\n")
b.WriteString("**Triggering comment ID:** `" + ctx.TriggerCommentID + "`\n\n")
} else {
b.WriteString("**Trigger:** New Assignment\n\n")
}
b.WriteString("## Quick Start\n\n")
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}