Compare commits

...

8 Commits

Author SHA1 Message Date
Jiayuan
1ca55c3d80 chore(daemon): skip squash commits in Co-authored-by hook
Test commit to verify the prepare-commit-msg hook appends the
Co-authored-by trailer automatically.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-29 22:59:23 +02:00
Jiayuan
528976e547 feat(settings): add Co-authored-by toggle in workspace Labs settings
Add a workspace-level toggle to enable/disable the Co-authored-by
trailer for agent commits. Default is enabled (on).

Backend:
- Include workspace settings in daemon register response
- Store settings in daemon workspaceState
- Thread CoAuthoredByEnabled through WorktreeParams to conditionally
  install the prepare-commit-msg hook
- Parse co_authored_by_enabled from workspace settings JSONB

Frontend:
- Replace empty Labs tab placeholder with a Git section containing
  a Switch toggle for the Co-authored-by trailer setting
- Optimistically update the workspace query cache on toggle
2026-04-29 22:54:33 +02:00
Jiayuan
1ddd210170 feat(daemon): add Co-authored-by trailer for Multica Agent to git commits
Install a prepare-commit-msg hook in worktree bare repos that appends
"Co-authored-by: multica-agent <github@multica.ai>" to every commit
made by agents. Uses git interpret-trailers for proper formatting and
skips duplicates.
2026-04-29 22:40:32 +02:00
Jiayuan Zhang
562949e1cb fix(daemon): prevent Quick Create from inventing requirements beyond user input (#1903)
The description rule in buildQuickCreatePrompt() instructed the agent to
"always provide a rich, self-contained description" and "spell out what
needs to be done", which caused the agent to fabricate detailed product
specs, implementation phases, and design decisions from a one-line input.

Replace with a faithfulness-first rule: enrich with factual context
(fetched PR details, linked resources) but never invent requirements,
design decisions, or constraints the user did not express.

Fixes MUL-1605
2026-04-29 21:12:17 +02:00
Jiayuan Zhang
65f6e9c9f2 feat(autopilots): show execution log button for run-only autopilot runs (#1901)
In run-only mode, autopilot runs don't create issues, so there was no
way to view the agent's execution transcript from the UI. Add a
TranscriptButton to each run row that has a task_id but no linked
issue, allowing users to lazy-load and inspect the full execution log
directly from the autopilot detail page.
2026-04-29 19:10:49 +02:00
Jiayuan Zhang
79d28b0da6 fix(agents): navigate to detail page before invalidating list query (#1897)
After creating an agent from the empty state, the query invalidation
triggered a refetch that re-rendered the agents list page (empty → list)
before navigation to the detail page completed, causing a visible flash.

Move navigation.push() before qc.invalidateQueries() so the user lands
on the detail page immediately; the list refetch happens in the
background after we've already left.
2026-04-29 18:22:56 +02:00
Jiayuan Zhang
aeccd4f26e feat(quick-create): enrich issue title and description with URL context (#1892)
* feat(quick-create): enrich issue title and description with URL context

Update the quick-create agent prompt to fetch context from URLs in user
input (GitHub PRs, issues, web pages) before creating the issue. The
agent now produces semantically rich titles (e.g. "Review PR #123:
Refactor auth to OAuth2" instead of "review PR #123") and includes
summarized link content in the description so issues are self-contained.

* refactor(quick-create): let agent decide when to fetch URL context

Replace prescriptive URL enrichment instructions (hardcoded gh/WebFetch
commands) with goal-oriented guidance. The agent now uses its own
judgment to decide whether fetching referenced URLs would produce a
meaningfully better title/description, rather than being told exactly
which tools to use.

* fix(quick-create): always generate rich description for agent execution

The description was previously optional ("omit if simple request"). Since
quick-create issues are executed by agents, richer context leads to
better execution — update the prompt to always produce a substantive
description with actionable context.

* fix(quick-create): remove Chinese text from prompt, use English only

Replace Chinese examples in priority mapping and assignee matching with
language-agnostic English equivalents, per project coding rules.

* fix(quick-create): remove language-related hints from prompt

Agent doesn't need to be told about language handling — remove
"(in any language)" and "or equivalent in any language" qualifiers.
Keep prompt purely in English with no language-related content.
2026-04-29 18:19:11 +02:00
Jiayuan Zhang
68ed2a32d9 fix(desktop): prevent Cmd+R / Ctrl+R / F5 from reloading the page (#1896)
In a desktop app an accidental page reload destroys in-memory state
(tabs, drafts, WS connections) with no URL bar to navigate back.

Add a before-input-event listener on the main BrowserWindow that
intercepts Cmd+R / Ctrl+R (with or without Shift) and F5, calling
preventDefault() to block the reload. DevTools refresh still works.
2026-04-29 18:18:01 +02:00
12 changed files with 341 additions and 44 deletions

View File

@@ -111,6 +111,22 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -279,10 +279,10 @@ export function AgentsPage() {
// Surfaced softly; the agent itself is fine.
}
}
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
setShowCreate(false);
setDuplicateTemplate(null);
navigation.push(paths.agentDetail(agent.id));
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
};
const handleDuplicate = useCallback((agent: Agent) => {

View File

@@ -44,7 +44,9 @@ import {
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
function formatDate(date: string): string {
@@ -63,11 +65,34 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run }: { run: AutopilotRun }) {
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
const wsPaths = useWorkspacePaths();
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
// For runs with a task_id (run_only mode), build a minimal AgentTask so
// TranscriptButton can lazy-load the execution transcript.
const syntheticTask: AgentTask | null = run.task_id
? {
id: run.task_id,
agent_id: agentId,
runtime_id: "",
issue_id: "",
status:
run.status === "running" ? "running" :
run.status === "completed" ? "completed" :
run.status === "failed" ? "failed" :
"queued",
priority: 0,
dispatched_at: null,
started_at: run.triggered_at || null,
completed_at: run.completed_at || null,
result: null,
error: run.failure_reason || null,
created_at: run.created_at,
}
: null;
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
@@ -83,6 +108,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
{syntheticTask && !run.issue_id && (
<TranscriptButton
task={syntheticTask}
agentName={agentName}
isLive={run.status === "running"}
title="View execution log"
/>
)}
</>
);
@@ -438,7 +471,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} />
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
))}
</div>
)}

View File

@@ -1,26 +1,83 @@
"use client";
import { FlaskConical } from "lucide-react";
import { useState } from "react";
import { GitCommitHorizontal } from "lucide-react";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { useCurrentWorkspace } from "@multica/core/paths";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
export function LabsTab() {
const workspace = useCurrentWorkspace();
const qc = useQueryClient();
const [saving, setSaving] = useState(false);
const coAuthoredByEnabled =
(workspace?.settings as Record<string, unknown>)?.co_authored_by_enabled !== false;
const handleToggle = async (checked: boolean) => {
if (!workspace || saving) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
settings: {
...((workspace.settings as Record<string, unknown>) ?? {}),
co_authored_by_enabled: checked,
},
});
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to update setting",
);
} finally {
setSaving(false);
}
};
if (!workspace) return null;
return (
<div className="space-y-4">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Labs</h2>
<h2 className="text-sm font-semibold">Git</h2>
<Card>
<CardContent>
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
<FlaskConical className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No experimental features yet</p>
<p className="text-sm text-muted-foreground">
Beta features that require manual opt-in will appear here.
</p>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
<GitCommitHorizontal className="h-4 w-4" />
</div>
<div className="space-y-1">
<Label
htmlFor="co-authored-by"
className="text-sm font-medium"
>
Co-authored-by trailer
</Label>
<p className="text-sm text-muted-foreground">
Automatically add{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
Co-authored-by: multica-agent &lt;github@multica.ai&gt;
</code>{" "}
to commits made by agents.
</p>
</div>
</div>
<Switch
id="co-authored-by"
checked={coAuthoredByEnabled}
onCheckedChange={handleToggle}
disabled={saving}
/>
</div>
</CardContent>
</Card>

View File

@@ -295,9 +295,10 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
// RegisterResponse holds the server's response to a daemon registration.
type RegisterResponse struct {
Runtimes []Runtime `json:"runtimes"`
Repos []RepoData `json:"repos"`
ReposVersion string `json:"repos_version"`
Runtimes []Runtime `json:"runtimes"`
Repos []RepoData `json:"repos"`
ReposVersion string `json:"repos_version"`
Settings json.RawMessage `json:"settings,omitempty"`
}
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {

View File

@@ -30,6 +30,7 @@ type workspaceState struct {
runtimeIDs []string
reposVersion string // stored for future use: skip refresh when version unchanged
allowedRepoURLs map[string]struct{}
settings json.RawMessage // workspace settings (JSONB)
lastRepoSyncErr string
repoRefreshMu sync.Mutex
}
@@ -321,12 +322,13 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
return resp, nil
}
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData) *workspaceState {
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
return &workspaceState{
workspaceID: workspaceID,
runtimeIDs: runtimeIDs,
reposVersion: reposVersion,
allowedRepoURLs: repoAllowlist(repos),
settings: settings,
}
}
@@ -370,6 +372,25 @@ func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string {
return ws.lastRepoSyncErr
}
// workspaceCoAuthoredByEnabled returns whether the Co-authored-by hook should
// be installed for the given workspace. Defaults to true when the setting is
// absent (new workspaces, older servers that don't send settings).
func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok || len(ws.settings) == 0 {
return true // default: enabled
}
var s struct {
CoAuthoredByEnabled *bool `json:"co_authored_by_enabled"`
}
if err := json.Unmarshal(ws.settings, &s); err != nil || s.CoAuthoredByEnabled == nil {
return true // default: enabled
}
return *s.CoAuthoredByEnabled
}
func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) {
if d.repoCache == nil {
return
@@ -510,7 +531,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
d.logger.Info("registered runtime", "workspace_id", id, "runtime_id", rt.ID, "provider", rt.Provider)
}
d.mu.Lock()
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos)
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos, resp.Settings)
for _, rt := range resp.Runtimes {
d.runtimeIndex[rt.ID] = rt
}

View File

@@ -554,7 +554,7 @@ func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) {
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("seed repo cache: %v", err)
}
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
t.Fatalf("ensureRepoReady: %v", err)
@@ -576,7 +576,7 @@ func TestEnsureRepoReadyTrimsURL(t *testing.T) {
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("seed repo cache: %v", err)
}
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
// URL with trailing whitespace should still hit the fast path.
if err := d.ensureRepoReady(context.Background(), "ws-1", " "+sourceRepo+" "); err != nil {
@@ -604,7 +604,7 @@ func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) {
ReposVersion: "v2",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
t.Fatalf("ensureRepoReady: %v", err)
@@ -627,7 +627,7 @@ func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) {
ReposVersion: "v1",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git")
if !errors.Is(err, ErrRepoNotConfigured) {
@@ -646,7 +646,7 @@ func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) {
ReposVersion: "v1",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo)
if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") {
@@ -674,7 +674,7 @@ func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) {
ReposVersion: "v2",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
const concurrency = 8
var wg sync.WaitGroup

View File

@@ -158,11 +158,12 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
}
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID),
})
if err != nil {
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)

View File

@@ -32,21 +32,22 @@ func BuildPrompt(task Task) string {
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
// user typed a single natural-language sentence in the create-issue modal;
// the agent's only job is to translate it into one `multica issue create`
// CLI invocation. No issue exists yet, so the agent must NOT call
// `multica issue get` or attempt to comment — there's nothing to read or
// reply to.
// the agent's job is to translate it into one `multica issue create` CLI
// invocation, using its judgment to decide whether fetching referenced URLs
// would produce a better issue. No issue exists yet, so the agent must NOT
// call `multica issue get` or attempt to comment — there's nothing to read
// or reply to.
func buildQuickCreatePrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
b.WriteString("Field rules:\n")
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
b.WriteString("- description: stay faithful to the user's original input — do NOT invent requirements, design decisions, implementation plans, or constraints that the user did not express. The description should enrich the user's input with factual context only: if the input contains URLs or references (PRs, issues, docs), fetch them and summarize the relevant parts. Restate the user's intent clearly so the executing agent understands the task, but do not expand scope or add made-up details. Keep it concise. Never echo the title here.\n")
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
b.WriteString("- assignee:\n")
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
agentName := ""
if task.Agent != nil {
agentName = task.Agent.Name

View File

@@ -327,11 +327,12 @@ func setFetchRefspec(barePath, refspec string) error {
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
type WorktreeParams struct {
WorkspaceID string // workspace that owns the repo
RepoURL string // remote URL to look up in the cache
WorkDir string // parent directory for the worktree (e.g. task workdir)
AgentName string // for branch naming
TaskID string // for branch naming uniqueness
WorkspaceID string // workspace that owns the repo
RepoURL string // remote URL to look up in the cache
WorkDir string // parent directory for the worktree (e.g. task workdir)
AgentName string // for branch naming
TaskID string // for branch naming uniqueness
CoAuthoredByEnabled bool // install prepare-commit-msg hook for Co-authored-by trailer
}
// WorktreeResult describes a successfully created worktree.
@@ -401,6 +402,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: existing worktree updated",
"url", params.RepoURL,
"path", worktreePath,
@@ -426,6 +434,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: worktree created",
"url", params.RepoURL,
"path", worktreePath,
@@ -650,6 +665,58 @@ func bareHeadBranch(barePath string) string {
return ref
}
// prepareCommitMsgHook is the prepare-commit-msg hook script that appends a
// Co-authored-by trailer for the Multica Agent to every commit message.
const prepareCommitMsgHook = `#!/bin/sh
# Multica: add Co-authored-by trailer for the Multica Agent.
# Installed by the Multica daemon. Do not edit — it will be overwritten.
COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"
# Skip merge and squash commits.
case "$COMMIT_SOURCE" in
merge|squash) exit 0 ;;
esac
TRAILER="Co-authored-by: multica-agent <github@multica.ai>"
# Don't add if already present.
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
exit 0
fi
# Use git interpret-trailers for proper formatting.
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
`
// installCoAuthoredByHook installs a prepare-commit-msg git hook that appends
// a Co-authored-by trailer for the Multica Agent. The hook is installed in the
// git common directory (the bare repo for worktrees) so it applies to all
// worktrees created from this cache.
func installCoAuthoredByHook(worktreePath string) error {
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-common-dir")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("resolve git common dir: %w", err)
}
commonDir := strings.TrimSpace(string(out))
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(worktreePath, commonDir)
}
hooksDir := filepath.Join(commonDir, "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return fmt.Errorf("create hooks dir: %w", err)
}
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
if err := os.WriteFile(hookPath, []byte(prepareCommitMsgHook), 0o755); err != nil {
return fmt.Errorf("write prepare-commit-msg hook: %w", err)
}
return nil
}
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
func excludeFromGit(worktreePath, pattern string) error {
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")

View File

@@ -883,6 +883,97 @@ func TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault(t *testing.T) {
}
}
// TestCreateWorktreeInstallsCoAuthoredByHook verifies that CreateWorktree
// installs a prepare-commit-msg hook that appends a Co-authored-by trailer
// for the Multica Agent to every commit made in the worktree.
func TestCreateWorktreeInstallsCoAuthoredByHook(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "a1b2c3d4-0000-0000-0000-000000000000",
CoAuthoredByEnabled: true,
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
// Make a commit in the worktree and verify the hook appends the trailer.
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
runGitAuthored(t, result.Path, "add", ".")
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
// Read the commit message.
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
commitMsg := string(out)
expectedTrailer := "Co-authored-by: multica-agent <github@multica.ai>"
if !strings.Contains(commitMsg, expectedTrailer) {
t.Errorf("commit message missing Co-authored-by trailer.\ngot:\n%s", commitMsg)
}
}
// TestCoAuthoredByHookIdempotent verifies that the hook does not add a
// duplicate Co-authored-by trailer if one is already present in the message.
func TestCoAuthoredByHookIdempotent(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "b2c3d4e5-0000-0000-0000-000000000000",
CoAuthoredByEnabled: true,
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
// Commit with the trailer already in the message.
trailer := "Co-authored-by: multica-agent <github@multica.ai>"
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
runGitAuthored(t, result.Path, "add", ".")
runGitAuthored(t, result.Path, "commit", "-m", "test commit\n\n"+trailer)
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
commitMsg := string(out)
// Count occurrences — should appear exactly once.
count := strings.Count(commitMsg, trailer)
if count != 1 {
t.Errorf("expected exactly 1 Co-authored-by trailer, found %d.\ngot:\n%s", count, commitMsg)
}
}
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
// entries, none match the common defaults, and none match the bare HEAD

View File

@@ -342,10 +342,19 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
})
repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos)
// Include workspace settings so the daemon can honour feature toggles
// (e.g. co_authored_by_enabled for the prepare-commit-msg hook).
var settings json.RawMessage
if len(ws.Settings) > 0 {
settings = json.RawMessage(ws.Settings)
}
writeJSON(w, http.StatusOK, map[string]any{
"runtimes": resp,
"repos": repoResp.Repos,
"repos_version": repoResp.ReposVersion,
"settings": settings,
})
}