The card-vs-collapse threshold used `>` so 4 PRs slipped past it and
all rendered as full cards, contrary to RFC v3 (N >= 4 collapses to
3 cards + compact tail). Switch to `>=` and update the threshold-
boundary test to expect "Show 1 more".
Co-authored-by: multica-agent <github@multica.ai>
Replaces the row + badge layout under "Pull requests" on the issue
detail sidebar with a card that mirrors the GitHub PR summary look:
title, author/avatar, +N −M · K files diff stats, segmented progress
bar (failed → pending → passed, failure leftmost), and a one-line
status caption following an explicit priority pass-through.
Backend
- Migration 092: github_pull_request adds additions / deletions /
changed_files (INT NOT NULL DEFAULT 0). Zero defaults are what the
new frontend treats as "legacy backend — hide the stats row" so old
PR rows that pre-date this migration don't render "+0 −0 · 0 files".
- pull_request webhook handler reads stats off the top-level payload.
- ListPullRequestsByIssue now surfaces per-suite counts
(checks_passed / failed / pending) alongside the existing aggregate
conclusion, so the segmented bar reuses the already-computed counts
with no new aggregation.
Frontend (packages)
- core/github/pull-request-status.{ts,test.ts}: pure-function module
for the status-kind priority table and the segment derivation; 15
cases covered, includes the "all-zero → hide stats" guard.
- views/issues/components/pull-request-list.tsx: PullRequestCard plus
a compact-row fallback used when count > 4 (first 3 as cards, the
remainder collapsed behind a Show more toggle).
- i18n: new `pull_request_card_*` keys in en + zh-Hans.
Tests
- 12 component tests covering each rule of the priority table, the
legacy-zero stats fallback, and the collapse threshold.
- Reuse of the v3 webhook handler tests confirmed.
Verification
- pnpm typecheck + pnpm test green (60 test files, 536 tests).
- go build ./... + go vet ./... clean.
- 6 demo issues (DEV-2..DEV-7) screenshotted via Playwright; see the
PR comments for the visual check matrix.
Co-authored-by: multica-agent <github@multica.ai>
Addresses code review on PR #2632.
1. check_suite handler now resolves the PR through the workspace-scoped
GetGitHubPullRequest query instead of GetGitHubPullRequestByRepoNumber.
The (workspace_id, repo_owner, repo_name, pr_number) tuple is the real
uniqueness key, so a bare (owner, repo, number) lookup could return a
stale row from another workspace and either land the suite on the wrong
PR or skip the right one when the installation ids drifted. The old
unscoped query is removed.
2. derivePRMergeableState now returns (value, clear) and the upsert SQL
distinguishes three cases: state-changing actions clear the column to
NULL, non-empty payloads write the value, and metadata events with an
empty payload preserve the existing column. Previously every empty
payload became NULL, so a labeled/assigned event silently wiped a
known clean/dirty verdict in violation of the RFC's "metadata empty
payload preserves" rule.
3. ListPullRequestsByIssue narrows to the issue's PR ids before running
the per-app check_suite aggregation, avoiding a full-table scan over
github_pull_request_check_suite when only a handful of rows belong to
the requested issue.
New helper test covers labeled+empty preserves; new integration test
verifies a metadata event after a known mergeable_state keeps the value.
Co-authored-by: multica-agent <github@multica.ai>
Surface "checks passed/failed" and "conflicts/no conflicts" badges under
each linked PR on the issue page so users can judge readiness without
flipping over to GitHub. CI state is fed by check_suite webhooks
(GitHub Actions + apps using the Checks API; legacy status events are
out of scope for MVP); conflicts are read from pull_request.mergeable_state.
Data model:
* github_pull_request: add head_sha + mergeable_state
* github_pull_request_check_suite: per-suite rows keyed by (pr_id, suite_id)
* Aggregation done at query time, filtering by current head_sha so
late-arriving suites for a stale head can't contaminate the new head's
pending view; per-app latest suite chosen first so a single app firing
multiple suites isn't counted N times.
Webhook hardening:
* synchronize/opened/reopened/edited(base) explicitly clear mergeable_state
* single-row ordering protection on the check_suite upsert prevents a
late-delivered older event from overwriting a newer one
* check_suite.pull_requests is iterated; unknown PRs are logged and dropped
UI:
* PR row shows Checks + Conflicts badges; opaque mergeable values
(blocked/behind/unstable/...) render as no badge, not as conflicts.
* Terminal PR states (merged/closed) suppress the status row entirely.
Tests: * Pure unit coverage for derivePRMergeableState + aggregateChecksConclusion
* Webhook integration tests: multi-app aggregation, old-head ignore,
late-older-event ignore, synchronize clears mergeable_state
* Vitest coverage for pull-request-list badge rendering across CI/conflict
combinations and the legacy (null) fallback.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): silent background auto-download for updates (MUL-2224)
Flip electron-updater to autoDownload=true so new releases are pulled in
the background without user action; the UI now only surfaces a
"ready to install" prompt once the package is fully downloaded.
- updater.ts: autoDownload=true; update-downloaded forwards version +
releaseNotes; single-flight guard around checkForUpdates() so startup,
periodic, and manual triggers don't pile up overlapping downloads.
- preload: update-downloaded payload now carries { version, releaseNotes? }.
- update-notification.tsx: drop available/downloading UI; ready state has
Later / Restart now and renders the version from the download event.
- updates-settings-tab.tsx: settings copy now describes background download
+ restart prompt instead of a download prompt.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): swallow unhandled downloadPromise rejection in updater (MUL-2224)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(execenv): native OpenClaw skill discovery via per-task config
MUL-2213 stopped lying about native discovery and routed openclaw skills
to .agent_context/skills/ — a path openclaw's scanner never reads.
Multica skills attached to openclaw-backed agents were still invisible to
the runtime; the AGENTS.md fallback was only a documentation patch.
OpenClaw's skill scanner walks <workspaceDir>/skills/ (plus a few other
roots), and workspaceDir is resolved from the openclaw config file —
specifically agents.list[id].workspace → agents.defaults.workspace →
~/.openclaw/workspace. There is no CLI flag or env var override on the
agent runtime; the only knob is the config file.
This change wires a per-task synthesized config:
1. execenv.prepareOpenclawConfig deep-copies the user's existing
openclaw.json (priority: $OPENCLAW_CONFIG_PATH, else
~/.openclaw/openclaw.json), rewrites agents.defaults.workspace AND
every agents.list[].workspace to the task workdir, and writes the
result to {envRoot}/openclaw-config.json. Provider sections,
registered agents, model providers, gateway settings — everything
openclaw needs to actually start — are preserved as-is.
2. resolveSkillsDir for "openclaw" now points at {workDir}/skills/,
which is the first path openclaw scans under workspaceDir. Skills
written here are picked up natively.
3. daemon.go exports OPENCLAW_CONFIG_PATH={env.OpenclawConfigPath} on
the openclaw subprocess and adds OPENCLAW_CONFIG_PATH to the
custom_env blocklist so users cannot accidentally override it.
4. buildMetaSkillContent now lists openclaw alongside the
"discovered automatically" providers; the .agent_context/skills/
fallback line stays for gemini/hermes.
The new regression test TestPrepareOpenclawSkillWriteMatchesScanPath is
the one MUL-2219's DoD calls out: it resolves the workspaceDir the way
openclaw does (reading agents.defaults.workspace out of the synthesized
config) and proves {workspaceDir}/skills/<name>/SKILL.md is what Multica
actually wrote. The pre-MUL-2219 fix asserted "we wrote a file" without
checking the scanner would ever see it — which is how the dead drop into
.openclaw/skills/ landed in #2621's first commit.
Verified locally: minimum-viable synthesized config validates via
`openclaw config validate`, and `OPENCLAW_CONFIG_PATH=<path> openclaw
config get agents.defaults.workspace` returns the task workdir as
expected. MUL-2219
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): delegate openclaw config parsing to CLI and fail closed
Address Elon's must-fix on PR #2628: the previous implementation parsed
~/.openclaw/openclaw.json with encoding/json, which cannot read JSON5
or follow $include — the OpenClaw spec's actual format. When parsing
failed, prepareOpenclawConfig silently emitted a minimal config, which
could boot OpenClaw without the user's registered agents, model
providers, or API keys.
Two changes:
1. Delegate active-config-path resolution and config reading to the
openclaw CLI itself. `openclaw config file` locates the active
config (covering OPENCLAW_CONFIG_PATH / OPENCLAW_STATE_DIR /
OPENCLAW_HOME / default and the legacy chain), and the wrapper we
write uses $include to point at it so OpenClaw's own loader handles
JSON5, $include nesting, env-substitution, and secret refs. We read
only agents.list via `openclaw config get --json` to rewrite each
entry's workspace — secrets, comments, and includes in the user
config are never touched.
2. Remove the silent minimal-config fallback. Any CLI failure,
malformed output, or write error now surfaces as a hard error from
Prepare / Reuse. The only "synthesize minimal" path left is a fresh
install (CLI reports a path but the file doesn't exist), where
there is no user data to lose.
The per-task override still rewrites every agents.list[].workspace,
not just agents.defaults.workspace — this is intentional task
isolation, documented in prepareOpenclawConfig and the PR body. A
host-scope per-agent workspace would otherwise silently route the
scanner back to the user's shared workspace.
Cleanups Elon flagged in the same review:
- daemon.go inline-system-prompt comment no longer claims openclaw
ignores the task workdir; it does load it now, and the inline brief
is a belt-and-suspenders carryover for older releases.
- execenv.go openclaw block no longer references "skill file paths in
the inline brief" — the brief uses "discovered automatically".
Reuse() switches to a ReuseParams struct so the openclaw binary path
threads through alongside CodexVersion without a 6th positional arg.
MUL-2219
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): grant OpenClaw $include cross-dir confinement for per-task wrapper
The per-task wrapper at envRoot/openclaw-config.json $includes the user's
active config (typically ~/.openclaw/openclaw.json), but OpenClaw confines
$include resolution to the wrapper file's directory unless the target's
parent is granted via OPENCLAW_INCLUDE_ROOTS. Without this, OpenClaw refuses
to follow the link at runtime and the wrapper boots with no user-registered
agents.
prepareOpenclawConfig now returns dirname(activePath) as IncludeRoot, and
the daemon prepends it to whatever the user already has in
OPENCLAW_INCLUDE_ROOTS via the new composeOpenclawIncludeRoots helper
(dedupes, drops empty segments, preserves user-configured roots). Fresh
install emits no $include and leaves the env var untouched.
Adds OPENCLAW_INCLUDE_ROOTS to the custom_env blocklist so a per-agent
override cannot strip the granted root.
Regression tests:
- TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement asserts
every $include target's dirname is covered by the IncludeRoot we surface.
- TestPrepareEnvironmentOpenclawWiresIncludeRoot covers the non-fresh-install
Environment wiring.
- TestComposeOpenclawIncludeRoots covers the daemon-side env composition
(preserve, dedupe, drop empties).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The RUNTIME cell rendered base name + (hostname) with both spans using
flex: 0 1 auto, so the longer hostname dominated and squashed the name
to a single letter. Give the base name shrink priority and let the
hostname own the flex slot with basis-0, so hostname truncates first
while the name stays readable.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): wake leader when dual-role agent posts as worker (MUL-2218)
The squad-leader self-trigger guard skipped a comment whenever the
author equalled the squad's leader id, regardless of the role the agent
was acting in. For an agent that holds both leader and worker roles in
the same squad, this meant the leader role never reacted to its own
worker output and the issue stalled.
Tag each enqueued task with is_leader_task and consult the agent's
most recent task on the issue from both self-trigger guards (comment
path + @squad mention path) — skip only when that task was itself a
leader task.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): inherit is_leader_task on retry task clone (MUL-2218)
CreateRetryTask cloned a parent task into a fresh queued attempt but
omitted is_leader_task from the column list, so the child silently fell
back to the column default (false). For a leader task that hit auto-retry
through MaybeRetryFailedTask, the retried task posed as a worker task —
the self-trigger guard then no longer recognised the leader's own
comments, re-opening the very loop MUL-2218 closes.
Inherit p.is_leader_task in the clone and add a query-level test that
covers both leader and worker retries.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-2215: fix(daemon): close handleRuntimeGone success/straggler race
handleRuntimeGone coalesced concurrent recoveries with a per-workspace
`reregisterNextAttempt` slot that was deleted immediately on success. A
late-arriving goroutine whose `removeStaleRuntime` was delayed by mutex
contention could reach the coalesce gate after the winner cleared the
slot, observe no slot, re-claim, and double-register — the source of the
intermittent `register endpoint called 2 times under stampede, want 1`
failure on PR #2348.
The slot delete on success is intentional (a genuinely later distinct
deletion in the same workspace must register again, validated by
TestHandleRuntimeGone_DistinctDeletionsWithinCoalesceWindowBothRecover),
so we can't just extend the slot's lifetime.
Add a second per-workspace gate: `reregisterLastCompletedAt`. Every call
captures `entryAt` at the top of handleRuntimeGone; at the coalesce gate
a caller bails if `lastCompletedAt >= entryAt`, i.e. a peer's register
completed AFTER we entered the function. Same-wave stragglers bail
deterministically; distinct later events have `entryAt > lastCompletedAt`
and proceed.
Extracted the gate into `tryClaimRegisterSlot` / `recordRegisterCompletion`
so the race can be exercised deterministically with synthetic timestamps
instead of relying on `-count=N` to win the scheduling lottery.
- TestHandleRuntimeGone_CoalescesConcurrentCallers: -count=500 -race
clean (previously intermittent).
- New unit tests cover the straggler bail, the distinct-later-event
claim, failure backoff suppression, and peer-holds-slot coalescing.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-2215: narrow completion stamp to success path
Second review caught that recordRegisterCompletion stamped
lastCompletedAt on both success and failure. A failed register has not
covered any workspace state, so a same-wave straggler whose entryAt
predates the failure must be allowed to retry once the failure backoff
expires — the previous behavior would let the failure-time stamp also
hide that straggler. workspaceSyncLoop only retries when a workspace's
runtimeIDs fully drain, so partial-deletion recovery has to come from
the straggler path.
Failure path now only updates reregisterNextAttempt; success path keeps
its existing stamp + slot clear. Add a regression test covering the
entryAt-before-failed-completion / arrival-past-backoff edge.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): write OpenClaw skills to .openclaw/skills/ for native discovery
The OpenClaw provider was missing a case in resolveSkillsDir, so workspace
skills attached to OpenClaw-backed agents fell through to .agent_context/
skills/ — a path the openclaw CLI never inspects. The result: agents
created against the OpenClaw runtime saw zero of their loaded Skills in
chat or task runs, even though the meta AGENTS.md content advertised
them as auto-discovered.
Mirrors the same per-provider mapping already in place for OpenCode,
Copilot, Pi, Cursor, Kimi, Kiro. Also adds .openclaw to the repocache
git-exclude list so the per-task skills directory does not pollute
checked-out repos. MUL-2213
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): drop .openclaw/skills dead-drop write; flag openclaw as non-auto-discovery
Reviewer (Elon) pointed out that {workDir}/.openclaw/skills/ is not in any
OpenClaw skill discovery path. Confirmed by reading openclaw upstream
(src/agents/skills/refresh.ts, src/agents/agent-scope-config.ts,
src/cli/program/register.agent.ts):
- OpenClaw scans <workspaceDir>/skills, <workspaceDir>/.agents/skills,
~/.openclaw/skills, ~/.agents/skills, bundled, and config
skills.load.extraDirs.
- workspaceDir is resolved from the openclaw config (per-agent
workspace -> agents.defaults.workspace -> ~/.openclaw/workspace).
It is NOT the cwd of the openclaw process.
- There is no --workspace CLI flag on 'openclaw agent', and no
OPENCLAW_WORKSPACE env var consumed at runtime. The only knob is the
config file.
So {workDir}/.openclaw/skills/ written by Multica is never seen by the
openclaw runtime, and the meta AGENTS.md was lying to the agent by
claiming auto-discovery. Reverts:
- resolveSkillsDir: drop the openclaw case; falls back to
.agent_context/skills/ (same path as hermes).
- agentGitExcludePatterns: drop .openclaw; nothing is written there now.
Also updates the openclaw branch in buildMetaSkillContent to point the
agent at .agent_context/skills/ explicitly (alongside gemini/hermes), so
loaded skills are at least referenced by path in the AGENTS.md context.
The openclaw native loader still won't see them as installed skills.
Native auto-discovery for openclaw needs per-task workspace integration
(e.g. synthesized per-task config via OPENCLAW_CONFIG_PATH that overrides
agents.defaults.workspace, or resolving the agent's actual configured
workspace at exec time) — tracked as follow-up. MUL-2213
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-2216: feat(agents,squads): persist Mine/All tab selection per workspace
Tab selection on the Agents and Squads list pages was held in
component-local state, so navigating into a detail page and back
remounted the list and reset the tab to the default "Mine". Move
`scope` into Zustand stores backed by `persist` +
`createWorkspaceAwareStorage`, matching the pattern used by the
Issues view store. Selection now survives list → detail → back
navigation and page reloads, scoped per workspace.
Only `scope` is persisted; `search`, `sort`, and other ephemeral
filters intentionally still reset on remount.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): reset scope to mine when switching to a workspace with no persisted value
zustand persist.rehydrate() is a no-op when storage returns null, so
workspaces with no entry kept the previous workspace's in-memory scope
("all" leaked from one workspace into the next). Provide a custom merge
that resets to the default "mine" when no persisted state is present.
Add coverage for the missing-storage workspace-switch case for both
Agents and Squads.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(settings): view/edit toggle for repositories tab
Saved repos render as static rows (truncated, monospace) with hover/focus-revealed
Edit + Delete affordances. Clicking Edit flips to the existing Input; on
successful Save the row returns to display mode. Save button is gated on a
dirty check (URL arrays in order) so a clean state reads as "All changes
saved". Resolves user feedback that the always-visible input made saved
state ambiguous (MUL-2217).
- Track editingIndices with a Set; new rows auto-enter edit mode; deleting
a row remaps indices so the wrong row never opens.
- Touch devices and focus-within keep the action buttons reachable.
- New i18n keys in en + zh-Hans (saved_hint, empty, edit/delete_aria, url_empty).
Co-authored-by: multica-agent <github@multica.ai>
* fix(settings): add Cancel affordance to exit clean edit mode
Clicking Edit on a clean saved row opened the row in edit mode with
no way back to display mode unless the user changed the URL and saved,
re-introducing the original saved-state ambiguity after an accidental
click. Add a per-row Cancel (X) button visible only in edit mode that:
- reverts the URL to the saved value for existing rows
- removes the row entirely for never-saved (newly added) rows
- exits edit mode without dirtying Save
Action group is always visible (no hover gate) while editing so the
exit is discoverable. Adds en/zh-Hans cancel_aria string and three
regression tests covering clean-cancel, dirty-cancel, and new-row-cancel.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
- Add Squads to Features list (EN/zh) highlighting team-level agent routing
- Add a short Squads callout to the 'What is Multica?' section
- Remove the outdated 'Multica vs Paperclip' section from both READMEs
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): resolve agent CLIs via login shell when daemon PATH misses them
GUI-launched daemons on macOS/Linux do not inherit the user's interactive
shell PATH, so fnm/nvm/volta multishells and the Anthropic native installer
silently disappear during onboarding even though `claude --version` works
in Terminal. Fall back to `$SHELL -ilc` to ask the login shell for the
canonical absolute path, then verify it with exec.LookPath before trusting
it. Symlinks (fnm/nvm prefix dirs) are resolved while the helper shell is
still alive so per-session paths get canonicalised before they vanish.
Refs MUL-2167, multica-ai/multica#2512.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): strip alias shadowing, harden timeout, lazy-resolve via login shell
Three follow-ups from the PR #2620 review (Elon):
1. Alias shadowing — `command -v claude` in zsh/bash returns the alias
definition, not the binary, and the absolute-path filter then rejects it.
The script now `unalias`/`unset -f` the name before lookup so `command -v`
falls through to the real PATH binary. This is the exact case behind
#2512.
2. Hard timeout — `CommandContext` kills only the shell process. Rc files
that background processes inheriting stdout (`direnv hook`, `nvm` shims,
plain `&`) keep the pipe open and `cmd.Output()` would block for as long
as the survivors live. `Cmd.WaitDelay` forcibly closes the pipes once
the cap elapses, so total startup penalty is bounded by
`timeout + waitDelay` regardless of rc-file content.
3. Lazy fallback — the resolver no longer runs on every daemon start.
`getShellResolved` is `sync.Once`-guarded and only fires when a bare
command name actually misses `exec.LookPath`. Users whose PATH already
contains every agent never pay the rc-file load cost.
Tests: - `TestResolveAgentsViaLoginShell_StripsAliasShadowing` — rc declares
`alias fakeclaude=...`, real binary lives on PATH, resolver must
return the binary, not the alias text.
- `TestResolveAgentsViaLoginShell_HardTimeoutOnBackgroundedStdout` —
rc backgrounds a 60s sleeper holding stdout; resolver must return
inside `timeout + waitDelay + slack`, not 60s.
- `TestLoadConfig_SkipsLoginShellWhenLookPathSucceeds` — when
exec.LookPath finds every agent, SHELL (a marker-writing sentinel)
must not be invoked.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): file-card render for self-host with local storage
Fixes#1520. When self-hosting without S3, the upload handler returns
site-relative URLs like /uploads/workspaces/<wsId>/<file>. Four
frontend regexes only matched https?://, so persisted
!file[name](/uploads/...) markdown failed to parse and leaked through
as raw text in the issue view, chat, skill file viewer, and board
card preview.
Narrow allow-list: the relative branch only accepts /uploads/ — not
any /-prefixed href — so protocol-relative //evil.com/x, path-traversal
/../api/x, and other internal /api/... paths are rejected. Without
this, a stored file-card with an attacker-chosen filename and a
//host/x href would turn into a one-click external-site jump via
window.open from inside an issue (per review feedback on #2349).
Single source of truth: packages/ui/markdown/file-cards.ts now exports
isAllowedFileCardHref + FILE_CARD_URL_PATTERN. The four sites use one
of them, so the next regression is cheaper than restoring four parallel
regexes.
- packages/ui/markdown/file-cards.ts: helper + URL pattern.
- packages/views/editor/extensions/file-card.tsx: Tiptap tokenizer
composes from FILE_CARD_URL_PATTERN.
- packages/views/editor/readonly-content.tsx: sanitiser uses helper.
- packages/ui/markdown/Markdown.tsx: sanitiser uses helper.
- packages/views/issues/components/board-card.tsx: strip markdown
tokens from the line-clamped board preview so raw !file[...] no
longer leaks there either.
- packages/ui/markdown/file-cards.test.ts: covers accept (/uploads/ok,
https://cdn/x) and reject (javascript:, data:, //evil.com/x,
/../api/x, /api/x, empty, ftp:, bare 'uploads/x') for both the
helper and the parser composed from the pattern.
javascript:, data:, and other dangerous schemes remain rejected.
* test(markdown): move file-card href allow-list test into @multica/views
Per review feedback on #2349: keep the test where vitest is already
running instead of bootstrapping a new test runner inside @multica/ui.
The test now lives at packages/views/editor/file-card-href.test.ts and
imports isAllowedFileCardHref / FILE_CARD_URL_PATTERN /
preprocessFileCards from the @multica/ui/markdown public surface,
exercising the same 30 cases.
Reverts the @multica/ui package.json test script + vitest devDep + the
local vitest.config.ts that the previous commit added; the package
goes back to typecheck + lint only, matching every other ui-only
package in the monorepo.
---------
Co-authored-by: Lalbadshah <11599756+Lalbadshah@users.noreply.github.com>
Rename the Deployment type dropdown options to Official App and
self-host so reporters pick the right one without guessing.
MUL-2212
Co-authored-by: multica-agent <github@multica.ai>
* refactor(agents): drop template chooser from create-agent dialog
Removes the blank-vs-template chooser, the template picker, and the
template detail step. The "Create agent" entry point now opens directly
on the form. The createAgentFromTemplate API and types remain
untouched — this only removes the UI entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(squads): fix stale comment about createAgentFromTemplate
Squad-scoped create flow no longer goes through the template path;
the dialog now only calls api.createAgent then api.addSquadMember.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Adds a dedicated bilingual /docs/squads page covering the squad model
(leader + members), assignment, comment trigger rules, archive
semantics, and the squad CLI surface. Wires the new page into
meta.json and meta.zh.json under the Agents section, and adds
short cross-references from agents, assigning-issues,
mentioning-agents, and the CLI reference so users can discover
squads from the pages they're already on.
MUL-2206
Co-authored-by: multica-agent <github@multica.ai>
When the user opens quick-create with a squad selected, the task is
enqueued against the squad's leader agent — but the squad, not the
leader, is the expected owner. The prompt previously instructed the
leader to "default to YOURSELF" using its own agent UUID, hiding new
issues from the squad's delegation flow.
Surface the squad's id + name on the claim response and branch the
default-assignee instruction in buildQuickCreatePrompt: when SquadID is
present, point --assignee-id at the squad UUID and explicitly forbid
self-assignment.
MUL-2203
Co-authored-by: multica-agent <github@multica.ai>
* feat(squads): add agent live peek hover card on member avatars
Squad members tab now opens a live-state peek card on agent avatar
hover/focus — workload, current issue (clickable), and last activity.
Identity (description / runtime / skills / owner) stays on the existing
AgentProfileCard; new AgentLivePeekCard is the second `hoverCardVariant`
on ActorAvatar so the 23+ existing profile-card call sites keep their
behaviour. Reuses the workspace agent-task snapshot already fetched by
the presence dot, so this adds zero new requests per row. Failed
terminal tasks surface as a small ⚠ on the last-activity line without
polluting workload (workload stays current-state only, matching the
deliberate split documented in core/agents/types.ts).
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): only enable hover card for agent avatars
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Exposes the existing /api/tasks/{id}/cancel backend endpoint as a CLI
command. Combined with upstream #2107 (cancel running agent on
server-side task delete), this gives operators a way to interrupt a
runaway agent push-storm without resorting to admin-bypass on the
downstream PR.
Use cases:
- Titan / DevBot iterating beyond its boundary (e.g. push-skip loops)
- Codex turn that locked in tool-call spam
- Manual recovery when a long-running task needs to stop NOW
Symmetric with 'issue rerun': accepts the short ID prefix shown by
'issue runs', supports --issue scoping, and reuses resolveTaskRunID
for ambiguity handling.
Refs: PR#19 octo-server post-mortem (2026-05-13)
Co-authored-by: yujiawei <yujiawei@mininglamp.com>
* feat(squads): add tooltips and agent detail link to squad member row
Replace native title attributes on the make-leader and remove buttons
with proper Tooltip components, and add a new icon button on agent
rows that navigates to the agent detail page. All three tooltips are
localised.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): keyboard focus visibility + AppLink for agent detail
- Add group-focus-within:opacity-100 so Tab to the row's hover-only
action buttons makes the container visible (previously opacity-0
kept buttons focusable but invisible).
- Replace the agent-detail jump button's onClick+push() with AppLink
href, restoring middle/Cmd+Click new-tab behavior. Removes the
now-unused onViewAgent callback chain.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Drop mx-auto + max-w-2xl wrappers around the Members and Instructions
tab content so the right pane fills the available width like the agent
detail page (TabContent uses flex h-full flex-col p-4 md:p-6).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Text/code attachments (markdown, JSON, .ts, .log, …) need an attachment id
to render through `/api/attachments/{id}/content`. The composer pipeline
was dropping that id at the upload-hook boundary, so the Eye preview gate
only fired for media (PDF / video / audio via filename fallback).
- `useFileUpload` now returns the full `Attachment` (with `link` kept as a
`url` alias) so editor providers can resolve content-type and id.
- New-comment and reply composers hold a `pendingAttachments` state and
feed it to `ContentEditor`; the active subset (those still referenced in
the markdown) is sent on submit as before.
- Comment edit modes (CommentRow + CommentCardImpl) merge pending uploads
with `entry.attachments` for the editor and pipe `attachment_ids` into
`onEdit` so newly uploaded files actually bind to the comment.
- Issue description editor pushes pending `attachment_ids` on every
debounced save and invalidates `issueKeys.attachments` so the preview
Eye survives a refresh.
- `UpdateComment` and `UpdateIssue` handlers accept `attachment_ids` and
call the existing `linkAttachmentsByIDs` / `linkAttachmentsByIssueIDs`
helpers; the bind is idempotent so re-sending an existing id is safe.
Closes MUL-2153.
Co-authored-by: multica-agent <github@multica.ai>
* fix: trigger squad leader agent run when squad is @mentioned in comment
Previously, enqueueMentionedAgentTasks only processed m.Type == "agent"
mentions, skipping squad mentions entirely. The shouldEnqueueSquadLeaderOnComment
path only fires when the issue is already assigned to a squad.
This adds handling for m.Type == "squad" in enqueueMentionedAgentTasks:
when a squad is @mentioned, look up the squad's leader agent and enqueue
a task for them (with the same dedup/self-trigger/archived guards as
direct agent mentions).
Co-authored-by: multica-agent <github@multica.ai>
* fix: add canAccessPrivateAgent gate to squad mention branch
Closes the P1 permission vulnerability where a plain workspace member
could trigger a private squad leader by @mentioning the squad, bypassing
the private-agent access check that the direct @agent mention path
enforces.
Adds regression test TestCreateComment_SquadMentionPrivateLeaderBlocksPlainMember.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): rewrite template catalog as 25 lightweight starters
Replaces every Phase-1 template with a curated set built around the
"persona + intake + scaffold + hard negatives" instruction shape. Cross-
platform survey (Cursor / Cline / Roo / Continue / Custom GPTs) showed
the industry baseline for starter agents is "few but sharp" — single
intent, no methodology buy-in, mostly prompt-only. The original catalog
went the opposite direction (avg 2.5 skills, six-skill Full-stack
methodology stack) and felt heavy for first-time use.
Catalog shape:
- 25 templates across 7 categories: Engineering (8), Product (4),
Writing (5), Design (3), Communication (2), Team (1), Productivity (2).
New Product / Design / Communication / Team domains fill gaps the old
Eng-heavy catalog ignored.
- 16 / 25 are prompt-only (no skill fan-out). Avg 0.56 skill per template
vs. 2.5 prior. Heaviest is 2 skills, only for templates whose intent
cannot be expressed in instructions alone (Playwright runner, single-
file HTML bundlers, design + UX-guidelines pair).
- Universal top-frequency intents that the old catalog missed are now
covered: Code Explainer (intent #1 across every platform surveyed),
Translator (中英), Summarizer, Writing Critic, PRD Drafter/Critic,
RCA Writer, ADR Writer, PR Description Writer, Commit Message Writer.
Loader allows 0-skill templates:
- server/internal/agenttmpl/loader.go drops the "must declare at least
one skill" validation; comment explains the picker's "Prompt only"
rendering path.
- loader_test.go: removed the corresponding negative case, added
TestLoadFromFS_PromptOnlyTemplate as a regression guard.
- agent_template.go handler is unchanged — every len(tmpl.Skills) call
site was already 0-safe (empty fan-out short-circuits the fetch phase
and the in-tx loop both skip cleanly).
Frontend:
- template-picker.tsx: 18 new lucide icons (BookOpen, Bug, GitPullRequest,
GitCommit, AlertTriangle, Scale, ClipboardList, Microscope, UserRound,
Target, Highlighter, Languages, AlignLeft, GraduationCap, Lightbulb,
Type, MessageSquare, Briefcase). Card renders a "Prompt only" badge
when skills.length === 0 instead of "0 skills".
- template-detail.tsx: skill list section is hidden entirely for prompt-
only templates — a header reading "Includes 0 skills" above an empty
list was just visual noise. Instructions section below carries the
agent's identity for these.
- locales/en + zh-Hans agents.json: new create_dialog.template_card.
prompt_only key ("Prompt only" / "纯指令").
Verification:
- go test ./internal/agenttmpl/ — 9/9 pass, including
TestLoad_RealTemplates which fails closed if any new JSON is malformed.
- pnpm typecheck — all 6 packages clean.
- pnpm --filter @multica/views test — 482/482 pass.
- pnpm lint — 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): add category filter pills to template picker
25 templates across 7 categories made the picker scroll-heavy on first
open. Add a single-select category filter row above the grid so a PM
can isolate Product templates in one click, an engineer can jump
straight to Engineering, etc.
Visual reuses the IssuesHeader scope-toggle pattern verbatim — Button
variant="outline" + active class swap (bg-accent / text-muted-foreground)
— so the affordance reads the same as the existing filter pills in
issues / squads / runtimes / my-issues. flex-wrap keeps the 8 pills
(All + 7 categories) honest on narrow widths.
Counts are inlined into the label ("Engineering (8)") rather than
shown as a separate badge — single-line-tall pills look right next to
the picker grid, and surfacing the per-category density up front
doubles as a hint at the catalog's "less but sharper" intent.
When a specific category is active, the grid renders flat (no
section headers) — the active pill already names what's on screen,
and a header reading "Engineering" above an only-Engineering grid is
visual duplication. "All" falls back to the prior grouped layout.
State is component-local (no URL sync, no persistence) since the
picker is dialog-internal transient state — closing the dialog
naturally resets the filter, which is the expected behaviour for a
"choose from a catalog" surface.
i18n: new `create_dialog.template_picker.filter_all` key in en + zh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Create Agent button on the Squad detail Members tab, visible
only to workspace owner/admin (matching the AddSquadMember backend
gate). The dialog reuses the existing CreateAgentDialog — both the
manual and template paths now accept an optional squadId; when set,
the dialog runs addSquadMember after createAgent / createAgentFromTemplate
and skips the navigation to the agent detail page so the user lands
back on the Members tab.
Atomicity is best-effort frontend-serial (no new backend transaction):
on partial failure the dialog surfaces a warning toast and the agent
remains addable from the existing Add Member flow.
Co-authored-by: multica-agent <github@multica.ai>
* fix: execution log name rendering and squad assignee support
- Strip mention markdown in trigger_summary ([@Name](mention://...) → @Name)
so execution log rows show clean text instead of raw markdown
- Add squad to ActorFilterValue type so squad assignees are filterable
- Add squad section to assignee filter dropdown in issues-header
- Add i18n keys for squads_group (en/zh-Hans)
Co-authored-by: multica-agent <github@multica.ai>
* fix: address PR #2575 review feedback
1. Extract stripMentionMarkdown as reusable helper with proper regex
- Handles escaped brackets in names (e.g. David\[TF\])
- Skips backslash-escaped mentions (\[@...])
- Handles issue mentions (no @ prefix)
- Does not touch regular markdown links
- 10 unit tests added
2. Squad only appears in Assignee filter, not Creator
- Added showSquads prop to ActorSubContent (default true)
- Creator filter passes showSquads={false}
3. Squad included in Agents scope
- issues-page scope filter now includes squad in agents scope
- 2 regression tests added for scope coverage
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The per-turn prompt in buildCommentPrompt() only injected the squad
leader no_action prohibition inside the 'if TriggerAuthorType == agent'
block. When a member (human) posted a comment like 'LGTM', the squad
leader was triggered but the per-turn prompt did NOT include the
prohibition, causing the model to post noise comments like 'LGTM is a
pure acknowledgment — no reply needed. Exiting silently.'
Fix: move the squad leader no_action rule outside the agent-only block
so it fires for ALL trigger types (agent and member).
Fixes: MUL-2168
Co-authored-by: multica-agent <github@multica.ai>
* feat: support pinyin search in @mention suggestions
Add pinyin matching for Chinese names in the mention suggestion popup.
Users can now search by:
- Full pinyin: 'liyunlong' matches '李云龙'
- Initial letters: 'lyl' matches '李云龙'
- Partial/hybrid: 'liyu' or 'liyunl' matches '李云龙'
Implementation:
- New pinyin-match.ts utility using pinyin-pro library
- Integrated into member, agent, and squad filters in mention-suggestion.tsx
- 21 tests passing (9 unit + 12 integration)
Co-authored-by: multica-agent <github@multica.ai>
* fix: normalize ü→v in pinyin matching for names like 吕布
Enable pinyin-pro's v:true option so 吕→lv instead of lü.
Add test case for 吕布/lvbu matching.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
PR #2564 only added IsSquadLeader handling to the assignment-triggered
workflow path and the Output section. When a squad leader is triggered by
a comment (the common case for re-evaluation), the comment-triggered
workflow path had NO squad leader special handling, so the model still
posted comments announcing no_action/silence.
Changes:
- runtime_config.go: Add IsSquadLeader check to comment-triggered step 4
with explicit prohibition against posting no_action announcement comments
- runtime_config.go: Strengthen Output section from 'may exit silently' to
'MUST exit without posting any comment' with explicit DO NOT examples
- runtime_config.go: Strengthen assignment-triggered step 5 similarly
- prompt.go: Add squad leader no_action rule to per-turn comment prompt
when trigger author is an agent and agent instructions contain the
Squad Operating Protocol marker
- Add tests for both the per-turn prompt and CLAUDE.md generation
Fixes MUL-2168
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): skip leader on comment when a member @mentions any agent (MUL-2170)
When a human commenter routes an issue directly at a specific agent via
[@Name](mention://agent/<id>), the squad leader was still being woken up
to evaluate the same comment. The leader's only real options were to
re-delegate to the agent the member already named or to record
no_action — both of which produce queue noise without changing the
outcome.
This skips the leader-enqueue path entirely when:
- the assignee is a squad,
- the comment author is a member, AND
- the comment body contains at least one agent mention.
Agent-authored comments are intentionally exempt: when an agent posts
an update that @mentions another agent, the leader still needs to
coordinate the thread. The existing leader-self-trigger guard is
preserved. Only the current comment's body is inspected — parent
(thread root) mentions are not inherited here.
Tests cover the helper (mentions parsing) plus the integration matrix:
member plain / member @member / member @non-leader-agent /
member @leader / agent @agent / leader-self.
Co-authored-by: multica-agent <github@multica.ai>
* test(squad): exercise full CreateComment path for leader-skip rule (MUL-2170)
Adds an integration test that drives the HTTP-layer CreateComment handler
(not just the helper) to lock the call-site wiring: a member top-level
comment with an @agent skips the squad leader, and a subsequent plain
reply in the same thread DOES wake the leader — the parent's @agent
mention must not be inherited into the leader-skip decision.
Picks up a non-blocking review note on PR #2569.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squad): skip leader on any explicit member mention, not only @agent (MUL-2170)
Broaden the leader-skip rule for squad-assigned issues: a member comment
that explicitly @mentions anyone — @agent, @member, @squad, or @all —
counts as deliberate routing and the squad leader stays out. Issue
cross-references (mention://issue/...) are not routing and still trigger
the leader as before.
Per Bohan's follow-up on MUL-2170 — @member should suppress the leader
for the same reason @agent does: the human has already pointed at a
specific recipient, so a leader turn would just be observation noise.
Helper renamed commentMentionsAnyAgent → commentMentionsAnyone with
explicit handling of all four routing mention types. Existing call-site
wiring (current-comment-only, agent-author exemption, leader self-trigger
guard) is unchanged.
Tests updated and extended to cover the full routing matrix:
@member / @squad / @all / @issue (cross-ref) plus the @agent variants
already covered.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The Eye button required a fully resolved Attachment record (URL-lookup
via `resolveAttachment(href)`) before showing. Download only required
the URL, falling back to `openExternal(href)` when the lookup missed.
Result: any case where the URL in markdown couldn't be reverse-matched
to the entity's `attachments` prop (cross-comment copy-paste, stale
caches) silently hid the Preview button while Download kept working —
edit and readonly surfaces diverged for the same content.
Widen the Preview gate to mirror Download: show the Eye whenever the
filename indicates a previewable type. Introduce a `PreviewSource`
tagged union — `{ kind: "full", attachment }` for the existing path,
`{ kind: "url", url, filename }` for the fallback. Media kinds
(pdf/video/audio) render directly from the URL; text kinds still
require an attachment id because the /content proxy is ID-keyed, so
`tryOpen` rejects URL+text combinations and PreviewContent has a
defensive fallback for direct mounts.
Side effects:
- `getPreviewKind` gains filename-extension fallbacks for video/audio
(was PDF-only); without these the URL-only path can't infer kind
when content_type is empty.
- AttachmentList in comment-card.tsx unchanged behaviorally — only the
tryOpen call site is updated to the new signature.
Pre-existing architectural issues (AttachmentList readonly-only,
URL-based attachment lookup, per-entity ownership) are intentionally
out of scope.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Template create used to silently default the runtime to "first usable"
and never collected a model — users had no idea where the new agent
would run or which model it would use until they opened the detail
page. Add a Runtime + Model picker pair above the skill list on the
template-detail step so the choice is visible (and overridable) before
the one-click Use action.
- Extract RuntimePicker out of create-agent-dialog so the form and the
template-detail step share one popover; selection seeding moves into
the picker too, since it's the only place that knows the active
filter (mine/all). Parent keeps just the duplicate-mode pre-fill.
- Mirror RuntimePicker's label-row + trigger DOM in ModelDropdown so
the two pickers render at identical heights when sat side-by-side
(fixes a 6-8px misalignment caused by inconsistent label-row sizing).
- Send model in createAgentFromTemplate; server side already accepts
the field (CreateAgentFromTemplateRequest.Model, omitempty), empty
string still falls through to the runtime's default model.
- Drop the runtime_register_first fallback hint that made the Runtime
trigger two-line in the empty state, breaking alignment with Model's
one-line trigger.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runtime prompt's Output section unconditionally required all tasks to
post a comment via 'multica issue comment add', which conflicted with the
squad leader protocol that says to 'exit silently' on no_action.
Changes:
- Add IsSquadLeader bool to TaskContextForEnv (detected via Squad Operating
Protocol marker in agent instructions)
- Relax the Output section and assignment-triggered workflow step 5 to
allow squad leaders to exit with only a 'multica squad activity' call
when the outcome is no_action
Fixes MUL-2168
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): resolve squad assignees in issue create/update/assign (MUL-2165)
The CLI assignee resolver only searched workspace members and agents, so a
quick-create input like "assign to <SquadName>" silently fell through to
"Unrecognized assignee: <SquadName>" in the issue description — even though
squads are first-class assignees server-side and the prompt's whole point was
to route the work for the user.
Extend resolveAssignee / resolveAssigneeByID to also fetch /api/squads, teach
the actor display lookup to render squad names in table output, update the
quick-create prompt and runtime-config command listing to mention
`multica squad list` alongside members and agents, and lock in the new
behavior with tests.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): gate squad assignee resolution behind an allowed-kinds set (MUL-2165)
The earlier MUL-2165 fix taught resolveAssignee / resolveAssigneeByID to also
return (squad, ...), but those helpers are shared. Project lead and issue
subscriber callers were still using them, and their target schemas reject
squads — project.lead_type has a DB CHECK constraint
(server/migrations/034_projects.up.sql:10) and the subscriber handler's
isWorkspaceEntity switch only knows member/agent
(server/internal/handler/handler.go:414). So
`multica project create --lead "<SquadName>"` and
`multica issue subscriber add --user "<SquadName>"` would resolve to
(squad, ...) and surface as a 500/403 server-side instead of a clean
CLI-side resolution error.
Thread an assigneeKinds set through the resolver and the pickAssigneeFromFlags
helper. Issue create/update/assign/list pass `issueAssigneeKinds` (all three);
project lead and subscriber pass `memberOrAgentKinds`. The squads fetch is
skipped entirely when not allowed, and the not-found / no-match error wording
adapts to the allowed kinds so it never mentions a type the caller cannot use.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(quick-create): searchable actor picker + squad support (MUL-2163)
- Replaces the flat agent dropdown in the "Create with agent" modal with a
searchable PropertyPicker that lists Agents and Squads in separate
sections, so users can filter by name and pick a squad as the creator.
- Persists the selection as (lastActorType, lastActorId), removing the
agent-only lastAgentId field on the quick-create store.
- Adds squad_id to the quick-create API request and stamps it onto the
task's QuickCreateContext. The handler resolves the squad to its leader
agent (re-using validateAssigneePair) and the daemon claim path injects
the squad-leader briefing when the task carries a squad hint, matching
the behavior of issue-bound squad tasks.
Co-authored-by: multica-agent <github@multica.ai>
* fix(create-issue): forward squad picks across manual→agent switch
Manual mode → agent mode previously only carried `agent_id`, so picking
a squad and then flipping to agent silently fell back to the persisted
actor / first visible agent and lost the user's choice. Carry `squad_id`
on the same branch so the agent panel honors the squad pick.
Adds a sibling test alongside the existing project-carry case.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): unify assignee menu with shared AssigneePicker (MUL-2157)
The Assignee submenu inside IssueActionsMenuItems was a parallel
implementation: no search, no squads, no agent permission check, no
archive filter, no frequency sort. The divergence was most visible from
the Inbox (where the issue detail's sidebar starts collapsed, so users
reach for the 3-dot menu).
Replace the submenu with a single menu item that closes the
surrounding dropdown / context menu and hands off to the shared
AssigneePicker popover — same component already used in the issue
detail sidebar, board cards, batch toolbar, and create-issue modal.
The picker is conditionally mounted to avoid every row in list / board
views subscribing to the members / agents / squads / frequency queries
on mount.
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): mock squadListOptions + add Assignee picker handoff test
`AssigneePicker` reads `squadListOptions` and `assigneeFrequencyOptions`
from `@multica/core/workspace/queries`. Tests that render IssueDetail
or IssueActionsDropdown without those mocks throw at the picker's
useQuery call and cascade into unrelated assertion failures — this is
what was leaving the `@multica/views` test job red on the MUL-2157 PR.
Add the missing mocks. Add a regression test that clicks the Assignee
menu item and asserts the shared picker (search input + Members group)
takes over, so a future regression to the parallel-implementation bug
this PR fixes fails loudly instead of silently.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(usage): mirror Tokens metric toggle onto Usage page Daily chart (MUL-2148)
#2537 added the Cost/Tokens metric toggle to the Daily chart inside the
runtime-detail Usage section (packages/views/runtimes/components/
usage-section.tsx). The workspace-level Usage page at /{slug}/usage
imports the same DailyCostChart primitive but renders it from
dashboard-page.tsx without any toggle wrapper, so #2537 only landed on
half of the surface that says "Daily cost".
This PR mirrors the same pattern to dashboard-page.tsx so users see
the toggle wherever a "Daily" chart appears.
Changes
- `packages/views/dashboard/utils.ts`: new `aggregateDailyTokens` helper
that folds DashboardUsageDaily[] into the same DailyTokenData[] shape
the DailyTokensChart consumes (mirrors aggregateByDate's dailyTokens
branch from the runtimes side, adapted to DashboardUsageDaily field
names).
- `packages/views/dashboard/components/dashboard-page.tsx`: rename
`DailyCostBlock` → `DailyTrendBlock`, add a Cost/Tokens Segmented
next to the section title, switch chart and title based on the
active metric, per-metric empty-state (so a workspace with unmapped
pricing but recorded tokens still gets a real Tokens chart while
the Cost view falls through to the empty-state — same convention as
DailyTab in usage-section.tsx).
- usage.json (en + zh-Hans): split `daily.title` into `title_cost` +
`title_tokens`, add `metric_cost` + `metric_tokens` toggle labels.
* feat(usage): default Daily chart to Tokens metric
Most users land on /{slug}/usage to gauge "how much agent work
happened" rather than "how much was spent." Tokens is the more
universally meaningful axis on first read (Cost depends on having
pricing mapped for every model and on whether the workspace has
unmaintained models). Cost stays one click away via the same toggle.
Also reorder the Segmented so Tokens sits first, matching the new
default.
* feat(usage): add timezone picker to usage page (#2533)
Extracts the runtime detail page's timezone dropdown into a shared
TimezoneSelect at packages/views/common/timezone-select.tsx and reuses
it in the usage page header, immediately to the right of the 7d / 30d
/ 90d segmented control. Defaults to the browser-resolved zone with
the same "(browser)" suffix rendering as the runtime page.
The runtime-detail TimezoneEditor still owns the PATCH mutation; only
the dropdown UI moved. UI-only — no API client / handler changes.
Co-authored-by: multica-agent <github@multica.ai>
* fix(usage): make header wrap so timezone picker fits on narrow widths
The h-12 PageHeader is a single non-wrapping flex row. Adding the
timezone picker with a 180px min-width pushed the title + project
filter + range switch + tz select past the viewport on narrow and
medium widths. Drop the picker's hard min-width, let the header grow
vertically (h-auto + min-h-12) and let the right toolbar wrap. Wide
viewports still render the original single row.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
#2505 (Squad MVP) merged with 29 hardcoded English strings in JSX text
nodes — packages/views/squads/components/squads-page.tsx (4) and
squad-detail-page.tsx (25). The package's eslint config enforces
`i18next/no-literal-string` as ERROR for every .tsx file, so
@multica/views#lint has been red on main, which Turbo cascades to
@multica/web#build, @multica/desktop#build, and @multica/views#typecheck
— effectively blocking every open PR's frontend CI (#2538, #2540, etc.).
Rather than disabling the rule for the Squad files (which would just
hide debt in a high-visibility surface), wire up a proper i18n
namespace and replace every flagged literal.
Namespace plumbing
- New `packages/views/locales/en/squads.json` and
`packages/views/locales/zh-Hans/squads.json` covering all 29 flagged
strings, grouped by surface (page / inspector / name_editor /
add_member_dialog / description_dialog / discard_changes_dialog /
members_tab / instructions_tab).
- Registered in `packages/views/locales/index.ts` and
`packages/views/i18n/resources-types.ts` so `t($ => $.squads.*)` is
type-safe.
Component replacements
- `squads-page.tsx`: add `useT("squads")`, replace 4 literals.
- `squad-detail-page.tsx`: add `useT("squads")` to seven inner
components that hold flagged text (`SquadDetailPage` / `InlineEdit
Popover` / `AddMemberDialog` / `RoleEditor` / `SquadDescriptionEditor`
/ `SquadDescriptionEditorBody` / `SquadOverviewPane` / `SquadMembers
Tab` / `SquadInstructionsTab` / `SquadDetailInspector`), replace all
flagged literals.
- Plural members count uses i18next's standard `_one` / `_other`
suffixes via `t(..., { count })` — matches the convention already
used in `runtimes/usage` and `agents`.
Notes
- A few unflagged user-facing strings remain (tab labels in
squadDetailTabs array, ternary alternatives like `"Save"` inside
`{x ? <Loader/> : "Save"}`, the inline `confirm()` archive prompt,
the `toast.success("Leader updated")` message). The eslint rule
uses `mode: "jsx-text-only"` so it only flags string children of
JSX nodes; attribute strings, object-literal values, and ternary
alternatives slip past. Those are real i18n gaps too but expanding
scope here would gold-plate the CI-unblock fix.
Verification
- `pnpm --filter @multica/views lint`: 0 errors (was 29). Remaining 13
warnings are pre-existing in unrelated files and don't fail CI.
- `pnpm typecheck`: 6/6 packages pass — namespace types resolve, all
selector calls infer correctly.
* feat(sidebar): top/bottom scroll fade mask (MUL-2150)
Apply useScrollFade to SidebarContent so the menu list softly fades
into the header / footer when overflowing, matching the existing
pattern used in chat list and onboarding steps.
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): useScrollFade re-evaluates on content mutations
ResizeObserver only fires on the observed element's own box. When a
flex / auto-height container's children grow asynchronously (sidebar
pinned items loading from TanStack Query, collapsibles expanding),
scrollHeight changes but clientHeight does not — mask stayed 'none'
until the user scrolled. Add a MutationObserver on childList to
recompute fade when content is inserted or removed.
Co-authored-by: multica-agent <github@multica.ai>
* test(paths): include squads in workspace route consistency check
main added the squads parameterless route to paths.workspace() in #2505
but the C4 consistency assertion wasn't updated, turning frontend CI
red on every PR. Add 'squads' to both the parameterless-method set and
the segment-mapping table.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
#2505 (Squad MVP) added paths.workspace(slug).squads() / squadDetail()
to paths.ts but didn't update paths/consistency.test.ts, whose first
test enumerates ALL parameterless workspace route methods and compares
the actual Set to an explicit expected Set. Squads landed on main, the
test started flagging the unexpected extra entry, and the @multica/core
test job has been red since 29082f7c.
Add "squads" to both:
- the expected-routes Set in `exposes the expected parameterless
workspace route methods` (the test that was failing)
- the expected-segments array in `each parameterless route emits
/{slug}/{segment}` (was silently skipping squads, now covered)
Also extend paths.test.ts with `ws.squads()` / `ws.squadDetail("sq_1")`
expectations so the per-route smoke test mirrors the rest of the
parameterless routes.
No source changes — only test files. The squad routes themselves
already exist on main and match the test's expectations.
The runtime Usage page's Daily timeline only showed daily $ cost, which
hides the underlying usage shape: cost varies wildly by model price, so
a quiet day on Opus can outspend a busy day on Haiku. Add a Cost/Tokens
toggle next to the Daily/Hourly/Heatmap tabs that swaps the chart over
to a four-segment stack of raw token counts (input / output / cache
read / cache write).
No backend changes needed — the existing /api/runtimes/{id}/usage
response already carries the per-day per-model token breakdown; this
just wires up DailyTokensChart on top of the dailyTokens aggregate that
aggregateByDate was already producing.
Co-authored-by: multica-agent <github@multica.ai>
* feat: implement Squad feature MVP
- Add migration 084_squad: squad, squad_member, squad_activity_log tables
- Extend issue.assignee_type to support 'squad'
- Add sqlc queries for squad CRUD, member management, activity logs
- Add Go handler with full Squad API (CRUD, members, activity log)
- Register routes: /api/squads/*, /api/issues/{id}/squad-activity, /api/squad-activity
- Add Squad trigger logic:
- Assign Squad immediately triggers leader
- Every external comment on squad-assigned issue triggers leader
- Anti-loop: squad members' comments don't trigger leader
- Dedup: skip if leader already has pending task
- Add squad activity log API (方案 B) for leader no-op recording
- Add frontend TypeScript types (Squad, SquadMember, SquadActivityLog)
- Add protocol events: squad:created, squad:updated, squad:deleted
Co-authored-by: multica-agent <github@multica.ai>
* fix: address PR review blocking issues
1. validateAssigneePair now accepts 'squad' assignee_type
2. All squad endpoints validate workspace ownership via GetSquadInWorkspace
3. CreateSquadActivityLog restricted to squad leader agent only
4. AddSquadMember validates member exists in workspace
5. UpdateSquad auto-adds new leader to squad members
6. DeleteSquad transfers assigned issues to leader before deletion
7. IssueAssigneeType includes 'squad' in frontend types
Co-authored-by: multica-agent <github@multica.ai>
* feat: soft-delete squads via archive instead of hard delete
- Add migration 085: archived_at + archived_by columns on squad table
- ListSquads now excludes archived squads (ListAllSquads for admin)
- DeleteSquad → ArchiveSquad (sets archived_at, preserves all records)
- Transfer squad-assigned issues to leader before archiving
- SquadResponse includes archived_at/archived_by fields
- Frontend Squad type updated with nullable archived fields
Co-authored-by: multica-agent <github@multica.ai>
* feat: re-add Squads frontend entry (sidebar nav + pages)
Re-applies the frontend squad entry that was lost during a merge:
- Sidebar nav: Squads item with Users icon
- Paths: squads() and squadDetail() in workspace paths
- Routes: /squads and /squads/[id] pages
- Views: SquadsPage (list) and SquadDetailPage
- i18n: en 'Squads' / zh '小队'
- Reserved slug: 'squads'
Co-authored-by: multica-agent <github@multica.ai>
* fix: fix SquadsPage rendering - use PageHeader children pattern
PageHeader takes children, not title/actions props. The incorrect
usage caused a React rendering error. Now matches the pattern used
by autopilots and agents pages.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): add API client methods and package export for squads pages
* feat: complete Squad frontend - create dialog, member management, API methods
- Add CreateSquadModal with name/description/leader selection
- Register 'create-squad' in modal registry
- Wire 'New Squad' button to open the modal
- Add full API client methods: createSquad, updateSquad, deleteSquad,
addSquadMember, removeSquadMember
- Rewrite SquadDetailPage with:
- Member list showing resolved names
- Add/remove member UI
- Archive squad button
- Back navigation to squads list
Co-authored-by: multica-agent <github@multica.ai>
* feat: improve Squad UI - match create agent dialog style
- CreateSquadModal: proper Dialog with Header/Description/Footer,
agent picker with avatars, textarea for description
- SquadDetailPage: centered max-w-2xl layout, ActorAvatar for members,
Crown badge for leader, textarea for member description,
improved spacing and visual hierarchy
- Renamed 'role' field label to 'Description' in add member form
(describes the member's responsibilities in the squad)
Co-authored-by: multica-agent <github@multica.ai>
* feat(squad): add avatar, instructions; drop unique-name constraint
- 086: add squad.avatar_url
- 087: drop unique constraint on squad.name (squads with the same
name are legitimate across teams; uniqueness was an accidental
product constraint)
- 088: add squad.instructions (text, default '')
- UpdateSquad now COALESCEs avatar_url + instructions
- handler exposes Instructions in SquadResponse and accepts it in
UpdateSquad
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): assignable + mention target; trigger leader on assign
- assignee picker and @mention suggestion list squads alongside
agents and members; renders squad avatar/icon
- creating or updating an issue with assignee_type=squad enqueues
a task for the squad's current leader (mirrors agent-assignee
parking-lot rule: skip backlog only)
- workspace queries/hooks expose squads where needed for the
pickers
- locales updated for new picker copy
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): agent-style detail page with members + instructions tabs
- restructure squad detail page to mirror the agent detail page:
320px inspector (creator, leader, created/updated) + tabbed
pane (Members | Instructions) with dirty-guard AlertDialog
- inline name + avatar editing on the inspector
- inline description editor (modal textarea)
- members tab: leader + member picker with role descriptions,
swap leader, edit member roles, remove
- instructions tab: ContentEditor + Save (mirrors agent pattern)
- squads list shows the squad avatar/icon
- core types + api.updateSquad accept avatar_url + instructions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): inject leader briefing on claim (protocol + roster + instructions)
When a squad's leader agent claims a task on a squad-assigned issue,
append a system-level briefing to the agent's Instructions composed of:
1. Squad Operating Protocol — hard-coded rules: leader is a
coordinator, dispatch via @mention, stop after dispatching,
resume on re-trigger, do not work outside the roster.
2. Squad Roster — leader self-row plus one row per non-archived
member with a literal mention markdown string ([@Name](mention://
agent|member/<UUID>)) the leader can paste verbatim. Round-trips
through util.ParseMentions, enforced by a contract test.
3. Squad Instructions — the user-defined squad.instructions block,
omitted entirely when empty so we do not leave a dangling heading.
Non-leader members claiming the same issue receive no briefing.
Tests cover: full squad with mixed agent/human members, lone leader,
archived agents skipped, empty user instructions, mention round-trip,
and the leader/non-leader claim-handler gate.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(squad): tell leader not to restate issue context in dispatch comment
After observing leaders padding their delegation comments with full
re-summaries of the issue body and prior discussion, make the
Operating Protocol explicit:
- assignees on Multica already have the full issue (title,
description, all comments, attachments) and workspace context;
- delegation comments should add only what cannot be inferred
(who is picked, why, extra constraints), aim for two or three
sentences;
- restating context is now an explicit hard rule violation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): unify leader evaluation into activity_log, add CLI command
- Squad member comments now trigger leader (only leader self-excluded)
- Replace squad_activity_log with activity_log (action: squad_leader_evaluated)
- Add CLI: multica squad activity <issue-id> <outcome> --reason
- Add API: POST /api/issues/{id}/squad-evaluated
- Update squad operating protocol to require evaluation recording
- Remove squad_activity_log table from schema and generated code
* feat(cli): add squad list, get, member list commands
* fix(squad): address review findings (P1+P2)
P1 fixes:
- Add 'squads' to reserved_slugs.json (source of truth)
- Add 'create-squad' to ModalType union
- Remove unused leaderOpen/selectedLeader in create-squad modal
- Replace literal JSX strings with i18n selectors (en + zh-Hans)
P2 fixes:
- Add 'squad' to mention regex (MentionRe)
- Fix human member lookup in squad briefing (use GetUser directly)
- Add squads routes to desktop app
- Add squad:created/updated/deleted to WSEventType + invalidation
- Reject archived squads as issue assignees
* fix(squad): restore zh-Hans key, publish activity event, invalidate issues on archive
- Restore create_project.title in zh-Hans modals.json (dropped by prior edit)
- Publish activity:created WS event after squad leader evaluation
- Invalidate issue queries on squad:deleted (archive transfers assignees)
- Add creator info to squad list cards
* fix(squad): realtime sync, rerun support, leader validation
- Use workspaceKeys.squads prefix for detail/member queries (realtime invalidation)
- Publish squad:updated after add/remove/role-change member mutations
- Support rerun for squad-assigned issues (targets leader agent)
- Reject assignment to squads whose leader is archived
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs(agents): three-phase agent quick-create plan
Captures the full design for moving agent creation from manual form +
one-by-one skill attachment to a tiered experience:
- Phase 1 (this PR): one-click curated templates, AI-free.
- Phase 2 (next): AI-recommended skills via the existing quick-create
task mechanism — no new server-side LLM dependency.
- Phase 3 (later): AI creates the whole agent end-to-end, composing
Phase 2 with a new `multica agent create` CLI driver.
Documents the architectural decisions that keep all three phases on
existing infrastructure (no SSE, no server-side LLM SDK, no new WS
channels), the two soft blockers Phase 1 unlocks for later phases
(createSkillWithFiles TX composability + skill same-name dedupe), and
the scope decisions we explicitly opted out of (Anthropic plugin
marketplace, ClawHub UI affordances).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): harden import against invalid UTF-8 and binary files
PG rejects two byte patterns in a TEXT column. Both crashed real skill
imports we hit while assembling the template catalog:
- Embedded NUL (0x00) -> SQLSTATE 22021. Already stripped by
sanitizeNullBytes, kept as-is.
- Other invalid UTF-8 (e.g. 0x91 — Windows-1252 smart quote in a skill
whose author saved prose from Word). sanitizeNullBytes now also runs
strings.ToValidUTF8 over the content so the second class no longer
takes the whole import down.
For non-text payloads (images, fonts, archives, compiled binaries),
sanitization isn't the right fix — agents never read those as text,
and the bytes can't survive a TEXT column at all. addFile now skips
them by extension before the per-bundle cap counters tick, logging
the skip so an unexpected drop leaves a breadcrumb.
Function name kept for compatibility with the many call sites; both
behaviours are strict supersets of the original.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): split createSkillWithFiles for tx composition + add workspace find-or-create query
Two soft blockers cleared so create-from-template (next commit) can
fold N skill creates and the agent + binding writes into one outer
transaction:
1. createSkillWithFiles used to Begin/Commit its own tx. Caller
composition was impossible — N invocations meant N separate
transactions and no atomicity over the whole materialise step.
Pull the body into createSkillWithFilesInTx(ctx, qtx, input); the
original function becomes a thin wrapper that manages its own tx
for standalone callers. Existing call sites: zero behaviour change.
2. Add GetSkillByWorkspaceAndName sqlc query — workspace skill lookup
by name, anchored to UNIQUE(workspace_id, name) from migration
008. Lets the template materialiser implement find-or-create:
reuse the workspace's existing skill row when a template
references the same name, rather than crashing on the unique
constraint or polluting the workspace with `<name>-2` clones.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): agent template catalog + create-from-template endpoint
Server-side foundation for Phase 1 of the quick-create roadmap (see
docs/agent-quick-create-plan.md). Adds:
- server/internal/agenttmpl/ — embed-loaded catalog of curated agent
templates. Each template ships pre-written instructions plus a list
of skill URLs that get materialised into the workspace at create
time. Validation runs at startup (init() panics on a malformed
template) so a bad JSON ships as a deploy-time defect, not a
runtime 500. Slug must equal the filename basename so the URL
router is mirror-symmetric with the file layout.
- 11 starter templates covering Engineering / Writing / Building /
Testing (code-reviewer, frontend-builder, planner, docs-writer,
one-pager, html-slides, full-stack-engineer, …).
- Three new endpoints, all behind RequireWorkspaceMember:
GET /api/agent-templates — picker list (no instructions)
GET /api/agent-templates/:slug — detail with instructions
POST /api/agents/from-template — materialise + create
Create flow:
1. Auth + runtime authorization happen BEFORE the GitHub fan-out
so a 403 never wastes 20s of upstream fetches.
2. Pre-flight dedupe by cached_name reuses workspace skills
without an HTTP fetch — second create-from-the-same-template
drops from 20s to <100ms.
3. Parallel fetch (30s per-URL timeout) for the remaining skills.
4. Single transaction: every skill insert, the agent insert, and
the agent_skill bindings. On any upstream fetch failure the TX
rolls back and the API returns 422 with `failed_urls` so the
UI can name the bad source(s).
5. extra_skill_ids (user-supplied additions) are verified through
GetSkillInWorkspace per id before attach, so a malicious client
can't graft a skill from another workspace via UUID guessing.
- multica agent create --from-template <slug> CLI flag dispatches to
the new endpoint with a 60s ceiling, matching `multica skill import`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): one-click create-from-template UI
Frontend half of Phase 1. CreateAgentDialog becomes a state machine
spanning four steps:
chooser → Start blank / From template cards
blank-form → existing manual form (post-chooser)
duplicate-form → existing form pre-filled from a duplicated agent
template-picker → grid of templates, click navigates to detail
template-detail → instructions + skill list preview + one-click Use
Picking a template never lands on the form: name auto-deduped against
existingAgentNames, runtime = first usable one, visibility = private.
Refinement happens on the agent detail page if needed. Same rationale
the doc spells out — templates exist precisely to skip configuration.
New components, all collapsible-by-default so quick-create stays fast:
- template-picker.tsx — categorised grid, lucide icons + semantic
accent tokens resolved through static maps so Tailwind's JIT picks
up every variant (dynamic class strings would silently miss).
- template-detail.tsx — instructions preview, skill list with cached
descriptions, Use CTA. Renders the failedURLs banner when a 422
fires — the only step that can trigger that response.
- instructions-editor.tsx — collapsed preview-card / expanded full
ContentEditor.
- skill-multi-select.tsx + skill-picker-list.tsx — shared multi-
select surface, also adopted by the existing skill-add-dialog.
- avatar-picker.tsx — agent avatar upload, mirrors the inspector's
visual language.
Schema-defended client (CLAUDE.md → API Response Compatibility): the
three new endpoints are wired through parseWithFallback with lenient
zod schemas. Desktop builds outlive any given server — a future
field rename / wrapping must not white-screen older installs.
listAgentTemplates accepts both the current bare array and a future
{templates: [...]} envelope. Coverage: 7 new schema-test cases in
schema.test.ts (null body, missing skills/instructions, malformed
create response, envelope migration).
Catalog + detail go through TanStack Query with staleTime: Infinity —
workspace-independent static data, no per-mount refetch.
Other:
- skill-add-dialog becomes a true multi-select (Confirm button +
checkbox list); attached skills are filtered out of the list.
- agents-page hands the freshly-created Agent back to the dialog so a
follow-up setAgentSkills can attach the form-selected skills.
- agent-overview-pane drops the mx-auto/max-w-2xl frame on config-
tab content; the wider dialog visual language reads better with
tabs filling the column.
- Every new UI string lives in both en/agents.json and
zh-Hans/agents.json under create_dialog.* / tab_body.skills.* —
locales/parity.test.ts blocks drift in CI.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): align skill import test + drop next-only lint suppression
- TestFetchFromSkillsSh_ResolvesRootLevelSkillMd now expects assets/logo.png
to be skipped; matches the new addFile binary-extension guard
(6fafd86e). The .png is intentionally dropped so PG TEXT inserts don't
hit SQLSTATE 22021.
- packages/views shares zero next/* deps, so the @next/next/no-img-element
eslint plugin isn't loaded there. The eslint-disable directive
referencing it produced a hard "rule not found" error in CI lint. Raw
<img> is the right primitive in views; remove the disable comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(agents): wrap CreateAgentDialog tests in workspace/navigation providers
The dialog now calls useNavigation() and useWorkspacePaths(), both of
which throw outside their providers. The existing tests rendered the
dialog bare and tripped both new requirements:
- NavigationProvider — supply a stub adapter so push() works for the
agent-detail redirect.
- WorkspaceSlugProvider — useWorkspacePaths() requires a slug.
The blank-vs-template chooser is now the default first step; the
existing tests target the runtime picker on the manual form, so the
helper auto-clicks "Start blank" when no template is passed
(duplicate-mode tests skip the chooser).
Manual afterEach(cleanup) + document.body wipe. Base UI's Dialog
portal renders into document.body and leaves focus-guard/inert wrapper
divs behind across tests, so the second test in the suite saw two
"All" / "My Runtime" matches and getByText failed. The wipe is local
to this file rather than the shared setup because it isn't a global
issue — only suites that open Base UI dialogs hit it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The four user-visible strings exposed by packages/ui rendered untranslated
on every page that used them:
- file-upload-button.tsx — "Attach file" aria-label/title
- sidebar.tsx — "Toggle Sidebar" sr-only label/aria-label/title
- pagination.tsx — "Go to previous/next page" aria-labels
- CodeBlock.tsx — "plain text" language fallback + "Copy code" aria-label/tooltip
Root cause: the package had no i18n hookup at all because the package
boundary rule forbids importing @multica/core. Replicating the pattern
five times would have been the same hack five times. Hooking up
react-i18next directly is the structurally clean fix — i18next is a
generic library, not business logic, and the upstream I18nextProvider
already exposes the instance via context.
To let packages/ui typecheck the selector form standalone (i.e. without
the views resource-types augmentation in scope), the augmentation is
split: views declares everything except the `ui` namespace on a new
global `I18nResources` interface, and packages/ui contributes the `ui`
slice via declaration merging in packages/ui/types/i18next.ts. Views'
resources-types side-effect-imports that file so both packages see the
merged shape during downstream typechecks.
Scope intentionally excludes:
- packages/ui/components/common/error-boundary.tsx — keeping its fallback
in English so a render-time crash never depends on i18n being healthy.
- apps/desktop/src/renderer/src/components/update-notification.tsx —
ships with the next desktop release, not via this PR.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(storage): add GetReader to Storage interface
Adds a streaming read method to the Storage abstraction so callers can
pull object bytes without forcing a full in-memory load. S3Storage wraps
GetObject; LocalStorage opens the file with path-traversal and sidecar
guards. Tests cover happy path, traversal rejection, sidecar rejection,
and missing key.
Used in the next commit by the attachment-preview proxy endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(server): add attachment preview proxy endpoint
GET /api/attachments/{id}/content streams the raw bytes of a
text-previewable attachment back to the client. Exists to (a) bypass
CloudFront CORS, which is not configured on the CDN, and (b) bypass
Content-Disposition: attachment which Chromium honors for iframe document
loads. Media types (image/video/audio/pdf) intentionally do NOT go through
this endpoint — clients render them directly from the signed CloudFront
download_url, which is already served with Content-Disposition: inline.
Hard cap: 2 MB. Larger files return 413. Anything outside the text
whitelist returns 415. The whitelist (isTextPreviewable) mirrors the
client-side dispatcher; the cross-reference comment in file.go flags
the manual sync until a JSON SSOT generator lands.
Response always uses Content-Type: text/plain; charset=utf-8 so a
hostile HTML payload can't be re-interpreted as a document. The
original MIME ships via X-Original-Content-Type for client dispatch.
Cache-Control: no-store so revoked attachment access takes effect
immediately on the next request.
Tests cover happy path (md), extension fallback when content_type is
generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and
the isTextPreviewable table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core/api): add getAttachmentTextContent + preview error types
Adds an ApiClient method that fetches the text body of an attachment via
the new /api/attachments/{id}/content proxy. Two typed errors —
PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the
preview modal render specific fallbacks instead of a generic failure.
Refactors the private fetch() into a shared fetchRaw() helper so the
new method inherits the standard infra: auth headers, 401 →
handleUnauthorized recovery, X-Request-ID, error logging, and the
ApiError contract. The previous draft bypassed all of these by calling
window.fetch directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views/editor): add AttachmentPreviewModal + Eye entry points
In-app preview for non-image attachments. An Eye icon now sits next to
the existing Download button on file cards / readonly file cards / the
standalone AttachmentList. Clicking it opens a full-screen modal that
dispatches by content_type:
pdf: <iframe src={download_url}> — Chromium PDFium
video/*: <video controls src={download_url}> — native controls
audio/*: <audio controls src={download_url}> — native controls
md: <ReadonlyContent> — full markdown pipeline
html: <iframe srcdoc sandbox=""> — fully restricted
text: <code class="hljs"> — lowlight highlight
Media types render directly from the signed CloudFront download_url
(server marks them inline-disposition). Text types fetch through the
new /api/attachments/{id}/content proxy via TanStack Query, wrapped
in useAttachmentPreview() so each entry point owns its own modal
state without depending on a global Provider mount.
Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger
than create-issue's max-w-4xl since PDF / video need room, but capped
to viewport on small screens. Sub-renderers use h-full to follow the
fixed modal height instead of viewport-relative units.
Images are intentionally NOT touched — the existing ImageLightbox
(extensions/image-view.tsx) already handles them correctly. The new
modal would be churn without user-visible benefit.
Adds i18n keys under attachment.* (en + zh-Hans) and registers
Preview/Download/Upload in the conventions glossary so future
translations stay consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(desktop): enable Chromium PDF viewer for attachment preview
Adds webPreferences.plugins: true to the main BrowserWindow so the
bundled Chromium PDFium plugin activates inside iframes — required for
the attachment preview modal's PDF dispatch. Default is false in Electron;
without it <iframe src=*.pdf> renders blank.
Security trade-off, accepted intentionally and documented inline:
1. This window already runs with webSecurity: false + sandbox: false,
so plugins: true does NOT meaningfully widen the renderer's attack
surface beyond what is already accepted.
2. The only PDFs that reach an iframe here are signed CloudFront URLs
we ourselves issued; user-supplied URLs are routed through
setWindowOpenHandler → openExternalSafely and cannot land in this
renderer.
3. Chromium's PDFium plugin is itself sandboxed and only handles
application/pdf — no Flash/Java/other historical plugin surfaces.
If we ever tighten webSecurity / sandbox, the follow-up is to host the
PDF viewer in a dedicated BrowserView with plugins scoped to that view,
keeping the main renderer plugin-free.
Old desktop builds ship without the preview modal, so the Eye button
never appears and PDF preview is gated by the same release — zero
regression risk for users on stale clients.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the regression reported in https://github.com/multica-ai/multica/issues/2515 that
PR #2437 only half-fixed in v0.2.31.
Two gaps remained on Ubuntu/GNOME:
1. The .deb shipped only the source 1024×1024 PNG under
/usr/share/icons/hicolor/, with no usable smaller sizes. GNOME's hicolor
lookup walks 16…512 and falls back to the theme default when none
match, so the launcher had no icon. The auto-generation pass in
electron-builder silently produced only the source size for us. Drop
pre-rendered 16/24/32/48/64/128/256/512 PNGs into build/icons/ and
point `linux.icon` at the directory so packaging stops depending on
the toolchain re-running that generation correctly.
2. WM_CLASS at runtime was `@multica/desktop`, while the .desktop file
declared `StartupWMClass=Multica`. PR #2437 assumed Electron derives
WM_CLASS from electron-builder.yml's `productName`, but Electron
reads `app.getName()`, which reads the *packaged ASAR's* package.json
— productName if present, otherwise name. Our source
apps/desktop/package.json had no top-level productName, so the ASAR
carried only `name: "@multica/desktop"` and Chromium emitted that as
WM_CLASS, breaking the .desktop association and the dock icon.
Fixed in two anchors for belt-and-braces: add
`"productName": "Multica"` to apps/desktop/package.json (so the ASAR
carries it and app.getName() resolves correctly by default), and call
`app.setName("Multica")` in the production branch alongside the
existing dev-only setName so a future regression in package.json or
the build pipeline cannot silently re-break WM_CLASS.
The `StartupWMClass: Multica` declaration in electron-builder.yml stays
pinned and the surrounding comment has been rewritten to record the
correct WM_CLASS derivation.
Verification on a real Ubuntu install:
- `dpkg-deb -c multica-desktop-*-linux-amd64.deb | grep hicolor` lists
≥8 sizes.
- `xprop WM_CLASS` on the running window prints `"multica", "Multica"`.
- Launcher and dock both show the Multica logo with no manual
~/.local/share/icons workaround.
Co-authored-by: multica-agent <github@multica.ai>
Base UI's Menu uses focus-follows-cursor — hovering a sibling row drags
DOM focus to that row, which made the rename input's onBlur=save fire
just from moving the mouse. The result: clicking the pencil and then
nudging the cursor would silently commit a half-typed title.
Replace the blur handler with a document-level pointerdown listener
(capture phase, so it runs before Base UI's outside-click close handler
unmounts the input). The listener only commits when the user actually
clicks somewhere outside the input. Enter still commits, Escape still
cancels, mouse hover is now a no-op.
MUL-2110
Co-authored-by: multica-agent <github@multica.ai>
Gemini CLI's folder-trust feature throws FatalUntrustedWorkspaceError
(exit code 55) when the current workspace isn't in
`~/.gemini/trustedFolders.json` and the process is headless — no
interactive trust prompt is available. The daemon spawns gemini with
`-p` + `--yolo` in a freshly checked-out worktree that the user has
never trusted interactively, so every run with `security.folderTrust`
enabled fails after ~10s with exit status 55 and no useful output.
Default `GEMINI_CLI_TRUST_WORKSPACE=true` on the child env to short-
circuit `checkPathTrust` in gemini-core. This mirrors gemini-cli's
documented `--skip-trust` flag; the env var has been gemini's
documented headless escape hatch for the entire folder-trust feature
lifetime so the fix works on every gemini version that can produce
the crash. Callers that explicitly set the same key in cfg.Env win,
preserving the ability to opt back into the gate.
Co-authored-by: multica-agent <github@multica.ai>
The gemini CLI's Windows shim emits `Active code page: 65001` (from
`chcp`) to stdout before the real version reaches `--version` output.
The daemon stored the raw concatenation as the runtime version, so the
runtime detail page rendered `Active code page: 65001 0.42.0` instead
of `0.42.0`.
Scan `<cli> --version` line by line and return the first line carrying
a semver-shaped token. Full strings like `2.1.5 (Claude Code)` or
`codex-cli 0.118.0` survive unchanged; unparseable output falls back to
the trimmed raw value.
Co-authored-by: multica-agent <github@multica.ai>
Adds a pencil icon next to the trash icon on each session row in the chat
dropdown. Clicking it turns the title into an inline editable input:
Enter / blur saves, Escape cancels.
Server: new PATCH /api/chat/sessions/{id} handler that updates the title
via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new
`chat:session_updated` WS event so other tabs / devices stay in sync, and
rejects blank titles. Frontend mutation is optimistic with rollback,
matching the existing delete-session pattern.
MUL-2110
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME
Codex is the only daemon runtime whose HOME is redirected — the daemon
sets CODEX_HOME to a per-task isolated directory so each task gets a
clean config slate without polluting ~/.codex/. Side effect: the codex
CLI never sees the user's `~/.codex/skills/` and tells the user no skill
was found.
Other runtimes (claude / copilot / opencode / pi / cursor / kimi / kiro)
don't have this issue: they leave HOME untouched and discover both
user-level skills (from ~/.<runtime>/skills) and workspace-assigned
skills (written to a workdir-local dotfile dir) natively. Codex is the
outlier.
Fix: in execenv.Prepare and execenv.Reuse, copy each subdirectory under
`~/.codex/skills/` into the per-task `codex-home/skills/` before writing
workspace-assigned skills. Workspace skills still win on sanitized-name
conflict; user-level installer symlinks (lark-cli style) are followed so
the per-task home gets real content rather than dangling links.
Closes#1922
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): wipe per-task codex skills dir before each hydration
Without this, the Reuse path leaves two classes of stale state behind:
1. Round 1 seeded user skill `writing/drafts/stale.md`. Round 2 reuses
the same workdir with workspace skill `Writing` assigned: seed
stage skips user `writing` (reserved), workspace stage writes
`SKILL.md` via MkdirAll + WriteFile but never clears the directory,
so the round-1 user support files surface under the workspace
skill — violating "workspace fully wins on name conflict" and
potentially leaking user-level files into a workspace skill view.
2. User uninstalls a skill from ~/.codex/skills between two runs. The
prior copy in codex-home/skills/<name>/ lingers, so the codex CLI
keeps seeing the removed skill.
Fix: RemoveAll(codex-home/skills) at the start of hydrateCodexSkills,
then re-seed user skills and re-write workspace skills. On Prepare
this is a no-op (envRoot was already wiped); on Reuse it resets the
slate.
Added two regression tests covering both scenarios.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In a new chat (no active session), the first send momentarily rendered
ChatMessageSkeleton before the user's message appeared. Root cause:
ensureSession called setActiveSession(newId) immediately after creating
the session, *before* handleSend wrote the optimistic message to the
chatKeys.messages(sessionId) cache. useQuery's first subscription to the
new key saw no data → isLoading=true → showSkeleton rendered for one
frame.
Apply TanStack Query's "seed the cache before subscription" pattern:
move setActiveSession out of ensureSession and into the callers, after
they've primed the messages cache. handleSend writes the optimistic
user message first, then flips activeSessionId; handleUploadFile seeds
an empty array first, then flips. useQuery's first read hits cache
synchronously and ChatMessageList mounts directly — no Skeleton frame.
This is a distinct race from the chat-done flicker fixed in #2509
(unmount/mount on reply completion); both share the same prime-before-
subscribe shape.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): collapse chat-done flicker via inline cache write
The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.
Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.
Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).
Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.
Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): cover chat done cache handoff
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
`disableMentions` previously skipped registering BaseMentionExtension entirely,
which removed the `mention` node type from the editor's schema. Pasting any
ProseMirror slice from another Multica editor (clipboard `text/html` carries
`data-pm-slice`) caused ProseMirror to silently drop the mention nodes and any
surrounding inline text glued to them.
Keep the extension registered in all cases. When `disableMentions=true`, attach
an inert suggestion (`allow: () => false`) so typing `@` still does not pop the
picker — matching the original product intent for agent system prompts — but
existing mentions pasted in survive and render as the normal pill.
Earlier attempt #2477 patched the paste classifier instead and broke in a
different way (`mention://` href tripped the markdown link validator),
which led to revert #2510.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): route attachment downloads through Electron native system on Linux
Replaces shell.openExternal with webContents.downloadURL for attachment
downloads in the Electron desktop app. On Linux/Ubuntu, opening a
CloudFront URL serving Content-Type: text/html via the system browser
causes the browser to render the HTML inline instead of downloading.
Electron's native downloadURL shows a save dialog and saves the file
directly, fixing HTML downloads regardless of Content-Type.
* test(views): update desktop download test to match the new downloadURL bridge
The test still referenced the old openExternal bridge. Updated it to
assert desktopAPI.downloadURL() instead.
* fix(desktop): add URL scheme allowlist to download IPC handler
Addresses review feedback on PR #2441.
The file:download-url IPC handler called webContents.downloadURL
directly, bypassing the http/https allowlist enforced by
openExternalSafely. Adds downloadURLSafely() alongside the existing
openExternalSafely wrapper, reuses the same isSafeExternalHttpUrl
check, and extends the ESLint no-restricted-syntax rule to ban direct
webContents.downloadURL calls.
Also handles nits: observable warning on null mainWindow, removes dead
openExternal field from DesktopBridge, adds desktop-branch failure test.
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.
Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
`dashboard` stays reserved in the marketing group (back-compat against
workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
expectation updates
Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
emphasised column header / cell — keeping ranking, visual quantity,
and column emphasis in lockstep so users always see what's being
measured
- Default sort = Tokens (most universally meaningful; Cost still one
click away)
- Project filter dropdown:
- Show ProjectIcon next to the selected project + each list item;
FolderKanban as the "All projects" fallback (matches ProjectPicker
language)
- alignItemWithTrigger={false} so "All projects" doesn't get pushed
above the trigger and clipped when the header sits at the top of
the viewport (was the root cause of "can't re-select All projects"
once a project was selected)
- max-h-72 to cap the dropdown when workspaces accrue many projects;
matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
component name intentionally left in place — user-visible rename
only, no broad code refactor.
Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
The editor underneath the feedback textarea already supports image/file
upload via paste and drag-drop, but the modal has no visible affordance
— users had no way to discover this. Chat input has the same plumbing
and exposes it through a paperclip button; mirror the pattern here.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Shiki's default bundle doesn't include the `env` grammar, so MDX
prerendering fails with `Language `env` is not included in this
bundle.` The two pages added in #2474 used ```env, which broke both
Preview and Production deployments of multica-docs.
Swap the language tag to `dotenv` (Shiki ships it by default) — same
visual result, no Shiki config change needed.
Refs MUL-2122
Co-authored-by: multica-agent <github@multica.ai>
When an agent completes successfully (exit 0) but produces no text
output, the daemon incorrectly classified it as 'blocked'. This is
wrong — agents can legitimately complete work via tool calls (posting
comments, pushing code) without emitting text output.
Change the empty-output path to return status=completed so the task
is correctly reported as successful.
Fixes MUL-2104
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): workspace/project token + run-time dashboard
Add a `/{slug}/dashboard` page showing per-agent token spend and execution
time across the whole workspace, with an optional project filter.
Backend:
- Three new sqlc queries against task_usage + agent_task_queue: daily
usage, per-agent usage, per-agent total run-time. All optionally
scoped to a project via sqlc.narg('project_id'), reaching project
through the issue join.
- Handlers under /api/dashboard return the same wire shape the runtime
page already consumes (model preserved for client-side cost math).
Frontend: - Shared DashboardPage in packages/views/dashboard reusing KpiCard,
DailyCostChart, ActorAvatar, and estimateCost from the runtime page
so the visual style and pricing math stay in lock-step.
- Period selector (7/30/90d), project dropdown, four KPI tiles
(cost, tokens, run time, tasks), daily cost chart, and a combined
"cost + run time by agent" list.
- Routed in both web (app/[slug]/(dashboard)/dashboard) and desktop
(memory router); sidebar nav entry added under Workspace group.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): drop stale project filter and stop double-counting tasks
Two issues caught in PR #2462 review:
1. Project filter held the previous selection's UUID across workspace
switches and project deletions: the dropdown gracefully showed
"All projects" (because the title lookup missed) while the three
dashboard queries kept forwarding the dead UUID, leaving the UI
looking like a full-workspace view but populated with empty
project-scoped data. Validate the picked UUID against the current
projects list before passing it to the queries.
2. The "by agent" table read its task count from the token rollup,
which is grouped per (agent, model). A single task that spans two
models lands twice and the agent's row reads e.g. "2 tasks" when
the real count is 1. Prefer `ListDashboardAgentRunTime`'s per-agent
distinct count when available; fall back to the token aggregate
only for agents with no terminal run yet (in-flight tasks).
Extract the merge into `mergeAgentDashboardRows` so the precedence
rules are unit-tested directly.
Co-authored-by: multica-agent <github@multica.ai>
* test(dashboard): allocate per-workspace issue.number explicitly
TestDashboardEndpoints creates two issues in the shared fixture
workspace. issue.number defaults to 0 (migration 020), and the table
carries UNIQUE (workspace_id, number), so the second insert raced the
first on the same default and failed in CI.
Allocate MAX(number) + 1 per insert so each row gets a fresh number
without stepping on rows other tests left behind in the same workspace.
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): rollup table + cron-driven aggregation for dashboard
Mirror the per-runtime rollup in `task_usage_daily` (migrations 073/077/082)
to remove the per-request raw aggregation the dashboard was doing.
Migration 084 adds:
- `task_usage_dashboard_daily` keyed on
(bucket_date, workspace_id, agent_id, project_id, model) — the
dimensions the dashboard actually queries, with project_id nullable
via UNIQUE NULLS NOT DISTINCT (PG15+) so "no-project" buckets
upsert cleanly.
- `task_usage_dashboard_rollup_state` watermark table.
- `task_usage_dashboard_dirty` invalidation queue.
- Triggers on agent_task_queue DELETE, task_usage DELETE, and
issue.project_id UPDATE — the cases the updated_at watermark can't
see. The project_id trigger re-attributes existing rollup rows when
a user moves an issue across projects.
- `rollup_task_usage_dashboard_daily_window(from, to)` —
idempotent recompute primitive (same shape as 077).
- `rollup_task_usage_dashboard_daily()` cron entry — own advisory
lock (4244) so it serialises independently of the runtime rollup.
- `task_usage_dashboard_rollup_lag_seconds()` health helper.
Sqlc queries `ListDashboardUsageDailyRollup` /
`ListDashboardUsageByAgentRollup` read from the new table; the handler
dispatches between rollup and raw on a separate
`UseDailyRollupForDashboard` config flag
(`USAGE_DASHBOARD_ROLLUP_ENABLED` env). Same fail-safe default (false →
raw) so operators can roll out independently of the per-runtime flag.
Bucket date is UTC (the dashboard aggregates across runtimes that may
sit in different tzs; there's no single correct local boundary).
Adds `cmd/backfill_task_usage_dashboard_daily` mirroring the existing
per-runtime backfill — operator runs it once before flipping the flag.
Tests: - TestDashboardEndpoints now also exercises the rollup read path
(raw vs. rollup, same project-scoped totals).
- TestDashboardRollupReattributesOnProjectChange verifies the
issue.project_id trigger enqueues both old + new buckets and the
next rollup tick zeroes the old project + populates the new one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard-rollup): close two invalidation gaps
Two leak paths missed by migration 084 review:
1. Issue cascade DELETE — the atq BEFORE DELETE trigger runs AFTER the
issue row is gone, so `LEFT JOIN issue` returns NULL project_id and
the original-project bucket never gets cleared (issue 077 calls this
out for the runtime rollup but didn't need to act on it). Adds an
`issue BEFORE DELETE` trigger that enqueues using OLD.project_id
while the issue row is still readable.
2. `LinkTaskToIssue` (quick-create task attaching to a real issue post-
completion) UPDATEs `agent_task_queue.issue_id` from NULL to a real
id. Migration 084 only watched DELETE on atq, so usage already
rolled up under the no-project bucket stayed attributed to NULL
forever. Extends the atq trigger to fire on UPDATE OF issue_id too,
enqueueing both OLD (NULL project) and NEW (linked issue's project).
Tests: - TestDashboardRollupClearsOnIssueDelete asserts rollup row drops to
zero after issue delete + rollup tick.
- TestDashboardRollupReattributesOnLinkTaskToIssue verifies tokens
move from the NULL bucket to the project bucket after the UPDATE.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): make GitHub repo list scrollable in Add Resource popover
When a workspace has many GitHub repos, the list in the Add Resource
popover extended beyond the visible area with no way to scroll. Add
max-h-48 overflow-y-auto to the repos container to enable scrolling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): make GitHub repo list scrollable in create project modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The GitHub App integration code reads these two env vars and only enables
the Connect flow when both are set. .env.example never listed them, and
docker-compose.selfhost.yml did not forward them into the backend
container, so self-hosters following the integration docs had no working
way to turn the feature on.
MUL-2107
Co-authored-by: multica-agent <github@multica.ai>
Notifications from system actors (e.g. GitHub PR closed) were rendering
with an "S" initials fallback. The avatar now shows the Multica icon
when actor_type === "system", matching the platform's brand.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): only auto-close issue when all linked PRs have resolved
Previously, the webhook handler unconditionally moved an issue to `done`
as soon as a single linked PR was merged. If a second PR was also linked
to the same issue and still open / draft, the issue would close before
the work was actually finished.
Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status
transition on it: a merged PR advances its linked issues only when no
sibling PR linked to the same issue is still in flight. Issues stay put
while siblings are open or draft, and the merge that resolves the last
in-flight PR is the one that closes the issue.
Adds an integration test that opens two PRs against the same issue,
merges the first, asserts the issue stays in_progress, then merges the
second and asserts the issue advances to done.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): re-evaluate auto-close on closed-without-merge events too
GPT-Boy review on #2470: gating only the `state == "merged"` branch left
one ordering hole. PR-A merges first → issue stays in_progress because
PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs
the auto-close check, so the issue is stuck in_progress.
Generalise the trigger to every terminal PR event (`merged` or `closed`)
and advance the issue only when:
- the issue is not already terminal (done / cancelled);
- no sibling PR is still in flight (open / draft);
- at least one linked PR — current or sibling — actually merged.
Rule (3) preserves "user closed every PR without merging → leave the
issue alone": if no work was delivered, the user decides what to do.
Replace `CountOpenSiblingPullRequestsForIssue` with
`GetSiblingPullRequestStateCountsForIssue`, which returns both the
in-flight count and the merged count in a single roundtrip.
Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy
flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case
guarding rule 3). Refactors the multi-PR webhook helper out of the
existing two-merge test so all three multi-PR scenarios share it.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Virtualization and precise deep-link landing have fundamentally opposed
contracts: virtualization uses estimated heights for off-screen items,
deep-link needs real heights for everything above the target. Three
prior fix attempts (initial scrollToIndex race, settle-by-silence
observer, 3-pass cooperative scroll) all tried to satisfy both in one
path and none fully stabilized — code/image/mermaid-heavy comments
kept drifting the target after first landing.
Split by user intent instead:
- highlightCommentId set (user came from inbox to read a specific
comment) -> render flat. Every comment mounts, every height is real,
the target id is in the DOM the instant the effect runs. Native
document.getElementById + el.scrollIntoView({block:'center'}) is
semantically identical to a native <a href="#comment-X"> anchor.
- otherwise -> Virtuoso. Browsing mode keeps the first-paint perf win
from #2413 on long timelines.
Deep-link effect collapses to ~22 lines, matching the pre-virtualization
implementation. A shared renderItem function keeps both render modes
consistent. Removes: bootstrapRef, three-pass scrollToIndex effect,
overflow-anchor:none, scrollPaddingTop on container, scroll-margin-top
on every comment wrapper, virtuosoRef + VirtuosoHandle, initialItemCount
prop, useLayoutEffect.
Mermaid gets a 280px skeleton (web.dev CLS guidance) plus a
sessionStorage layout cache keyed by chart-text hash, so the 0px ->
real-height shift no longer drifts the surrounding layout — useful for
both render modes, deep-link or browsing. Pattern matches ant-design/x
#1497 which fixes the same Mermaid drift in their own stack.
Auto-expand a folded resolved thread when the deep-link target is a
reply inside it; without this the target reply stays collapsed and the
user sees only the resolved-bar.
Net: +131 / -245 in issue-detail.tsx. Tests added for the
resolved-thread-reply auto-expand path.
Known follow-ups:
- <ReadonlyImage> aspect-ratio for image CLS (same class as Mermaid).
- Layout heisenbug (page width "abnormal" without devtools open) is
orthogonal to deep-link and survives this PR; needs separate triage.
- 500+ comment cold mount in deep-link mode pays full markdown+lowlight
cost; GitHub takes the same hit and we accept it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The card displayed a per-installation row (avatar + account_login +
"User|Organization · connected <date>") plus a disconnect button. In
practice the title regularly fell back to "unknown" because the server's
fetchInstallationAccount call doesn't sign App JWT, and the
account-level framing also leaked GitHub's data model into the UX —
users care about which repos are wired up, not which GitHub account the
App is installed on.
Collapse the card to: GitHub mark + description + Connect button (plus
the "not configured" hint and role gate). Existing installations stay
fully manageable from GitHub's own settings page, reachable via Connect.
Removes:
- installation list + disconnect button + handleDisconnect
- useQueryClient / Trash2 / githubKeys imports
- five now-dead i18n keys (loading / empty / connected_at /
toast_disconnected / toast_disconnect_failed) in en + zh-Hans
The two issue-detail surfaces that stop a single agent task — the
sticky AgentLiveCard banner and the active rows inside
ExecutionLogSection — cancelled on the first click. Task
cancellation is irreversible, and a misclick on a long-running run
was costly with no way to recover.
Both entry points now route through a shared
TerminateTaskConfirmDialog (AlertDialog with destructive confirm),
mirroring the pattern the Agents list row actions already use for
the "cancel all tasks" flow. The running-state note about a few
seconds to fully halt is only shown when the task is actually
running or dispatched.
Chat window pending-pill Stop is intentionally not affected — it
is fire-and-forget with the UI clearing optimistically, and a
confirm step there would interrupt chat flow.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The 'Learn more' link on the Runtimes page pointed to
https://multica.ai/docs/runtimes which returns 404. The docs page is
published at /docs/daemon-runtimes.
* feat(github): GitHub App backend for PR ↔ issue linking
- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): UI for connecting GitHub and viewing linked PRs
- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast
- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): i18n the new GitHub UI strings to satisfy lint
CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Follow-ups to #2444:
- ServeFile refuses keys ending in .meta.json so the sidecar JSON isn't
a stable read API. Sits before any disk work so a crafted
.meta.json sibling can't trigger an out-of-tree read.
- ServeFile rejects paths that resolve outside uploadDir (via
filepath.Rel) before readLocalMeta runs. http.ServeFile's own ..
guard fires later on r.URL.Path, but readLocalMeta would otherwise
do a stray disk read on <some-path>.meta.json before the 400 lands.
- Upload only writes a sidecar when filename is non-empty. ServeFile
only reads the filename anyway, so a content-type-only sidecar was
dead disk weight.
- Drop the dead json.Marshal error branch — marshaling two strings
cannot fail.
Three new tests cover sidecar suffix rejection, the traversal guard,
and the no-filename Upload short-circuit.
Co-authored-by: multica-agent <github@multica.ai>
LocalStorage.ServeFile delegated straight to http.ServeFile without
setting Content-Disposition, so downloads of local-storage attachments
landed on disk under the UUID-based storage key instead of the human
filename the uploader had chosen. The S3 backend already sets
Content-Disposition on PutObject (s3.go:186-187), so the local backend
was the only one losing the original filename — a sibling asymmetry
that's been there since multi-backend support landed.
Upload now writes a sidecar <key>.meta.json beside the data file
capturing the original filename and sniffed content type. ServeFile
reads the sidecar when present and sets Content-Disposition using the
existing sanitizeFilename + isInlineContentType helpers, mirroring the
S3 inline/attachment decision exactly. Uploads from before this lands
have no sidecar and fall through to the previous behavior. Delete now
removes the sidecar alongside the data file so the upload directory
doesn't grow orphans.
Closes#2442
The first file upload in a brand-new chat showed the blob preview for
a moment and then disappeared — the upload looked like it had failed
even though the attachment was actually saved.
Root cause: `<ContentEditor key={draftKey}>`. `draftKey` includes
`activeSessionId`, and `handleUploadFile` (chat-window.tsx) awaits
`ensureSession("")` before forwarding the file to the upload handler.
Lazy-create flips `activeSessionId` from null to a uuid mid-upload,
which changes `draftKey`, which forces React to remount the editor.
The blob image node inserted by `uploadAndInsertFile` was on the old
editor instance; by the time the upload settled, the swap-to-CDN-URL
walk in file-upload.ts couldn't find the blob src in the new editor
and finally `URL.revokeObjectURL` released the blob — broken image.
The create-issue modal has the same draft-store pattern but does not
hit this bug because it never sets a `key` on its ContentEditor; the
editor lives for the lifetime of the modal regardless of draft churn.
Split the two concerns the previous `draftKey` was conflating:
- `draftKey` (zustand storage key) keeps `activeSessionId` so each
session gets its own draft slot — unchanged behaviour.
- `editorKey` (React identity key) drops `activeSessionId` and only
varies on `selectedAgentId`, which is the actual signal Tiptap's
Placeholder needs to refresh on agent switch.
Now the editor stays mounted across the lazy session creation. The
blob preview survives long enough for the swap to find it, and the
user sees the image render normally on the very first upload of a new
chat.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(modals): correct text input height in issue creation dialog
Fixed text input height for both agent and manual create issue dialogs:
- Agent dialog: added flex to outer div and flex-1 to inner div
- Manual dialog: added flex to description container and flex-1 to editor
Fixed: #2433
* fix(editor): make EditorContent a proper flex container
- EditorContent: flex flex-1 flex-col
- Remove min-height: 100% from .ProseMirror CSS
- Let flex-grow handle height consistently across the chain
Fixed: #2433
---------
Co-authored-by: ayakabot <ayakabot@seepine.com>
* refactor(feedback): replace generic description with brand-colored GitHub CTA
The Feedback modal previously rendered three lines of grey copy before the
editor — title, description, and the GitHub hint from #2451. The hint blended
into the description, defeating its purpose of nudging users toward a tracked
channel.
Drop the generic description (placeholder already explains what to type) and
restyle the hint so GitHub itself is the only brand-coloured anchor. The
shorter sentence ("Want faster traction? Head to GitHub") puts the link at
the natural end-of-line fixation point, where the colour shift actually
registers.
i18n splits into prefix + link (suffix would be empty), avoiding the
sentence-order brittleness that 3-key splits usually introduce.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* copy(feedback): expand GitHub hint to highlight discussion as well
Reviewer feedback: "faster traction" only signals speed; users also care about
having an open back-and-forth on a tracked thread. Update the hint to surface
both benefits without lengthening the line meaningfully.
- EN: "Want faster handling and open discussion? Head to GitHub"
- ZH: "想被更快处理、参与讨论?请去 GitHub"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Replaces #2452's first attempt (placeholder-freeze, 800ms blank
window) and the multi-observer settle pipeline from #2449. Both
were trying to land the target with a single perfectly-timed scroll,
which doesn't compose with how virtualization actually works.
The non-virtualized version of this code, pre-#2413, was 12 lines:
one el.scrollIntoView once timeline.length > 0 && !loading. That
worked because every comment was in the DOM, so the target's
absolute position was real, not estimated. Virtualization breaks
that invariant — Virtuoso renders a window, fills the rest with
spacer heights derived from estimates, and the target's offset is
spacer-sum until each above-target item is mounted and measured for
the first time. Those measurements arrive in waves: viewport mount,
ResizeObserver pass, markdown render, lowlight code highlight,
image load. Each wave updates spacers and shifts the target's
offset by tens to hundreds of pixels.
The previous two attempts both tried to detect "settle" and land
once. ResizeObserver on the target watches the symptom, not the
cause (#2449). Rendering placeholders to freeze the cause shows
800ms of blank where comments should be (#2452 v1).
This rewrite cooperates with Virtuoso's own measure→correct loop
instead of trying to outrun it. Three scrollToIndex calls — t=0,
t=120 (after the first measurement wave), t=500 (after markdown /
lowlight settle) — let the convergence narrow on each pass. Each
call uses whatever spacer heights are current; differences across
passes are typically a few pixels (cold viewport) to a few dozen
(big code blocks), not the full-spacer drift that motivated
placeholders. Visually it reads as a single instant scroll with at
most a couple of subtle re-centerings, not a re-jump.
initialTopMostItemIndex stays — it's the only API that anchors
position *before* first paint, and it's the reason cold-start
deep-links from inbox land at the target without a visible "scroll
from top". Captured exactly once via a useRef one-shot following
React's documented "avoid recreating ref contents" idiom, so #458's
persistent-anchor reset behavior can't trip. Crucially we now
spread-on-defined rather than passing `={undefined}` — react-virtuoso
crashes with "Cannot read properties of undefined (reading 'index')"
on the latter because the library accesses .index on the prop without
a null guard.
Net delta vs main: −86 lines. Deletes ~150 lines of the #2449
MutationObserver/ResizeObserver settle pipeline plus this PR's
prior placeholder/deepLinking/flushSync machinery, replaces with
~30 lines of straightforward effect + bootstrap ref. The whole
deep-link path is now smaller than the original pre-virtualization
version was, because the convergence loop is explicit and the
correctness story doesn't require auxiliary state.
Refs: react-virtuoso #458 (initialTopMostItemIndex anchor reset),
#883 (initial scroll race), #1083 (scrollTop model divergence vs
native scrollIntoView).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a small CTA below the Feedback modal description that links to
github.com/multica-ai/multica/issues for users who want a tracked, public
channel. The in-app feedback form still serves vague impressions and
weekly-aggregated input; GitHub is for concrete bugs, feature requests, and
discussion that benefits from community visibility.
i18n covers en + zh-Hans following the conventions.zh.mdx voice guide
(full-width punctuation, ASCII ellipsis, spaces around Latin terms).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(plans): chat attachment & image support implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(db): add chat_session_id/chat_message_id to attachment
Co-authored-by: multica-agent <github@multica.ai>
* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage
Co-authored-by: multica-agent <github@multica.ai>
* feat(file): upload-file accepts chat_session_id form field
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): SendChatMessage links uploaded attachments to the new message
Co-authored-by: multica-agent <github@multica.ai>
* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds
Co-authored-by: multica-agent <github@multica.ai>
* feat(core): useFileUpload supports chatSessionId context
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): support paste/drag/upload attachments in chat input
Co-authored-by: multica-agent <github@multica.ai>
* test(e2e): chat input attachment upload + send round-trip
Co-authored-by: multica-agent <github@multica.ai>
* chore(chat): keep lazy-created session title empty so untitled fallback localizes
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review — dedupe ensureSession + parse upload response
- chat-window: cache in-flight createSession promise in a ref so a file drop
followed by a quick send no longer spawns two sessions (and orphans the
attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
matching the API boundary rule.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface
1. Backend test was 400ing because the handler reads workspace from
middleware-injected ctx, and `newRequest` only sets the header. Helper
`withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
loads the member row + SetMemberContext before invoking the handler.
2. Attachment metadata now flows end-to-end:
- new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
comment-side query)
- `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
surfaces them — same shape as CommentResponse
- `ListChatMessages` loads them via a new `groupChatMessageAttachments`
helper so the chat bubble can render file cards
- daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
user message and ships `ChatMessageAttachments` to the daemon
- `buildChatPrompt` lists id+filename+content_type and instructs the
agent to `multica attachment download <id>` — fixes the private-CDN
expiring-URL problem where the markdown URL would have expired by
the time the agent acts
- TS `ChatMessage` gains an optional `attachments` field
3. Chat composer now blocks send while uploads are in flight:
- `pendingUploads` counter increments in handleUpload, SubmitButton
uses it to disable
- handleSend also gates on `editorRef.current.hasActiveUploads()` to
catch the Mod+Enter path that bypasses the button
- new vitest covers the "drop large file → immediate send" scenario
where attachment id would otherwise be silently dropped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore: drop implementation plan doc
Process artefact, not something the repo needs to keep.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The earlier deep-link fix (0d0d100e) used a fixed 20-frame rAF poll to
wait for Virtuoso to mount the target before handing off to the
browser's native scrollIntoView. That approach failed under three
conditions all reproduced on the 500-comment perf fixture:
1. Items near the bottom of long lists: Virtuoso's estimate→mount→
ResizeObserver→correction sequence stretches past 320ms; the
poll gave up and set highlight without scroll.
2. Tall markdown/code-block comments: the target mounted within the
poll window but its measured height was not yet final (lowlight
was still highlighting). scrollIntoView landed on the not-yet-
reflowed card; the card grew a moment later and dragged the
target out of view.
3. Late image loads or any post-mount layout shift inside the
timeline: the browser's built-in CSS scroll-anchoring silently
nudged scrollTop after we had already finished, putting the
target back off-center.
The root cause is the same race that every variable-height
virtualizer has — official react-virtuoso #1263 calls it out as
intentional, and #1296 shows even Virtuoso's own `scrollIntoView({done})`
callback is unreliable across the same scenarios. The fix is
virtualizer-agnostic: don't trust *any* "we landed" signal the
virtualizer gives you. Wait for the real DOM node to stop reflowing
before handing off to the browser.
Four phases now:
Phase 1 (coarse): virtuosoRef.scrollToIndex only to *mount* the
target. The scroll position it produces is discarded.
Phase 2 (adopt): MutationObserver on the scroll container picks
up the target node as soon as it enters the DOM.
Phase 3 (settle): a ResizeObserver on the target with a
"settle-by-silence" timer — every RO tick re-arms a 120ms idle
window; when the window elapses with no further ticks the card
is treated as stable. Baseline 150ms timer so a fully-static
card (or test env with stubbed RO) still proceeds.
Phase 4 (land): native el.scrollIntoView({block:'center'}), then
light the highlight on `scrollend` (or a 200ms fallback for
Safari < 17.4 and jsdom, both of which never fire scrollend).
Hard 2.5s cap on the whole pipeline so a comment whose images load
indefinitely doesn't leak observers; in that case we still attempt a
final scroll with whatever's measured and flash the highlight so a
manual scroll lands on a marked card.
CSS partner: `overflow-anchor: none` on the scroll container disables
the browser's automatic re-anchoring on layout shifts above the
viewport. Without this even a perfectly-landed scrollIntoView can be
silently nudged off-target by a late ResizeObserver pass on a
comment above the viewport.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes three gaps in the Linux desktop build that combined to render the
Multica window with the system Settings (gear) icon on Ubuntu:
1. Force `linux.executableName: multica` so the scoped npm name
`@multica/desktop` stops leaking into `executableName`, the `.desktop`
filename, the `Icon=` field, and `/usr/share/icons/hicolor/*/apps/*.png`.
The leading `@` in the previously-generated `@multicadesktop` violates
freedesktop desktop-entry naming, breaking GNOME's window↔.desktop
association and forcing the theme-default icon. (The artifact-filename
side of the same scoped-name leak was already patched in 10618b1f;
this commit closes the desktop/icon-identity side.)
2. Always set `BrowserWindow({ icon })` on Linux — previously gated on
`is.dev`. AppImage direct-launches never install the `.desktop` entry,
so without an explicit window icon the WM has no other path to the
bundled image. The resolved path now points into `app.asar.unpacked/`
(matching the existing `bundledCliPath()` convention in
`daemon-manager.ts`) since the Linux native icon code path requires a
real filesystem path, not an asar-internal one.
3. Pin `linux.desktop.entry.StartupWMClass: Multica` explicitly. The
value already matches the productName-derived default, so this is a
build-time no-op today, but it makes the WM_CLASS↔StartupWMClass
matching contract auditable in config — future changes to
`productName` or `app.setName()` now show up as a diff against this
file instead of silently re-breaking the icon association.
Fixes https://github.com/multica-ai/multica/issues/2424.
* docs(self-hosting): document Caddy WebSocket essentials
Add a single-domain Caddy example and harden the separate-domain one
with the WebSocket route a self-hoster actually needs:
- handle /ws* (prefix match, not exact `/ws`) so future path variants
don't fall through to the frontend block
- flush_interval -1 inside the WS reverse_proxy, otherwise frames sit
behind Caddy's default flush window and surface as "comments only
appear after a page refresh"
Both gaps were hit by a self-hosted user on a single-domain Caddy
deployment, and neither was documented.
Co-authored-by: multica-agent <github@multica.ai>
* docs(self-hosting): tighten Caddy /ws matcher to avoid catching `/ws-*` slugs
Use a named matcher `path /ws /ws/*` instead of the over-broad `handle /ws*`.
Caddy's `*` is a path-glob without segment boundary, so `/ws*` would also
match unrelated paths like `/ws-foo` — which is a legitimate workspace URL
under the current reserved-slug rules (only the exact `ws` slug is reserved).
Per GPT-Boy review on PR #2436.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The Diagnostics card's Visibility section had a two-line layout — icon +
label on top, descriptive hint underneath — which made it look noisy next
to the compact Timezone / CLI sections. Move the hint into a tooltip on
hover and collapse the buttons into a tight segmented-toggle pair
matching the runtimes-page Mine/All filter pattern. Readout side mirrors
the change: chip-only, full description on hover.
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent
Closes the hole where a plain workspace member could pick another member's
runtime in the Create Agent dialog and bind an agent to it — the backend
wasn't checking runtime ownership, so the agent ran on someone else's
hardware / tokens. Reported on GH #1804.
Schema
- Migration 083 adds agent_runtime.visibility ('private' default, 'public')
with a CHECK constraint. Existing rows default to private — same
ownership semantics as before, no behavior change for legacy data.
Backend
- canUseRuntimeForAgent predicate: allow when caller is workspace
owner/admin, the runtime owner, or the runtime is public.
- CreateAgent and UpdateAgent both gate on it: UpdateAgent matters because
a plain member could otherwise create on their own runtime, then re-bind
to a private one.
- PATCH /api/runtimes/:id accepts { visibility } — owner/admin only,
validated against the same private/public allow-list.
Frontend
- Create-agent dialog renders other-owned private runtimes disabled with a
Lock badge + tooltip explaining who to ask.
- Inspector runtime-picker disables the same set so re-binding fails
the same way at the UI layer.
- Runtime detail diagnostics gains a Visibility editor (owner/admin) or
read-only chip (everyone else).
- Runtime list shows a private/public chip next to the name.
Tests
- Go: canUseRuntimeForAgent truth table; CreateAgent / UpdateAgent
end-to-end gate tests (admin / runtime owner / plain member);
PATCH visibility owner / admin / member / invalid-value coverage.
- Vitest: create-agent dialog disabled state on private/public runtimes,
default-runtime selection skips locked rows; runtime detail visibility
editor → mutation, read-only fallback.
Migrating runtimes: existing rows default to private to preserve the
"owner only" status quo. Owners switch to public via the detail page
diagnostics card.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtime): apply timezone+visibility atomically; don't seed locked template runtime
Two issues surfaced in review of MUL-2062:
1. PATCH /api/runtimes/:id ran the timezone branch first, which:
- returned early on a tz no-op, silently dropping a concurrent
`visibility` patch in the same body;
- committed the timezone mutation (+ usage rollup rebuild) before
validating visibility, so an invalid visibility left the row
half-updated.
Validate every field first, then run the mutations in order. The
no-op short-circuit now only triggers when nothing else is requested.
2. The Create Agent dialog in duplicate mode unconditionally seeded
`template.runtime_id` as the selected runtime, even when that runtime
is now private and owned by someone else — the user saw a selected
row they couldn't submit (Create → backend 403). Fall back to the
first usable runtime when the template's runtime is locked, and gate
the Create button on `selectedRuntimeLocked` as defense in depth.
Tests:
- Go: TestUpdateAgentRuntime_CombinedPatchAppliesBoth (tz no-op +
visibility flip), TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone
(atomic-fail invariant).
- Vitest: duplicate template pointing at a locked runtime now seeds
the first usable one; Create button stays disabled when no usable
alternative exists.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* perf(views): virtualize issue detail timeline with react-virtuoso
The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.
Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.
Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.
Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.
Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views): repair deep-link scroll and isolate comment drafts
The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:
1. Deep-link landing position was wrong by ~380px on every issue.
In customScrollParent mode Virtuoso computes scrollTop from the
list's internal coordinate space only — it doesn't account for
sibling content (title editor, description, sub-issues, agent
card) sitting above the list inside the same scroll parent. The
useEffect now uses Virtuoso scrollToIndex only to MOUNT the
target into the DOM, then polls a `data-comment-id` anchor and
delegates positioning to the browser's scrollIntoView, which
honors getBoundingClientRect and lands accurately every time.
2. Scroll-up was being yanked back to the deep-link anchor on every
ResizeObserver tick. Root cause was `followOutput="auto"`, which
stays "stuck to bottom" once the deep-link lands there and resets
scrollTop to maxScrollTop on each height change. Issue detail is
document-shaped, not chat-shaped, so removing followOutput
altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
acts as a persistent anchor in customScrollParent mode (Virtuoso
#458) — dropped entirely and replaced with imperative scroll.
`defaultItemHeight` is also dropped so Virtuoso probes real
heights instead of estimating + correcting visually.
3. Reply-comment deep-links from the inbox would short-circuit
because the reply id isn't in the flat items[] array. Added a
replyToRoot map so deep-link falls back to the enclosing thread's
root index, scrolls there, and lets the reply's own ring fire
once the thread is in view.
Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.
Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summarizes the 24 PRs landed since v0.2.29 in EN and ZH changelog
data, organized into features, improvements, and fixes.
Co-authored-by: multica-agent <github@multica.ai>
Three user reports converge on the same Windows-shell encoding bug:
- #2198 / #2236 — Chinese, Codex on Win11. Comments / descriptions
generated by the agent arrive as `?`.
- #2376 — Cyrillic, non-Codex agent ("Ops Lead") on Win11 Desktop.
Title preserved (argv → CreateProcessW UTF-16), description / agent
reply garbled (stdin → shell-codepage re-encoding).
woodcoal's independent diagnosis on #2198 confirms the root cause:
Windows PowerShell 5.1's `$OutputEncoding` defaults to ASCIIEncoding
when piping to a native command, so non-ASCII bytes are silently
replaced with `?` before they reach `multica.exe`. The CLI's stdin
parsing is fine; the bytes are corrupted upstream, in the agent's
shell layer.
This PR ships the fix that supersedes the codex-only attempt in
PR #2265 (which is closed in favour of this one):
## CLI
Add `--content-file <path>` to `multica issue comment add` and
`--description-file <path>` to `multica issue {create,update}`. The
CLI reads bytes off disk via `os.ReadFile` and skips the shell
entirely; UTF-8 survives end-to-end regardless of `$OutputEncoding`
or `chcp`. The three input modes (`--content`, `--content-stdin`,
`--content-file`) are mutually exclusive.
## Runtime config
`buildMetaSkillContent`'s Available Commands section is rewritten as a
neutral three-mode menu. The previous unconditional "MUST pipe via
stdin" / `--description-stdin` mandate (over-spread from #1795 /
#1851's Codex-multi-line fix) is gone for non-Codex providers; the
strong directive now lives only in the Codex-Specific section, which
branches on host:
- Codex / Linux+macOS: `--content-stdin` + HEREDOC (preserves MUL-1467
fix against codex's literal `\n` habit).
- Codex / Windows: `--content-file` (PowerShell ASCII pipe is the
exact bug we're patching).
## Per-turn reply template
`BuildCommentReplyInstructions` now takes a provider arg and branches
provider × OS:
- Windows + any provider → `--content-file` (the bug is shell-layer,
not provider-layer; #2376 shows non-Codex agents on Windows also
hit it). All providers write a UTF-8 file with their file-write tool
and post via `--content-file ./reply.md`.
- Linux/macOS + Codex → stdin/HEREDOC (MUL-1467 protection).
- Linux/macOS + non-Codex → lightweight pre-#1795 inline
`--content "..."`. The CLI server-side decodes `\n`, so escaped
multi-line works; the agent retains stdin / file as escape hatches
for richer formatting.
`BuildPrompt` and `buildCommentPrompt` gain a `provider` arg;
`daemon.runTask` already has it in scope.
## Tests
- `TestResolveTextFlag` — file-source verbatim with non-ASCII
(`标题 / Заголовок / 中文段落`), missing-file error, empty-file
rejection, three-way mutual exclusion.
- `TestInjectRuntimeConfigAvailableCommandsIsNeutral` — every
non-Codex provider × {linux, darwin, windows} pins the three-mode
menu present + over-spread "MUST stdin" substrings absent.
- `TestInjectRuntimeConfigCodexLinuxEmphasizesStdin` +
`TestInjectRuntimeConfigCodexWindowsUsesContentFile` — Codex
section's per-OS branch.
- `TestBuildCommentReplyInstructionsCodexLinux` +
`TestBuildCommentReplyInstructionsNonCodexLinux` +
`TestBuildCommentReplyInstructionsWindowsUsesContentFile` — the
reply-template provider × OS matrix.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` — end-to-end
AGENTS.md / CLAUDE.md on Windows has no prescriptive stdin
directive, for claude / codex / opencode.
`go test ./...` and `go vet ./...` clean.
Closes#2198, #2236, #2376.
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): namespace recent-issues by workspace id in state
The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.
Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.
Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).
Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.
* fix(core): fail closed on null slug in workspace-aware storage
createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.
Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.
---------
Co-authored-by: YYClaw <yyclaw0@gmail.com>
* fix(attachments): re-sign CloudFront download URLs at click time
The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).
- Add `GET /api/attachments/{id}` client method that re-signs on every call,
validated by a stricter `AttachmentResponseSchema` (enforces `url`,
`download_url`, `filename` so a malformed response degrades to the
EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
- Web: synchronously open `about:blank` inside the click gesture to keep
popup activation, then hydrate `location.href` after the fetch. Cannot
pass `noopener` here — HTML spec dom-open step 17 makes that return
null.
- Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
buttons resolve the attachment id by URL match; external URLs fall back
to `openExternal`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): re-sign downloads from ContentEditor file/image NodeViews
The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:
- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)
- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
can resolve markdown URLs to an attachment id and call the existing
`useDownloadAttachment` hook. The default fallback (no provider mounted)
hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The Pi backend hardcoded `--tools read,bash,edit,write,grep,find,ls` in
buildPiArgs. Pi's SDK treats --tools as a restrictive allowlist: only the
listed tools pass through `_refreshToolRegistry()`, silently filtering
out any user-installed extension tools registered via `pi.registerTool()`.
Omitting --tools makes Pi's `allowedToolNames` undefined, so the
`isAllowedTool()` filter becomes a no-op and all tools — built-in and
extension — are available. This matches Pi's standalone behavior.
Users who want to restrict tools can still pass --tools via custom_args
(it is not in piBlockedArgs).
Closes#2379
* feat(workspace): revoke a member's runtimes when they leave or are removed
Previously, leaving or being removed from a workspace only deleted the
member row — every runtime the departed user owned in that workspace
remained in the DB, kept its daemon_token valid, and stayed reachable to
the workspace's other members. The departed user lost access but their
machine kept doing work.
This change converges the runtime state in the same transaction as the
member-row deletion: agents pinned to those runtimes are archived,
in-flight tasks are cancelled (so the daemon's per-task status poller
interrupts the running agent gracefully), the runtimes are forced
offline, and the daemon_token rows are deleted. After commit the
DaemonTokenCache is invalidated and agent:archived / daemon:register
events fire so connected clients reconcile immediately.
Server-side state convergence is the production safety net; the
daemon_token revoke takes effect once the mdt_ flow is live (today most
daemons fall back to PAT/JWT, and the member-row deletion is what stops
those requests via requireWorkspaceMember).
Daemon-side handling (recognising the resulting 401/404 and tearing down
the local pairing for that workspace) lands in a follow-up.
Co-authored-by: multica-agent <github@multica.ai>
* fix(workspace): also cancel tasks for archived agents on member revoke
CancelAgentTasksByRuntime only matched tasks whose runtime_id was in the
revoked set, missing a real path: agent.runtime_id can be reassigned via
UpdateAgent, but agent_task_queue.runtime_id keeps the value from when
the task was queued. So an agent currently bound to the leaving member's
runtime gets archived correctly, but its older tasks still pinned to a
prior runtime stay 'queued' — and ClaimAgentTask does not gate on
agent.archived_at, so those orphaned tasks remain claimable by the
prior runtime.
Replace CancelAgentTasksByRuntime with CancelAgentTasksByRuntimeOrAgent,
which OR-matches runtime_ids and the archived agent IDs in one UPDATE.
Pass the archived agent IDs through from revokeAndRemoveMember.
Adds TestDeleteMember_CancelsTasksFromAgentReassignment as a regression
guard: same agent, two runtimes, the older task on the surviving runtime
must end up cancelled while the surviving runtime stays online.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): suppress git console windows on Windows
Apply the same HideConsoleWindow pattern used for agent processes
(PR #1474) to all git commands spawned by the daemon's repo-cache,
execenv, and GC packages. Each exec.Command now calls
util.HideConsoleWindow(cmd) which sets CREATE_NEW_CONSOLE + HideWindow
so grandchildren inherit a hidden console instead of flashing visible
console windows.
Closes#2357
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
* refactor: use EnsureHiddenConsole at daemon startup
Replace per-site HideConsoleWindow(cmd) calls with a single
EnsureHiddenConsole() invoked once at daemon startup. The daemon
now owns a hidden console that every child process (git, cmd /c
mklink, etc.) inherits automatically, eliminating the need for
per-call SysProcAttr configuration.
This also covers the previously missed exec.Command in
codex_home_link_windows.go (cmd /c mklink) which never had a
HideConsoleWindow call.
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
---------
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Chat input had `submitOnEnter` enabled while the comment editor used
`Mod+Enter`. Two consequences:
- Inconsistent muscle memory between the two inputs.
- In chat, bare Enter sending stole the only key that continues a
TipTap bullet/ordered list. Shift+Enter falls through to HardBreak
(a <br> inside the same list item), so bullet lists were stuck at
one item.
Drop `submitOnEnter` from the chat input so it follows the editor
default. Mod+Enter (⌘↵ / Ctrl+Enter) sends in both places; bare Enter
now continues lists and inserts paragraphs as users expect.
Surface the shortcut on the SubmitButton via a new optional `tooltip`
prop, and route the comment input through SubmitButton instead of an
ad-hoc Button — same affordance, deduped.
Add unit coverage for the submit-shortcut extension that pins
Mod-Enter, the submitOnEnter=false case, IME, and code-block guards.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat: per-runtime timezone for token usage aggregation
The runtime token-usage charts (daily and hourly tabs on the
runtime-detail page) bucketed every event by the Postgres session
timezone, which is UTC in production. For an operator in UTC+8 that
meant a Tuesday afternoon's tasks landed in Tuesday early-morning's
bar — the chart was always one off.
Fix: store an IANA timezone on agent_runtime and aggregate under it.
* migrations 081 / 082 add agent_runtime.timezone (TEXT NOT NULL
DEFAULT 'UTC') and rebuild the rollup pipeline (window function
and both trigger functions) to compute bucket_date with
AT TIME ZONE rt.timezone instead of bare DATE().
* No historical backfill — task_usage_daily rows already on disk
keep their UTC bucket_date; only future writes / re-touches
recompute under the new tz. (Product call from MUL-1950: 'guarantee
future correctness'.)
* runtime_usage.sql gains a @tz parameter on ListRuntimeUsage and
GetRuntimeUsageByHour and threads tz through GetRuntimeTaskHourly Activity. ListRuntimeUsageDaily reads bucket_date as-is since the
rollup already wrote it in tz.
* parseSinceParamInTZ replaces the raw N×24h cutoff with start-of-
day-N in the runtime's tz so 'last 7 days' lines up with bucket
boundaries.
* Daemon registration sends the host's IANA tz (TZ env, then
time.Local), and UpsertAgentRuntime preserves any user override
via a CASE-on-existing-value pattern so a daemon reconnect can't
silently revert the operator's setting.
* New PATCH /api/runtimes/:id endpoint (UpdateAgentRuntime) lets
the runtime detail page edit the tz; the editor seeds with the
browser tz on first interaction.
Refs: MUL-1950
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix: harden runtime timezone rollups
Co-authored-by: multica-agent <github@multica.ai>
* fix: address runtime timezone review nits
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
* fix(agent): expand Copilot CLI model catalog with correct dotted IDs
The Copilot CLI provider only exposed two models in the runtime
dropdown, and one of them used the dashed legacy form
`claude-sonnet-4-6` which `copilot --model` rejects with
"Model ... is not available". The CLI accepts dotted IDs
(e.g. `claude-sonnet-4.6`, `gpt-5.4`).
Sync `copilotStaticModels()` with the official supported-models
catalog so the dropdown surfaces the full set the user's account
can route to (8 OpenAI + 4 Anthropic), and add a regression test
that pins the expected IDs and bans the dashed form.
Closes MUL-1948.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agent): dynamic Copilot model discovery via ACP session/new
The previous static catalog could only ever lag behind the user's
real entitlements and what GitHub ships. Copilot CLI exposes the
live catalog through its ACP server (`copilot --acp`): the
`session/new` response includes `models.availableModels` plus
`currentModelId`, scoped to the authenticated account.
Wire copilot through the existing discoverACPModels helper —
already used by hermes/kimi/kiro — so the dropdown reflects the
account's real catalog, including the `auto` entry and per-tier
model availability (Pro / Pro+ / Enterprise / evaluation models).
The Copilot CLI puts itself into ACP server mode via the `--acp`
flag instead of an `acp` subcommand, so acpDiscoveryProvider now
takes an optional acpArgs override.
Copilot's ACP payload omits the vendor name, so a small
prefix-based inferCopilotProvider keeps the UI's openai /
anthropic / google grouping working.
When the binary is missing or auth fails, fall back to
copilotStaticModels() so self-hosted runtimes without a copilot
install still see a populated dropdown.
Verified against `copilot 1.0.44`: live discovery returns 13
models with gpt-5.5 marked Default. Closes MUL-1948.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): drop no-op COPILOT_ALLOW_ALL env and generalize OpenAI o-series prefix check
- discoverCopilotModels: remove COPILOT_ALLOW_ALL=1 (not a real
Copilot CLI env var; copy-pasta from HERMES_YOLO_MODE=1).
Discovery only drives initialize + session/new which never
trigger tool-permission prompts, so no extra env is needed.
- inferCopilotProvider: replace the o1/o3/o4 prefix chain with a
generic o<digit>+ check via isOpenAIReasoningSeriesID, so future
o5/o6/… reasoning models are tagged as openai automatically.
Guards against false positives like 'opus-…' or bare 'o'.
- Extend TestInferCopilotProvider with o5/o6 forward-compat cases
and negative cases (opus-fake, omni, o).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtimes): let users set custom prices for unmaintained models
The Runtime > Usage pricing diagnostic previously told users to "edit
packages/views/runtimes/utils.ts" when a model wasn't priced. That's
fine for us, useless for everyone else. We can't track every model
release, so let users supply their own per-million-token rates for
anything we don't ship a maintained rate for (e.g. gpt-5.5-mini today).
- Add a persisted Zustand store (custom-pricing-store) keyed by model
name; rates live in localStorage so they survive reloads.
- resolvePricing consults the maintained MODEL_PRICING catalog first,
then falls back to the store. Catalog still wins on overlap so a
stale local override can't shadow a known rate.
- EmptyChartState gains a "Set custom prices" button when unmapped
models exist; the dialog lists every unmapped model plus everything
already overridden so users can edit / clear prior entries.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): show pricing-gap notice for partial unmapping; invalidate cost memos on price save
Two bugs surfaced in review:
1. The "Set custom prices" CTA only showed inside EmptyChartState, which
only fires when Daily / Hourly total cost is exactly 0. Mixed windows
(some priced + some unpriced models) rendered the chart normally and
left no entry point — the unpriced tokens silently contributed \$0
to totals.
Add a permanent UnmappedPricingNotice above the KPI grid that appears
whenever collectUnmappedModels(filtered) is non-empty, regardless of
chart state. EmptyChartState keeps the diagnostic text but the CTA
button moves to the notice so the two surfaces don't duplicate.
2. The aggregate useMemo blocks (WhenChart's dailyCostStack / hourlyCost,
CostByBlock's byAgent / byModel, ActivityHeatmap's cells) keyed only
on their query data. After a price save the parent re-rendered, but
the memos returned cached pre-save totals because their deps were
identical. The KPI cards updated; the charts did not.
Subscribe to the pricing store in each aggregating component and
list `pricings` as a memo dependency. The store returns a stable
reference until setCustomPricing fires, so memos only invalidate
on real changes.
New unit tests cover both: a mixed priced/unpriced aggregate produces
mixed costs (and surfaces the unpriced names), and aggregateCostByModel
called twice on the same input array reflects a freshly-saved override.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): allow same-origin WebSocket clients (mobile/CLI)
The previous CheckOrigin implementation (PR #2318) bypassed the Origin
check whenever the request URL carried `client_platform=mobile` and no
browser session cookie. That contract requires every native client to
remember to add a query parameter — and in practice mobile clients hit
ws://localhost:8080/ws with no extra params, so the Origin filled by
the WebSocket library (the server's own host) gets rejected.
Replace the platform-specific bypass with same-origin acceptance: if
Origin's host equals the request Host, allow the upgrade. This is
gorilla/websocket's default CheckOrigin behavior, restored alongside
the existing cross-origin allowlist (for browser web/desktop clients).
Native clients are now zero-config. CSRF defense is unaffected:
SameSite=Strict cookies, the multica_csrf token, workspace membership
check, and the allowlist itself remain in place. Browser CSWSH attacks
fail both same-origin (browser forces Origin = page origin, not the
server's Host) and allowlist checks.
Refs: https://pkg.go.dev/github.com/gorilla/websockethttps://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): use case-insensitive Host comparison for same-origin
HTTP host is case-insensitive (RFC 7230 §2.7.3), and gorilla/websocket's
default checkSameOrigin uses equalASCIIFold(u.Host, r.Host). The plain
== comparison would reject legitimate same-origin requests with a
case-mismatched Host header (e.g. Host: LOCALHOST:8080 vs
Origin: http://localhost:8080).
Switch to strings.EqualFold and cover the case with a regression test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): gate private-agent surfaces with allowed_principals predicate
Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.
- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
private-agent existence + counts), chat.CreateChatSession,
chat.SendChatMessage (re-checks on every send so role changes can't leave
a stale session as a back-door), and autopilot.shouldSkipDispatch
(caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
owner/admin members. No new table — manual config is intentionally not
exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
can't access) from 404 (deleted/missing) and renders a "no access"
placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
vitest passes for the affected views.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): gate issue assignment with the private-agent predicate
Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.
Add an integration test covering all three callers (workspace owner,
agent owner, plain member).
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close three private-agent gate bypasses found in PR review
1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
X-Agent-ID before trusting the agent identity. Without this a plain
workspace member could set X-Agent-ID to any visible agent UUID and
short-circuit the gate to "actor=agent, allow". Daemons already
pair the two headers, so legitimate A2A traffic is unaffected.
2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
GetPendingChatTask / MarkChatSessionRead now go through a new
gateChatSessionForUser helper that re-applies canAccessPrivateAgent
after the ownership check, so a session creator whose role was later
downgraded loses transcript access. ListChatSessions and
ListPendingChatTasks filter their result sets by the same predicate.
3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
resolve the mentioned agent via GetAgentInWorkspace scoped to the
issue's workspace so a UUID belonging to a different workspace's
private agent can't slip past the gate (the gate was being applied
against the current workspace's role table, which is the wrong
one).
Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests
After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema
The previous CI run still failed in two places:
1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
only set X-Agent-ID, so resolveActor downgraded the request to "member"
and the on_comment chain produced the wrong task counts. Fix:
authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
ensureAgentTask(agentID) helper.
2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
INSERT was missing comment.workspace_id, which migration 025 made
NOT NULL. Pass testWorkspaceID into the seed row.
Build + vet clean locally; both packages compile.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
When clicking an inbox notification for a different issue, the IssueDetail
remounts and both the issue detail and timeline queries fetch in parallel.
If the timeline query resolves first, `timeline.length` flips to >0 while
`loading` is still true — at that moment the component is rendering the
skeleton, so `getElementById('comment-<id>')` returns null and the scroll
silently fails. Without `loading` in the effect's deps, the effect never
re-runs when the issue finally loads, leaving the user at the top of the
issue instead of jumping to the highlighted comment.
Add `loading` to the early-return guard and to the dep list so the scroll
fires once both the issue and its comments are mounted. The dropped
`return () => clearTimeout(timer)` was inside requestAnimationFrame and
never functioned as cleanup — removed for clarity.
Test seeds the timeline cache and holds back the issue fetch to reproduce
the race deterministically; without the fix the regression test times out
waiting for scrollIntoView.
Co-authored-by: multica-agent <github@multica.ai>
The Changelog link rendered as plain text next to two pill-shaped
buttons, breaking the header's visual rhythm. Reuse the shared ghost
button helper so all secondary actions share one shape language.
Surfaces the changelog page from the marketing site's top navigation,
sitting alongside GitHub and the auth CTA. Hidden below the `sm`
breakpoint so the mobile header stays compact.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): allow --mode run_only on autopilot create/update
The autopilot run_only dispatch path is wired end-to-end (handler accepts
the mode, AutopilotService.dispatchRunOnly enqueues a task with
AutopilotRunID, daemon resolves workspace via autopilot_run -> autopilot
in ClaimTaskByRuntime and TaskService.ResolveTaskWorkspaceID). The CLI
guard was added before those fixes landed and never removed.
Drop the CLI rejection on both create and update so callers can pick the
same modes the API and UI already support, and remove the stale "unstable"
callout from the autopilots docs.
Closesmultica-ai/multica#2347
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): advertise autopilot run_only in agent runtime instructions
The runtime config injected into AGENTS.md / CLAUDE.md only listed
`--mode create_issue` for autopilot create and didn't expose `--mode` on
update at all. So even after the CLI guard was lifted, agents reading
their harness instructions would still believe create_issue was the only
choice — undermining the "agents operate the same surface as humans"
intent.
Update both lines to advertise create_issue|run_only on create and on
update, and add an InjectRuntimeConfig assertion so the runtime prompt
can't drift away from the CLI surface again.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The inline path now carries the full runtime brief (CLI catalog,
workflow steps, persona, skills, project context) rather than just
identity/persona instructions, after #2353 / #2355. The pre-existing
comment still described it as "identity/persona instructions inline",
which would mislead future maintainers about why the inline payload is
load-bearing.
Also call out kiro/kimi alongside openclaw/hermes since they were added
to providerNeedsInlineSystemPrompt in #2328, and document the concrete
failure mode (issues stuck in todo) so the rationale is searchable.
Co-authored-by: multica-agent <github@multica.ai>
InjectRuntimeConfig writes the full meta skill content (CLI catalog,
workflow instructions, project context, skills) to workdir/AGENTS.md,
but providers like OpenClaw, Hermes, Kiro, and Kimi read bootstrap
files from their own agent workspace — not the task workdir. The
inline system prompt path (providerNeedsInlineSystemPrompt) only
passed the agent persona instructions, so these providers never
received the runtime brief.
Have InjectRuntimeConfig return the rendered content so the daemon can
both write it to disk (for file-reading providers) and pass it inline
(for workspace-isolated providers). This avoids double-rendering and
keeps the file and inline payloads identical.
Fixes#2353
* feat(editor): render mermaid diagrams inside issue descriptions
Issue descriptions are rendered through the Tiptap-based ContentEditor
(not ReadonlyContent), so the mermaid handler that PR #1888 added to
ReadonlyContent never reached them. Comments worked because comment-card
toggles between ContentEditor (edit mode) and ReadonlyContent (display
mode); issue descriptions stay in ContentEditor permanently.
This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid
preview when the language is `mermaid`, giving issue descriptions a
split view: live diagram on top, editable source below. Theme variables
(light/dark), the sandboxed iframe, the lightbox and error fallback all
come from the existing implementation — only the location moved.
Changes:
- Extract MermaidDiagram + helpers (theme detection, sandbox iframe,
lightbox, useThemeVersion) from `readonly-content.tsx` into a new
`editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter)
imports the same component, so comment-card / inbox rendering is
unchanged byte-for-byte.
- Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render
`<MermaidDiagram>` above the editable source whenever the block's
language is `mermaid` and the source is non-empty.
Tested:
- pnpm --filter @multica/views typecheck — clean
- pnpm --filter @multica/views test — 327 tests pass (43 files)
- Manually verified a mermaid block in an issue description renders as
an SVG flowchart while staying editable underneath.
Closes#2079
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(editor): debounce mermaid preview re-renders during edits
Addresses review feedback on #2297. Previously every keystroke in a
Mermaid code block triggered `mermaid.initialize() + render()` on the
CodeBlockView preview. Because `mermaid.initialize()` mutates a
process-global config, those bursts could race a concurrent
ReadonlyContent render (e.g. a comment card) and clobber its theme
variables.
200ms is short enough that the preview still feels live during typing
but long enough to make concurrent inits unlikely in practice. The
ReadonlyContent path is unchanged: chart there is the saved markdown
and never changes after mount, so the race only existed on the new
edit-time path this PR introduced.
A small `useDebouncedValue` hook local to the file gates `chart` so
that it only flows into MermaidDiagram after 200ms of stable input.
When the language is non-Mermaid the hook short-circuits to "", so
non-Mermaid blocks pay no extra cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sub-issue rows on the parent issue's detail page now expose inline StatusPicker and AssigneePicker, optimistically syncing the children cache via a useUpdateIssue parent-id fallback that scans loaded children caches.
- Hover-revealed checkbox + indeterminate select-all in the section header drive batch selection through the existing useIssueSelectionStore; the BatchActionToolbar gains a "placement" prop and renders inline directly under the sub-issues header so the action is right next to the rows.
- useBatchUpdateIssues / useBatchDeleteIssues now mirror their optimistic patches into every loaded children cache (with rollback) and invalidate children + childProgress on settle.
- SubIssueRow restructure: AppLink wraps only the identifier + title, so the checkbox / picker areas no longer accidentally fire navigation.
Refs MUL-2005.
* fix(runtimes): price OpenAI Codex / GPT models so cost stops showing $0
The runtime detail / usage charts compute cost client-side from
MODEL_PRICING, but the table only had Claude entries. Codex CLI
sessions report models like gpt-5-codex / gpt-5, so estimateCost()
returned 0 for every Codex runtime — the dashboard read $0 even on
runtimes with billions of tokens consumed.
Add pricing rows for the GPT-5 family (incl. -codex/-mini/-nano), the
o-series reasoning models, and GPT-4o, ordered so the startsWith()
fallback resolves the more-specific variants first. Cover the new
entries with a small unit test for utils.ts.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): require explicit price rows for catalog SKUs (no startsWith fallback)
Per review: the previous startsWith() fallback let `gpt-5.5*` / `gpt-5.4*`
inherit the lower-tier `gpt-5` price. Address by:
- Add explicit rows for every dotted Codex catalog SKU listed in
server/pkg/agent/models.go: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex.
- Drop the startsWith fallback in resolvePricing entirely. Anything not
exactly matching a row (after date-snapshot stripping) is now reported
as unmapped — the diagnostic surfaces it rather than silently absorbing
it into a near-named relative.
- Extend the date-strip regex to also handle `2025-08-07`-style dashes
(OpenAI snapshot format) in addition to the `20250929` Anthropic format.
- Tests cover dotted SKUs at their own tier, gpt-5-2025-08-07 stripping,
and explicitly assert that gpt-5.5-mini (catalog SKU without a published
OpenAI price) is unmapped instead of borrowing gpt-5.5's row.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
`hermes`, `kimi`, and `kiro` all wired stderr through
`cmd.Stderr = io.MultiWriter(logWriter, providerErrSniffer)`.
The OS-pipe → MultiWriter copy goroutine that exec spawns for
that form is only joined by `cmd.Wait()`, which the lifecycle
goroutine fires in deferred cleanup — *after*
`promoteACPResultOnProviderError` already consulted the sniffer.
When stopReason=end_turn (success) raced ahead of the stderr
drain, the sniffer's `lines` slice was empty, the helper fell
through to the synthetic agent-text fallback ("hermes provider
error: API call failed after 3 retries"), and the actionable
upstream signal (HTTP 429 / usage limit) was lost.
This was visible as a flaky
`TestHermesBackendPromotesProviderErrorWithNonEmptyOutput` in CI
under high parallelism — a real prod bug, not a test issue: live
runs hit the same race when an upstream LLM returns 429 and
hermes' synthetic agent turn beats the stderr drain to the
parent.
Replace the MultiWriter wiring with `cmd.StderrPipe()` + an
explicit copier goroutine that signals on `stderrDone`. The
lifecycle goroutine already awaits `<-readerDone` for stdout;
add `<-stderrDone` next to it before `promoteACPResultOnProviderError`
runs. The deferred `cmd.Wait()` ordering is unchanged — it just
becomes a cheap reap by the time it fires.
Verified: `go test ./pkg/agent/ -run "TestHermes|TestKimi|TestKiro"
-count=10 -race`, then full package `-count=3 -race`, all green.
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): stop full timeline re-render on every WS event (MUL-1941)
Two compounding causes made every Comment/reply WS event re-render every
sibling thread on the issue detail page — visible during AI streaming as
a flash across all 10 nested replies under a parent and as the green
reply-input losing its draft.
1) `useCreateComment.onSettled` invalidated the timeline query, forcing a
full `GET /timeline` refetch on every comment submit. The response
replaced every entry's reference even when the content was unchanged,
poisoning every downstream React.memo. The `comment:created` WS
broadcast already keeps the cache fresh and `useWSReconnect` invalidates
on disconnect, so the redundant refetch had no upside. Drop it.
2) The `timelineView` useMemo passed the full `repliesByParent: Map` to
every CommentCard. Each WS event rebuilt the Map (new ref), so React.memo
on CommentCard fell back to a re-render for *every* card, not just the
one whose thread changed. Replace the Map prop with a per-thread
`replies: TimelineEntry[]` slice, precomputed once via
`collectThreadReplies` and stabilized against the prior render — when a
thread's flat list is shallow-equal to last time, reuse the previous
array reference so unrelated cards keep their memo.
ResolvedThreadBar gets the same `replies` prop, so the collapsed count +
author list still match the expanded view without re-walking the graph.
Verified: pnpm typecheck + pnpm test for @multica/views and @multica/core
(334 + 214 tests, all passing).
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): mark timeline stale without refetching active queries (MUL-1941)
Per GPT-Boy's review on PR #2329: dropping `useCreateComment.onSettled`'s
invalidate wasn't enough. The global `useRealtimeSync` runs in WSProvider
for the lifetime of the app and re-invalidates the timeline on every
`comment:created` / `comment:updated` / `comment:deleted` /
`comment:resolved` / `comment:unresolved` / `activity:created` /
`reaction:added` / `reaction:removed` event. With `staleTime: Infinity` on
the QueryClient default, the active timeline query refetches on every
invalidate — replacing every entry's reference and busting the per-thread
memoization the prior commit just put in place.
Switch the global handler's `invalidateQueries` to `refetchType: "none"`.
Active observers now stay fresh via the granular `setQueryData` handlers
in `useIssueTimeline`; inactive issues' caches are still marked stale, so
when IssueDetail mounts later, `refetchOnMount` triggers a fresh fetch
the same way it did before.
`comment:resolved` / `comment:unresolved` previously had no granular
handler — only the global invalidate kept the cache in sync. Add
useWSEvent handlers in `useIssueTimeline` that replace the matching
entry via `commentToTimelineEntry`, and extend that helper to carry the
resolved_at / resolved_by_type / resolved_by_id fields so resolved state
survives the round-trip (it was silently dropped on every
`comment:updated` too — fixed as a side effect).
Tests: 3 new cases covering resolved / unresolved / cross-issue isolation
in the timeline hook. All 337 + 214 unit tests + full monorepo typecheck
pass.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Kiro and Kimi share Hermes' ACP architecture and already accept
SystemPrompt prepended in front of the user prompt (kiro.go:244-247,
kimi.go:256-257). Without daemon-side opt-in, ExecOptions.SystemPrompt
is never set, so per-task agent identity instructions are lost in
deployments that rely on inline injection (e.g. K3 Lens-style
daemon → wrapper → docker compose exec acp).
Co-authored-by: multica-agent <github@multica.ai>
ACP backends (Kiro, Hermes, Kimi) put the actionable reason for
code=-32603 'Internal error' in the JSON-RPC `data` field, e.g.
"No session found with id". The wrapped Go error only carried
`code` and `message`, leaving operators staring at a bare
"kiro session/prompt failed: session/prompt: Internal error
(code=-32603)" with no way to tell apart session expiry, model
unavailability, lost auth, or quota.
Parse `data` too. Strings render unquoted; objects/arrays render
as raw JSON; null/missing keeps the previous format unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): mark provider 429 / out-of-credit runs as failed, not completed
Two bugs combined to silently report failed agent runs as
"Completed" in the UI when the upstream LLM returned a 4xx (e.g.
HTTP 429 rate-limit / no credit on the account).
1. ACP backends (hermes, kimi, kiro) only promoted the run status to
"failed" when their stderr sniffer fired AND the agent output
buffer was empty. But hermes injects a synthetic agent text turn
("API call failed after 3 retries: HTTP 429...") on retry
exhaustion, so the buffer was never empty in the rate-limit
case and the promotion never ran. Drop the empty-output
precondition: the sniffer's regex (HTTP-status markers, named
error types) is specific enough to trust on its own.
2. The daemon's task-result switch only routed "blocked" through
FailTask; every other status — including "cancelled", and any
future status we forget to enumerate — fell through to
CompleteTask. Invert it so only an explicit "completed" status
reports success, and extract the switch into reportTaskResult
for direct testing. Cancelled now defaults to failure_reason
"cancelled" instead of being silently completed.
Closes GitHub multica#1952.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): only promote ACP run to failed on terminal provider error
Address GPT-Boy's review on the multica#1952 fix. The previous
promotion rule ("any sniffer line → fail") was too broad: the
existing sniffer also captures transient per-attempt warnings
("API call failed (attempt 1/3): RateLimitError [HTTP 429]"), and
those lines stay in the buffer for the rest of the run. A retry
sequence whose first attempt blipped but whose third attempt
succeeded would have been wrongly reported as failed.
Tighten the criteria with two additional signals, both defined on
the existing acpProviderErrorSniffer / output buffer:
- acpTerminalErrorRe — sticky `terminal` flag set when stderr shows
an exhausted/non-retryable marker (❌, [ERROR], "after N retries",
Non-retryable, BadRequestError, AuthenticationError). Per-attempt
warnings deliberately don't match.
- acpAgentOutputTerminalRe — matches the synthetic "API call failed
after N retries..." turn that hermes-style adapters inject into
the agent text stream when they give up; this catches multica#1952
even if hermes' stderr only logged transient attempts.
Promotion logic becomes a shared helper, promoteACPResultOnProviderError,
called from hermes / kimi / kiro. Promotes when (a) terminalMessage
is non-empty, (b) output contains the synthetic give-up turn, or
(c) output is empty and the sniffer captured anything at all
(preserves the original empty-output safety net for transient-only
sequences with no real result to fall back on).
Tests:
- TestHermesProviderErrorSnifferTerminalVsTransient — transient
attempt 1/3 alone returns terminalMessage="" but message!="";
a follow-on terminal marker flips terminal on.
- TestHermesProviderErrorSnifferTerminalNonRetryable — confirms
BadRequest / Authentication / Non-retryable / ❌ / [ERROR] are
classified terminal even on the very first attempt.
- TestHermesBackendDoesNotPromoteOnTransientRetry — fake hermes
emits attempt 1/3 to stderr then a normal agent text turn and
end_turn; resulting Status must stay "completed".
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(quick-create): add project picker that remembers last pick
Quick-create users targeting one project repeatedly had to restate "in
project X" in every prompt. The modal now exposes a project picker beside
the agent picker, persists the selection per-workspace, and pins the
agent's `multica issue create` invocation to that project so the prompt
text doesn't have to.
The picked project also flows to the daemon as ProjectID/ProjectTitle and
its github_repo resources override the workspace repo fallback — same
treatment issue-bound tasks already get.
Co-authored-by: multica-agent <github@multica.ai>
* fix(quick-create): move project picker into property pill row
Reviewer feedback: the picker felt out of place wedged next to the agent
header. Move it into a property toolbar row above the footer, reusing the
shared `ProjectPicker` + `PillButton` so its placement and styling line up
exactly with the manual create panel.
This also drops the bespoke dropdown / aria / label strings that were only
needed while the picker rendered inline beside "Created by".
Co-authored-by: multica-agent <github@multica.ai>
* fix(quick-create): clear stale persisted project + carry across mode switch
Two review-blocking bugs in PR #2321:
1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0
and only cleared local state, leaving lastProjectId pointing at a deleted
project. The next open re-seeded the dead UUID and submit hit the server's
`project not found` rejection. Gate on the query's `isSuccess` so we can
tell "loading" apart from "loaded as empty", and clear both local state
and the persisted preference when the selection isn't in the resolved list.
2. ManualCreatePanel's switchToAgent dropped the picked project from the carry
payload, so flipping manual → agent silently fell back to the agent panel's
own lastProjectId — potentially routing the issue to a different project
than the one shown in manual mode. Forward project_id alongside prompt /
agent_id, and add a regression test.
Co-authored-by: multica-agent <github@multica.ai>
* test(quick-create): pass new isExpanded props in stale-project tests
Main got an expand button on AgentCreatePanel via #2320 while this branch
was open, adding `isExpanded` / `setIsExpanded` to the panel's required
props. The two new stale-project tests still passed `{ onClose }` only,
which CI's typecheck (run on the main+branch merge) caught while my
local run did not.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929)
The cursor-paginated /timeline and /comments endpoints were sized for a
problem the data shape doesn't have: prod p99 is ~30 comments per issue
and the all-time max is ~1.1k. Time-based pagination also splits reply
threads across page boundaries (orphan replies), which the frontend was
papering over with an "orphan rescue" that promoted disconnected replies
to top-level — confusing UX with no real benefit.
Replace both endpoints with a single full-issue fetch, capped server-side
at 2000 rows as a defensive safety net (never hit in practice).
Server
- /api/issues/:id/timeline now returns a flat ASC TimelineEntry[]
(matches the legacy desktop contract — older Multica.app builds keep
working because the wrapped TimelineResponse + cursors are gone, and
the raw array shape was always what they consumed).
- /api/issues/:id/comments drops limit/offset; only ?since is honoured
for the CLI agent-polling flow.
- Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest
and the timelineCursor encoding.
- Replace with ListCommentsForIssue / ListCommentsSinceForIssue /
ListActivitiesForIssue (capped by argument).
CLI
- multica issue comment list drops --limit / --offset and the X-Total-Count
reporting; --since is preserved for incremental polling.
Frontend
- Replace useInfiniteQuery with useQuery in useIssueTimeline; drop
fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount.
- Remove timeline-cache helpers (mapAllEntries / filterAllEntries /
prependToLatestPage) and the TimelinePage / TimelinePageParam types.
- WS event handlers update the single flat-array cache directly.
- Drop the orphan-reply rescue in issue-detail — every reply's parent
is now guaranteed to be in the same array.
- Strip the "show older / show newer / jump to latest" buttons and their
i18n strings.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): address review feedback on pagination removal
Three issues caught in PR #2322 review:
1. /timeline broke for stale clients between #2128 and this PR. They send
?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema;
the new flat-array response was failing schema validation and falling back
to an empty timeline. Restore the wrapped shape on those query params
(DESC entries, null cursors, has_more_*=false), keeping the flat ASC array
for bare requests. Around-mode now also fills target_index from the merged
slice so legacy clients can still scroll-to-anchor without a follow-up.
2. The agent prompts in runtime_config.go and prompt.go still told agents
that `multica issue comment list` accepts --limit/--offset and to use
`--limit 30` on truncated output. With those flags removed in this PR,
new agent runs would hit "unknown flag" or skip context. Update the
prompt copy to "returns all comments, capped at 2000; --since for
incremental polling".
3. useCreateComment's onSuccess was a bare append to the timeline cache
with no id-dedupe, so a fast comment:created WS event firing before
onSuccess produced a transient duplicate. Restore the id guard the old
prependToLatestPage helper used to provide.
Adds two new boundary tests:
- TestListTimeline_LegacyWrappedShape_OnPaginationParams
- TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): fix timeline test assertions for handler-package isolation
The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.
Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
When an issue progresses to in_review / done / cancelled, archive any
pre-existing task_failed inbox rows for that issue across all member
recipients and emit inbox:batch-archived per recipient so connected
clients self-heal. Reuses the existing archived column rather than
introducing a parallel dismissed flag; the activity log preserves the
full failure history for audit independently of the inbox surface.
Closes#2291.
Co-authored-by: multica-agent <github@multica.ai>
Mirrors the manual create panel's expand affordance so the agent panel
can grow to the same wider footprint when the user wants more room for
a long prompt or pasted screenshots. Expand state is shared across
modes via the shell, so the user's preference persists when toggling
between agent and manual.
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): skip dispatch when assignee runtime is offline (MUL-1899)
Prevents scheduled autopilots from accumulating doomed tasks against
offline / archived / unbound agents. Before this change, a paused laptop
or crashed daemon would let a 5-minute-cron autopilot pile up thousands
of queued agent_task_queue rows that no runtime would ever drain — this
is the dominant source of the 89k stuck-task backlog flagged in MUL-1899.
DispatchAutopilot now performs a pre-flight admission check on the
assignee agent's runtime status. If the runtime is not 'online' (or the
agent is archived / has no runtime bound / has no assignee), the run is
recorded as 'skipped' with a failure_reason and no task is enqueued.
Skipped runs still emit autopilot:run.done so the UI / activity feed
reflect that the trigger fired and was evaluated.
Skipped runs are deliberately NOT counted toward the failure-ratio
auto-pause: a user who closes their laptop overnight should not have
their autopilot paused. Sustained server-side failures keep their
existing pause path via the failure monitor.
Tests: added an integration test that creates an offline runtime and
asserts DispatchAutopilot records a skipped run with no task enqueued.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(scheduler): expire stale queued tasks via TTL sweeper (MUL-1899)
Companion to the dispatch-time admission gate added in this PR. The
admission gate prevents *new* tasks from being enqueued against an
offline runtime, but it does not drain the historical backlog
(~89k stuck queued rows observed at MUL-1899 baseline) and does not
help when a runtime goes offline *after* a task has already been
queued. This adds a passive TTL sweeper:
- New SQL query `ExpireStaleQueuedTasks` transitions queued tasks
older than the TTL to status='failed' with
failure_reason='queued_expired' and a clear error message.
- Sweep is capped per tick (`queuedExpireBatchSize`, default 500) via
a CTE+LIMIT so that draining a large backlog cannot monopolise the
DB on a single tick. At 30s ticks the worst case is 60k rows/hour.
- Wired into the existing 30s `runRuntimeSweeper` loop alongside
`sweepStaleTasks` and reuses `taskSvc.HandleFailedTasks` so the
expired tasks broadcast `task:failed` events, reconcile agent
status, and roll back any in-progress issues — same lifecycle as
any other failed task.
- Default TTL = 2h. Conservatively above any reasonable
"queued behind a long-running task" window (default agent timeout
is 2h, sweeper runs every 30s) so legitimate work isn't expired.
- Integration tests cover the happy path (stale → expired, fresh →
left alone, correct status/reason/error) and the per-tick batch cap.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): address review blockers from PR #2311 (MUL-1899)
GPT-Boy review of the offline-runtime + queued-TTL PR flagged four
blockers; this commit addresses them all.
1. Restore the 'skipped' autopilot_run status in the DB constraint.
Migration 043 had removed 'skipped' along with the now-defunct
concurrency_policy feature, so the new admission gate's INSERT of
status='skipped' violated `autopilot_run_status_check` and broke
`TestAutopilotDispatchSkipsWhenRuntimeOffline` in CI. New
migration 079 re-adds 'skipped' to the CHECK list. The down
migration migrates skipped → failed before re-tightening, mirror-
ing what 043 did for the original removal.
2. Make `ExpireStaleQueuedTasks` race-safe.
The CTE-then-UPDATE pattern could clobber a task that the daemon
claimed between victim selection and the outer update. Two
guards added:
- `FOR UPDATE SKIP LOCKED` in the CTE so we never wait on a
row that's currently being claimed (and never block the
claim path either).
- The outer UPDATE now re-checks `t.status = 'queued'` AND the
TTL predicate so even if a row's lock is released after a
successful claim, we cannot transition a now-dispatched/
running task to 'failed'.
3. Add a partial index for the queued-TTL sweeper.
`idx_agent_task_queue_queued_created_at` on `created_at WHERE
status = 'queued'` — keeps the 30s sweep query (status=queued
AND created_at < ... ORDER BY created_at LIMIT 500) cheap even
when historical terminal rows accumulate (~89k+ at MUL-1899
baseline). The partial predicate keeps the index tiny because
only in-flight rows live in 'queued'.
4. Fix the failure-monitor denominator.
`SelectAutopilotsExceedingFailureThreshold` had been counting
'skipped' toward total runs, which would have diluted the failure
ratio: a 100%-failing autopilot could mask itself behind a wall
of admission skips. With 'skipped' restored as a real status,
the auto-pause monitor must explicitly exclude it from BOTH
numerator and denominator — admission skips are neither a
success nor a failure.
Verified: `go test ./cmd/server/... ./internal/service/...` passes
(including TestAutopilotDispatchSkipsWhenRuntimeOffline,
TestExpireStaleQueuedTasks, TestExpireStaleQueuedTasksRespectsBatch
Limit). `go build ./... && go vet ./...` clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): split queued-task TTL index into concurrent migration
Per PR #2311 review: agent_task_queue is a hot table, so building the
new partial index with plain CREATE INDEX inside migration 079 would
hold ACCESS EXCLUSIVE on the queue and block dispatch during deploy.
The migration runner does not allow CONCURRENTLY to share a file with
other statements (documented in 068), so split the index into its own
single-statement file 080 — matching the existing pattern in 035 /
067 / 074 / 075 / 078. Migration 079 keeps the autopilot_run
constraint change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): treat upstream API 400 invalid_request_error as poisoned session
A markdown-linked image in an issue description that the agent downloads as
a tiny CDN auth-error file and Read's as a PNG poisons the conversation:
the LLM API rejects the bad image with 400 invalid_request_error, the
session_id is pinned mid-flight, and every follow-up task on the issue
(comment-trigger, auto-retry) resumes the same poisoned conversation and
hits the same 400 — the issue can no longer be executed even after the
description is cleaned up.
Mirror the existing fallback-output classifier on the error side: detect
"API Error: ... 400 ... invalid_request_error" in the agent error string,
persist failure_reason='api_invalid_request', and add it to the
GetLastTaskSession exclusion list so the next task starts a fresh
session that re-reads the (now-clean) description.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): unblock issues already poisoned by API 400 invalid_request_error
The forward-only classifier from the previous commit only tags new failures.
Issues like MUL-1918 already have multiple failed-task rows whose
failure_reason is the pre-fix default 'agent_error', and GetLastTaskSession
falls back to those legacy rows on the next claim — so deploying the
classifier alone leaves existing poisoned issues stuck (GPT-Boy review
on PR #2314).
Two complementary changes:
- Migration 079 backfills failure_reason='api_invalid_request' on every
pre-existing 'agent_error' row whose error text matches the canonical
Anthropic 400 invalid_request_error shape. Keeps observability
consistent (multica issue runs / UI now report the right reason).
- GetLastTaskSession adds a defensive ILIKE clause on error text. Closes
the deploy-window gap where the old binary could write a new
'agent_error' row between the migration running and the new code
taking over, and protects against future error-format variants the
daemon classifier might miss.
Plus regression tests covering the legacy + new coexistence case GPT-Boy
flagged, and a guard rail asserting benign 'agent_error' failures
(timeouts, tool errors) still resume their session.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The priority badge in the issue/project priority picker dropdown used a
parallel `bg-priority` orange color family (with opacity gradient for level
intensity), while the standalone PriorityIcon outside the dropdown used
semantic tokens — destructive for Urgent, warning for High/Medium, info for
Low. The two languages produced an inconsistency users noticed most clearly
on Low: blue in the list, orange in the picker.
Switch the dropdown badges to the same semantic tokens as the icon, and
remove the now-unused `--priority` / `--color-priority` design token from
both `packages/ui/styles/tokens.css` and `apps/web/app/custom.css`.
Closesmultica-ai/multica#2289
Co-authored-by: multica-agent <github@multica.ai>
* feat(execution-log): add one-click retry for failed/cancelled tasks (MUL-1922)
Adds a Retry icon button to past-run rows in the issue execution log so
users can re-enqueue failed or cancelled tasks without leaving the page.
The button calls POST /api/issues/{id}/rerun (already exposed by the CLI
issue rerun command) which cancels any prior task on the assignee and
spawns a fresh task with a new agent session.
Co-authored-by: multica-agent <github@multica.ai>
* fix(execution-log): reset retry button state on rerun success
The previous handler only reset `retrying` on error, but the past row
stays mounted (its `task.id` is unchanged) after a successful rerun, so
the Retry button hovered into a permanent spinner. Move the reset into
a finally block so both paths clear the loading state.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The slug_reserved error introduced in #2228 was hardcoded English, and
the older inline format/conflict errors in step-workspace.tsx had the
same problem. Move all of them to the workspace + onboarding locale
namespaces (en + zh-Hans) and drop the now-unused string constants
from slug.ts.
Co-authored-by: multica-agent <github@multica.ai>
PR #2281 added table-format support to parsePiModels but kept the
unconditional `strings.Replace(":", "/", 1)`, which would silently
rewrite a `:` inside a model name read from column 1 of the table
output (e.g. `claude-sonnet-4-6:exp` would become
`claude-sonnet-4-6/exp`). Move the replace into the legacy
`provider:model` branch so only the colon-as-separator case is
normalized, and restore a short doc comment describing the dual-
format contract. Test extended with a colon-bearing table row.
Co-authored-by: multica-agent <github@multica.ai>
Agent text rows in the run-records dialog only got a chevron when the
message had a newline; a long single-line reply was rendered with
truncate and the trailing content was unreachable. Other event types
(tool_use, tool_result, thinking, error) are expandable on any
non-empty content — bring text in line.
Also lead the collapsed summary with the first non-empty line instead
of the last, so multi-paragraph replies preview the lede rather than
the closing remark and the row stays stable while messages stream.
Co-authored-by: multica-agent <github@multica.ai>
The pi CLI changed its --list-models output from a single-field
'provider:model' format to a multi-column table with separate
'provider' and 'model' columns. The existing parser only looked
at the first whitespace-delimited field (the provider name) and
skipped lines without ':' or '/' — discarding every model entry.
Update parsePiModels to handle both formats:
- New table format: combine fields[0] (provider) + fields[1] (model)
- Legacy format: single field with ':' or '/' separator
Add regression test for the table format using real pi output.
The issue-detail "agent live" banner only showed dispatched/running tasks.
A task that was queued — runtime offline, busy on a prior task, or held
behind a coalesced sibling — left the issue silent until claim, which
reads as "the trigger never landed".
Include 'queued' in `ListActiveTasksByIssue`, then branch the renderer:
queued banners use a non-spinning Clock, "{name} 排队中 / is queued"
copy, "queued for Ns" elapsed anchored on `created_at`, and hide the
transcript button (no execution log yet). Cancel still works because
`CancelAgentTask` already accepts queued.
Client-side re-sort by lifecycle (running → dispatched → queued) so the
sticky slot stays on the most-active task even when a queued sibling
was created more recently.
Co-authored-by: multica-agent <github@multica.ai>
DropdownMenuContent had `w-(--anchor-width)` which locks the popup
width to the trigger. With icon-sm kebab triggers (~32px) the popup
was clamped by `min-w-32` to 128px, and longer items like
"Unresolve thread" / "标记为已解决" wrapped onto two lines.
Anchor-width matching is the right behavior for Select / Combobox
(both keep that class), but a generic kebab menu should size to its
own content. Drop the `w-(--anchor-width)` and keep `min-w-32` as the
floor.
Co-authored-by: multica-agent <github@multica.ai>
When the inbox split-pane is open and the user clicks a comment-notification
for issue X, then a non-comment notification for the SAME issue (status,
assignment, sub-issue), <IssueDetail> stays mounted (keyed on issueId in
inbox-page.tsx so composer drafts and scroll position survive). The hook's
internal `around` state has to react to the prop transitioning back to falsy
— otherwise the around-mode cache is re-served on every subsequent click and
entries outside the original window appear "lost" until a hard refresh.
The truthy guard on the effect skipped the falsy branch:
useEffect(() => {
if (options.around) setAround(options.around); // ← skipped on null
}, [options.around]);
Replace it with an unconditional sync. useState's initialiser already covers
the mount-time read; the effect now covers all subsequent prop transitions
including → null.
Adds a regression test that asserts the hook re-keys useInfiniteQuery on the
truthy → undefined transition.
Co-authored-by: Sara <sara@sara.local>
* docs(cli): clarify `issue rerun` semantics
The CLI table described `multica issue rerun <id>` as "Rerun the most
recent agent task", which led users to expect it would re-run whichever
agent ran last. The actual behavior is to enqueue a fresh task for the
issue's **current** agent assignee, regardless of who ran most
recently — see `TaskService.RerunIssue` in
`server/internal/service/task.go`.
Also fix a stale claim in `tasks.mdx`: the "Manual rerun" section
described session inheritance as "Yes", but commit b1345685 made manual
rerun pass `force_fresh_session=true` precisely to avoid replaying a
poisoned session. Only **automatic retry** still inherits the session.
Updates EN + ZH mirrors of `cli.mdx` and `tasks.mdx`.
Co-authored-by: multica-agent <github@multica.ai>
* docs(tasks): tighten rerun trigger surface; clean stale Go comments
Apply review feedback on PR #2304:
- `tasks.mdx` / `tasks.zh.mdx`: rerun is triggered via CLI or the
`/api/issues/{id}/rerun` endpoint, not "UI or CLI" — there's no rerun
affordance in web/desktop today.
- `tasks.mdx` / `tasks.zh.mdx`: comparison table — manual rerun applies
to "Issues with an agent assignee", not "All sources". The handler
rejects with `issue is not assigned to an agent` for anything else,
and there's no rerun path for chat or autopilot tasks.
- `task_lifecycle.go`: `RerunIssue` doc comment claimed the new task
"carries the most recent session_id/work_dir so the agent can resume".
That has been false since b1345685 — rewrite to reflect the actual
`force_fresh_session=true` contract.
- `agent.sql` (regenerated `agent.sql.go`): `GetLastTaskSession` doc
said it serves "auto-retry / manual rerun"; manual rerun is now
routed around it via `force_fresh_session=true`. Note both the
auto-retry path it does serve and the rerun escape hatch.
No logic change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The CLI now accepts routable short IDs across issue/autopilot/project/label/task
commands (shipped 2026-05-08), but the docs still only show <id> placeholders,
so new users wonder whether `multica issue list` -> `multica issue get MUL-123`
is supposed to work. Add a callout to the cheat sheet pages and a concrete
`MUL-123` example to the reference page so the supported flow is discoverable
without reading --help for every command.
Co-authored-by: multica-agent <github@multica.ai>
The `runtime ping` command was removed in #1554 along with the Test
Connection feature; runtime reachability is now detected via daemon
heartbeat. The English and Chinese CLI reference pages still listed the
removed command, which sent users to a non-existent subcommand.
Closesmultica-ai/multica#2276
Co-authored-by: multica-agent <github@multica.ai>
* feat(comments): resolve threads with collapsible bar (MUL-1895)
Adds a Linear-style resolve action on comment thread roots. Resolved
threads collapse to a single "N resolved comments from X" bar in the
activity feed; clicking expands the thread inline (per-session, not
persisted). Replying inside a resolved thread auto-unresolves it.
Backend
- migration 069: resolved_at, resolved_by_type, resolved_by_id on comment
- sqlc ResolveComment / UnresolveComment queries (idempotent via COALESCE)
- POST/DELETE /api/comments/{id}/resolve handlers, root-only validation
- CreateComment auto-clears resolved_at when a reply lands in a resolved
thread, publishing comment:unresolved
- comment:resolved / comment:unresolved events; CommentResponse and
TimelineEntry both surface the new fields
Frontend
- Comment + TimelineEntry types extended; payloads typed; WS sync wired
- useResolveComment optimistic mutation with rollback
- ResolvedThreadBar component for the collapsed view
- Resolve / Unresolve menu items on root comments; Collapse strip on the
expanded resolved card
- en + zh-Hans locale strings
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): cover agent reply path, expand-state hygiene, nested counts (MUL-1895)
Addresses three review issues from Emacs on PR #2300:
1. TaskService.createAgentComment bypasses Handler.CreateComment, so the
auto-unresolve wired into the handler did not fire when an agent replied
in a resolved thread (task / mention / on_comment paths). Extracted the
logic to TaskService.AutoUnresolveThreadOnReply so both reply paths share
it; rewired Handler.CreateComment to call the new method.
2. Resolving an already-expanded thread no longer collapses it back to the
bar because expandedResolved still contained the id. Added
clearResolvedExpand + handleResolveToggle wrapper so resolve / unresolve
always wipe the session expand entry.
3. ResolvedThreadBar received only direct children, while CommentCard's
expanded view recurses through descendants. Extracted the recursive
walk into thread-utils.collectThreadReplies and called from both —
counts and author lists now match.
Co-authored-by: multica-agent <github@multica.ai>
* test(comments): mock useResolveComment + add zh-Hans plural key
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): derive appUrl from apiUrl in dev so copy-link follows the connected env
Local desktop dev was hardcoding appUrl to http://localhost:3000, so the
"Copy issue link" output pointed at localhost even when the renderer was
connected to a remote (e.g. test) backend — the resulting URL only worked
on the developer's machine.
- runtime-config dev path now mirrors the production loader: when
VITE_APP_URL is unset, derive appUrl from apiUrl (host-only). The
localhost api host is special-cased to keep the local web port (3000),
while a remote api host (api.test.x) yields a remote appUrl.
- Web navigation adapter now implements getShareableUrl directly with
window.location.origin instead of leaving it undefined.
- NavigationAdapter.getShareableUrl is now required; copyLink callers
drop the window.location fallback branch and call it unconditionally.
- Add the missing getShareableUrl mock in issue-detail.test.tsx.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): strip leading api. label when deriving appUrl
Address Emacs' code review on PR #2298. The previous derivation kept the
api hostname unchanged, so VITE_API_URL=https://api.test.multica.ai
produced appUrl=https://api.test.multica.ai — not the env's actual web
URL. Multica's convention exposes the api at api.<web-host>; strip that
leading label (when the host has at least 3 labels, to avoid mangling
short hosts like api.local) so a single api configuration produces the
correct shareable web origin.
- api.multica.ai → multica.ai
- api.test.multica.ai → test.multica.ai
- api-staging.x.com → unchanged (no leading "api." label)
- congvc-x99.ts.net → unchanged
Update both the dev and production tests; also fix the existing
runtime-config-loader test that asserted the unstripped value.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Reserved workspace slugs lived in two parallel files (`workspace_reserved_slugs.go`
and `packages/core/paths/reserved-slugs.ts`) with no parity check. Adding or
renaming a global route on one side without the other would slip through CI
and surface only when a real user hit the collision.
Collapse the two lists into one source: `server/internal/handler/reserved_slugs.json`.
Go embeds the JSON via `//go:embed` and parses it at package init; the TS file
is regenerated by `scripts/generate-reserved-slugs.mjs` (run via
`pnpm generate:reserved-slugs`). CI re-runs the generator and `git diff
--exit-code`s the TS output, so a stale TS file cannot land. The slug set is
unchanged (87 entries, byte-equivalent slug literals).
Update CLAUDE.md to describe the new "edit JSON, run generator" workflow.
Co-authored-by: multica-agent <github@multica.ai>
Two follow-up nits from PR #2211 review:
- Rename the package-local `repoCache` interface to `repoCacheBackend`
so the field declaration `repoCache repoCacheBackend` no longer shadows
its own type name.
- Bump the `/health`-must-respond timeout in
`TestHealthHandlerRespondsWhileTaskRepoLookupWaits` from 200ms to 1s.
The regression case blocks indefinitely on the old code, so a 1s
upper bound still fail-fast detects it while leaving headroom for
loaded CI runners.
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): add disk-usage CLI to surface per-task / per-workspace footprint
Adds `multica daemon disk-usage [--by-workspace] [--by-task] [--top N]
[--output json]`, walking the workspaces root to report task and workspace
disk consumption without requiring a running daemon. Sizing reuses the GC
artifact patternSet (basename-only) so the reported "artifact" footprint
matches what `cleanTaskArtifacts` would actually reclaim, and the walk
honors the same safety contract: never enters .git, never follows symlinks,
counts only regular files.
Refactors WorkspacesRoot resolution into an exported `ResolveWorkspacesRoot`
so the read-only CLI picks the same root the running daemon would have.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): distinguish displayed totals from scan totals; add workspace artifact ratio
- Track scan-wide TotalTaskCount / TotalWorkspaceCount on the report so
`--top N` no longer leaves the table footer claiming the truncated row
count is the full count. The CLI now prints a "Showing top N of M …
Displayed: X. Scan total: Y" line whenever truncation happens, and keeps
the bare "Total: …" footer for the un-truncated case.
- Add ArtifactRatio (0..1) on WorkspaceDiskUsage and TotalArtifactRatio on
the report. The workspace table renders an `ARTIFACT %` column. ratio()
guards size=0 so empty workspaces report 0% instead of NaN%.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Filters available skills by name + description (case-insensitive) as the
user types. Auto-focuses on open and clears the query on close. Shows a
distinct "no match" empty state vs. the existing "all assigned" one.
Closes#2266
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): extend GC to chat / autopilot / quick-create tasks
Before this change the daemon's GC was strictly issue-centric: only tasks
with a non-empty issue_id ever wrote .gc_meta.json, and shouldCleanTaskDir
called only the issue gc-check endpoint. Chat / autopilot run / quick-create
tasks fell through to the GCOrphanTTL mtime path, which mis-killed active
chat sessions while leaving deleted ones around far longer than necessary.
Schema:
- GCMeta gains a Kind discriminator and per-kind ID fields
(ChatSessionID / AutopilotRunID / TaskID). WriteGCMeta now takes a
GCMeta struct so the call site classifies the task explicitly.
- ReadGCMeta defaults empty Kind to GCKindIssue, so legacy on-disk meta
files keep flowing through the issue path with no migration required.
Server endpoints (siblings of /api/daemon/issues/{id}/gc-check, all behind
requireDaemonWorkspaceAccess for the same anti-enumeration shape):
- GET /api/daemon/chat-sessions/{id}/gc-check -> {status, updated_at}
- GET /api/daemon/autopilot-runs/{id}/gc-check -> {status, completed_at}
- GET /api/daemon/tasks/{id}/gc-check -> {status, completed_at}
shouldCleanTaskDir dispatches on Kind:
- chat: active is hard-skipped (no mtime fallback) so idle sessions are
never reclaimed; archived + GCTTL cleans; 404 falls back to mtime to
stay safe for cross-workspace tokens.
- autopilot_run: terminal (completed/failed/skipped/issue_created) +
GCTTL cleans; running/pending skips. Uses run.completed_at as the TTL
anchor since autopilot_run has no updated_at column.
- quick_create: terminal task status cleans immediately (workdir is not
reused by the linked issue task, which has its own envRoot); running
skips.
Also drops the "skipping .gc_meta.json: issue_id is empty" warn — with
the new kind dispatch, chat/autopilot/quick-create tasks now write a
proper meta file instead of triggering this log.
Refs: GC follow-up to PR #2077 (symptom fix) and #2115 (chat hard delete).
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): chat gc-check 404 cleans immediately, no mtime gate
PR review caught that the chat 404 path was routing through
orphanByMTime, which deferred reclamation to GCOrphanTTL (72h) when
acceptance #3 calls for cleanup within one GC cycle (≤ 1h) after the
user hard-deletes a session.
Every chat_session_id we ever ask about was written by this same daemon
under its current token, so the cross-workspace probe defense the issue
path needs doesn't apply here. Drop the gate and clean on 404 directly.
Test updates:
- TestShouldCleanTaskDir_KindDispatch/chat_404 flips the locked
expectation from gcActionSkip to gcActionClean.
- Adds TestShouldCleanTaskDir_ChatHardDeletedFreshMtime: GCOrphanTTL
set to a year so any mtime-based path is unmistakably out, and the
fresh-mtime workdir still cleans on the chat-404 fast path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Two related changes for the same UX problem (#1857 follow-up).
1. Orphan-reply rescue. The grouping in issue-detail.tsx put replies under
their parent's CommentCard, looking them up via repliesByParent.get(parentId).
When a reply's parent wasn't in the loaded timeline — pagination boundary,
merge truncation, future backend bug — the entire reply subtree dropped
off the screen, since the orphan replies sat in the map with no
CommentCard around to render them. MUL-1847 hit this on the OLD backend:
1 root + 29 replies, the root was the oldest entry and the merge dropped
it, so all 29 replies vanished from the UI even though the API returned
them.
The fix: a reply whose parent_id points to a comment NOT in the loaded
timeline is promoted to top-level. It still loses its visual indentation
under the missing parent, but it stops disappearing.
2. Page size 50. With activities now decoupled from the comment budget
(#2253) and the off-by-one fixed (#2259), 50 fits the typical issue
without any "Show older" interaction. Cost is bounded — SQL fetches
limit+1 = 51 comments + 50 activities through the keyset index from
migration 068; response body grows ~70% over 30 but stays well under
the legacy compat path's 200-row cap. UI renders 100 entries
comfortably; CommentCards memoize.
Frontend default in `client.ts` (`limit = 50`) matches the new backend
default (`timelineDefaultLimit = 50`) so pages walk consistently.
Test: render-level case in `issue-detail.test.tsx` mocks a timeline page
containing only an orphaned reply (parent_id refers to a missing id) and
asserts the reply text appears.
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): aggregate task_usage into daily rollup table to cut DB load
ListRuntimeUsage previously did a SUM(...) GROUP BY DATE(created_at), provider,
model over the raw task_usage stream once per runtime row on the runtimes
list and once per detail page load, scaling O(events) per call. This is the
hot read path responsible for sustained load on Postgres.
Switch the read path to a materialized daily rollup table maintained by a
pg_cron job:
- 072_task_usage_daily_rollup: schema for task_usage_daily +
task_usage_rollup_state, plus rollup_task_usage_daily_window(p_from, p_to)
(window primitive used by both cron and offline backfill, idempotent via
ON CONFLICT DO UPDATE adding deltas) and rollup_task_usage_daily() (cron
entry point — pg_try_advisory_lock(4242) for serialization, watermark
advancement, 5-minute safety lag for late-visible inserts). Also adds
idx_task_usage_created_at to help the two lazy endpoints
(ListRuntimeUsageByAgent / GetRuntimeUsageByHour) that still hit the
raw table.
- 073_task_usage_daily_pgcron: CREATE EXTENSION IF NOT EXISTS pg_cron in a
DO/EXCEPTION block (mirrors the migration 032 pg_bigm pattern so envs
without shared_preload_libraries=pg_cron skip gracefully) and schedules
rollup_task_usage_daily() every 5 minutes when the extension is present.
- queries/runtime_usage.sql ListRuntimeUsage rewritten to read from
task_usage_daily; sqlc regenerated. Other usage queries unchanged.
- cmd/backfill_task_usage_daily: one-shot Go command that walks
task_usage in monthly slices through rollup_task_usage_daily_window,
then stamps the watermark to now()-5m so the cron resumes cleanly.
Run once after migrations have applied, before relying on the rollup.
- runtime_test.go: TestGetRuntimeUsage_BucketsByUsageTime now invokes
rollup_task_usage_daily_window after fixture inserts so the handler
sees the rolled-up rows. Synthetic daily rows cleaned up after each
test.
- runtime_rollup_test.go: new tests covering aggregation correctness,
idempotency contract of ON CONFLICT DO UPDATE, and the watermark
advancing exactly to now()-5m via the cron entry point.
Deployment order: apply migrations → run backfill_task_usage_daily once
→ pg_cron picks up subsequent windows automatically. Today bucket may be
up to ~10 minutes stale (5 min cron + 5 min lag) by design.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): make task_usage_daily rollup safe to overlap, replay, and correct
Addresses 4 review blockers on the original PR:
1. Cron/backfill double-count race: the rollup function is now idempotent.
Window calls find DIRTY KEYS via task_usage.updated_at, then RECOMPUTE
each bucket from ground truth and REPLACE the daily row (no more
additive ON CONFLICT). Cron and backfill can now overlap safely.
2. Silent pg_cron absence: the read path is gated behind a new
USAGE_DAILY_ROLLUP_ENABLED feature flag (default off). The raw
task_usage scan is preserved as the fallback. Operators flip the
flag per-environment after backfill + cron are confirmed healthy
(task_usage_rollup_lag_seconds() helper added for monitoring).
3. UpsertTaskUsage corrections invisible to rollup: added
task_usage.updated_at column (default now(), backfilled from
created_at), and bumped it on conflict. Corrections now mark the
bucket dirty and the next window call recomputes it correctly.
4. CREATE INDEX blocking writes on hot table: split into separate
single-statement migrations using CREATE INDEX CONCURRENTLY
(074, 075), matching the 035/067 pattern.
Also: cron.schedule() removed from migrations entirely. Migration 076
only enables the extension (gracefully on unsupported envs); the actual
schedule is a documented operator runbook step that runs AFTER backfill.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): trigger-driven invalidation + online-safe migration for task_usage_daily
Round-2 review feedback on PR #2256:
1. Add explicit dirty-bucket queue (task_usage_daily_dirty) populated by
triggers on agent_task_queue (UPDATE OF runtime_id, DELETE) and
task_usage (DELETE). The rollup window function drains both this queue
and the updated_at-based discovery, so runtime reassignment and
issue-cascade deletes no longer leave the rollup divergent from the
raw query.
Triggers join via agent (not issue) to look up workspace_id, because
when the cascade comes from issue, the issue row is already gone by
the time atq's BEFORE DELETE fires; agent stays alive.
2. Make migration 072 online-safe: only ADD COLUMN updated_at TIMESTAMPTZ
(nullable, no default → metadata-only ALTER, no row rewrite) and a
separate ALTER for SET DEFAULT now() (also metadata-only). No bulk
UPDATE on the hot task_usage table. The rollup window function's
dirty_keys CTE handles legacy NULL rows via an OR branch, supported
by partial index idx_task_usage_created_at_legacy.
3. Refresh stale documentation in cmd/backfill_task_usage_daily/main.go
header to describe the current recompute/replace semantics, idempotent
re-runnability, and the actual migration numbering (072..077).
Tests:
- TestRollupTaskUsageDaily_InvalidationOnReassign: verifies usage moves
between runtime buckets after ReassignTasksToRuntime-style update.
- TestRollupTaskUsageDaily_InvalidationOnIssueDelete: verifies daily
bucket is cleared after issue delete cascades through atq → task_usage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): close dirty-queue race + move legacy partial index to its own concurrent migration
Round-3 review feedback on PR #2256:
1. Blocker: dirty-queue invalidations could be silently lost under
concurrency. ON CONFLICT DO NOTHING let a late trigger see the row
already enqueued, no-op, and then the rollup drain (WHERE
enqueued_at < p_to) would delete the original row — losing the
late invalidation. Switched all three trigger enqueue paths to
ON CONFLICT DO UPDATE SET enqueued_at = GREATEST(existing,
EXCLUDED.enqueued_at), so any invalidation arriving during a
rollup tick keeps enqueued_at > p_to (p_to = now() - 5min) and
survives the post-tick drain.
2. High: idx_task_usage_created_at_legacy (partial index on hot
task_usage table) was being created in the regular 077 migration
without CONCURRENTLY. Moved to new migration 078 with
CREATE INDEX CONCURRENTLY, matching the pattern of 074/075.
077's down migration leaves the index alone (it is owned by 078).
3. Minor: gofmt -w on runtime_rollup_test.go and
backfill_task_usage_daily/main.go (tabs were lost in the original
heredoc append). PR description rewritten to describe the current
recompute/replace + dirty queue + feature flag design and the
072..078 migration ordering.
Tests still green: TestRollupTaskUsageDaily_* (including both new
invalidation regressions), TestGetRuntimeUsage_*, TestWorkspaceUsage_*.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): unify workspace_id source via agent in rollup window function
Round-4 review feedback (J) on PR #2256:
M1 (must-fix): The dirty queue triggers resolved workspace_id via
`agent.workspace_id`, but the window function's `dirty_from_updates`
discovery and `recomputed` recompute join used `issue.workspace_id`.
There is no schema-level FK guaranteeing
`agent.workspace_id == issue.workspace_id`. Any divergence (future
cross-workspace task scenarios, data repairs, migration bugs) would
cause:
- dirty queue rows with workspace_id from agent
- recompute join filtering by workspace_id from issue
- 0 matches in recompute → bucket erroneously hits the
deleted_empty branch and the daily row is silently dropped
- dirty_from_updates path attributing usage to the wrong workspace
Replaced both CTEs to JOIN agent (not issue) so trigger / discovery /
recompute share one workspace_id source. Comment in 077 explains the
constraint.
N1: Refreshed two stale references in
cmd/backfill_task_usage_daily/main.go (header now says "072..078";
stampWatermark warning now mentions migration 073, where the rollup
state table is actually introduced).
Test: New TestRollupTaskUsageDaily_WorkspaceMismatch constructs an
atq with agent.workspace_id != issue.workspace_id, asserts the bucket
lands under agent's workspace (not issue's), and re-asserts after a
runtime reassign in the foreign workspace. Acts as a canary if the
schema invariant changes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Pre-fix the gate was `len(comments) >= limit`, which fired even when the
issue had EXACTLY <limit> comments. The "Show older" affordance appeared,
the user clicked, the next page fetched zero rows. User flagged it on
MUL-1857 — "this issue happens to have 30 comments; the button shouldn't
appear in that case."
The fix is the standard over-fetch probe: ask the SQL for limit+1 rows; if
it returned more than limit, drop the extra and report hasMore=true.
Otherwise hasMore=false.
- New helper `commentOverflow(rows, limit) -> ([]db.Comment, bool)` replaces
the count-based `hasMoreCommentsBeyond`. Works for both DESC (latest /
before) and ASC (after / around-newer) since both want "keep first
<limit>".
- All four mode handlers (latest, before, after, around) now ask for
limit+1 comments and route through the helper.
- Activities still cap at <limit> with no overflow probe — they don't gate
pagination (#1857), so the boundary doesn't matter for them.
Tests:
- TestCommentOverflow pins the truth table with the boundary case
("exactly limit comments" → hasMore=false).
- TestListTimeline_ExactlyLimitCommentsHidesShowOlder is the DB-backed
regression: 30 comments, limit=30, asserts has_more_before=false and
next_cursor=nil.
Co-authored-by: multica-agent <github@multica.ai>
The pre-fix top "Show older" was a bare <button> sandwiched between two
horizontal divider lines, styled `text-xs text-muted-foreground`. Visually
it read as a divider, not an action — users on issues with hidden older
entries thought the comments had vanished and didn't notice the affordance.
Convert all three timeline pagination affordances to shadcn Button:
- Top: outline button with ChevronUp icon, "Show older"
- Bottom (in around-mode pages): outline button with ChevronDown icon,
"Show newer"; default-variant button with ArrowDownToLine icon,
"Jump to latest" (or "Jump to latest · N new")
No behavior change — same fetchOlder / fetchNewer / jumpToLatest hooks,
same i18n keys. Just the visual treatment.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): exclude activities from comment page budget
The /timeline endpoint paginated comments + activities through one shared
50-row budget, so an issue with a chatty agent (status flips, task_completed
markers, assignee toggles per run) could trigger "show older" with as few as
10-20 actual comments — users opened the page and thought their discussion
had vanished.
- Comment limit drops from 50 to 30 (the visible page size users wanted).
- has_more_before / has_more_after gate on comments alone via the new
hasMoreCommentsBeyond helper. Activity rows still ride along at the same
per-call SQL cap but no longer push real comments off-page.
- Merge functions stop truncating at the page limit; both pools are
individually bounded by SQL, so dropping rows here only re-introduced the
bug. The legacy (pre-cursor) path applies its 200-row cap inline.
- Test rewrite: TestHasMoreBeyond → TestHasMoreCommentsBeyond, replaced the
#2192 merge-truncation regression with a #1857 "dense activity does not
hide comments" test that pins the new contract directly.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): per-pool keyset cursor for comments and activities
Pre-fix, next_cursor / prev_cursor anchored on the merged page boundary
(oldest / newest entry overall). When activity rows were older than every
fetched comment — common on issues created with a status change before the
first comment — the latest page emitted a cursor pointing at that activity,
and the next "show older" call sent that timestamp into ListCommentsBefore,
skipping every unreturned comment in between. GPT-Boy flagged this on
PR #2253 with the 80-comment / 30-activity scenario where 50 comments
became permanently unreachable.
The fix splits the cursor into independent comment and activity positions:
- timelineCursor carries (CommentT, CommentID, ActivityT, ActivityID).
encode/decode signatures changed accordingly.
- New cursorPos type and four bounds helpers (commentBoundsDesc / Asc,
activityBoundsDesc / Asc) extract per-pool oldest/newest from fetched
rows, with a carry fallback so empty pools advance past the input cursor
instead of resetting.
- All four mode handlers (latest, before, after, around) now derive cursors
from each pool's own bounds. Removed the entryTimestamp / entryID helpers
that re-parsed the merged entry slice.
Tests:
- TestTimelineCursor_RoundTrip pins the encode/decode contract for the new
dual-pool format (and rejects garbage input).
- TestListTimeline_PerPoolCursorWalksAllComments reproduces GPT-Boy's exact
scenario (30 activities older than 80 comments, limit=30) and asserts
every comment is reachable through repeated `before=<cursor>` walks.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Parent and child issues already render their identifier on the issue
detail page; only the issue you're viewing is missing one. Add it to
the breadcrumb between the parent identifier (when present) and the
title, matching the existing parent identifier styling.
Refs multica-ai/multica#2243
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes
After brew upgrade on Linux, os.Executable() resolves /proc/self/exe to
the Cellar path (e.g. .../Cellar/multica/0.2.9/bin/multica), which
brew cleanup deletes. The previous IsBrewInstall() short-circuit skipped
EvalSymlinks to 'preserve' the symlink, but on Linux there was nothing
to preserve - the path was already resolved.
Use cli.GetBrewPrefix() to resolve the stable symlink path
<brewPrefix>/bin/multica for brew installs. Fall back to
EvalSymlinks(os.Executable()) with a warning log when GetBrewPrefix()
returns empty (brew binary missing from PATH).
Introduce package-level function vars (isBrewInstall, getBrewPrefix) so
the daemon test can override them without modifying the cli package.
Closes#1624
* fix(daemon): harden brew-prefix fallback and document the WHY
When `brew --prefix` is unavailable but the binary is under a known Cellar
root, recover the prefix from cli.MatchKnownBrewPrefix and target
<prefix>/bin/multica instead of falling back to the resolved Cellar path
(which brew cleanup just deleted).
- Extract knownBrewPrefixes + MatchKnownBrewPrefix in cli/update.go and
reuse from IsBrewInstall to keep one source of truth for the install-root
list.
- Add a WHY comment above the brew branch in triggerRestart explaining the
/proc/self/exe -> Cellar -> deleted-by-brew-cleanup chain.
- Cover both fallback paths (matched / unmatched) in daemon_test.go.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(cli): add --content-file / --description-file for non-ASCII on Windows
Windows PowerShell 5.1 (the Win11 default) and cmd.exe re-encode HEREDOC
content through the active console codepage before piping it to a child
process. Characters the codepage cannot represent are silently replaced
with `?`, so agents on Chinese Win11 hosts emitting `--content-stdin` /
`--description-stdin` HEREDOCs land all of their Chinese as `?` in the
issue body and comments. The daemon log shows the original Chinese
correctly because slog writes to a file directly, so the regression
hides until the user opens the issue page.
Add a `--content-file <path>` / `--description-file <path>` source to
`resolveTextFlag`: the CLI reads the file straight off disk, preserves
UTF-8 bytes verbatim, and skips the shell entirely. The runtime config
injected into AGENTS.md / CLAUDE.md now surfaces this as the canonical
Windows fallback when the daemon host runs on Windows; non-Windows hosts
keep the existing stdin/HEREDOC guidance untouched.
Closes#2198, #2236.
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): route every Windows-host stdin directive at --content-file
GPT-Boy on PR #2247 caught that the previous patch only inserted a Windows
fallback into the Available Commands section. Two later prompt surfaces
still hard-coded `--content-stdin` and overrode it for the agent:
- The Codex-specific paragraph in `buildMetaSkillContent`, which always
said "always use `--content-stdin` with a HEREDOC".
- `BuildCommentReplyInstructions`, which is re-emitted on every turn for
comment-triggered tasks (both via the AGENTS.md/CLAUDE.md workflow and
the daemon's per-turn prompt) and mandated the same HEREDOC pipe.
On Windows hosts we now branch both surfaces to a file-based template:
the agent writes the body to a UTF-8 file with its file-write tool and
posts via `--content-file <path>`. Non-Windows hosts keep the existing
stdin/HEREDOC guidance untouched.
Tests:
- `TestBuildCommentReplyInstructionsWindowsUsesContentFile` pins the
Windows / non-Windows reply-instruction text directly.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` asserts that
the end-to-end CLAUDE.md / AGENTS.md surface for a comment-triggered
Windows task has no remaining `--content-stdin` directive that could
override the Windows fallback (covers Claude + Codex providers).
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): make Windows comment block file-first, pin tests by GOOS
GPT-Boy's second review on PR #2247 flagged two follow-up blockers:
1. The Windows comment/description block in `buildMetaSkillContent` was
"stdin first, file caveat appended" — agents on Windows still saw
"Agent-authored comments should always pipe content via stdin" /
"MUST pipe via stdin" / `--description-stdin` directives before
reaching the Windows fallback, so the contradicting instruction was
live in the same prompt. Rewrite the entire Available Commands
bullet for Windows hosts as file-first: the headline line names
`--content-file`, the bulleted rules name `--content-file` /
`--description-file`, and stdin only appears in anti-prescriptive
"do NOT pipe via …" prose.
2. The existing non-Windows tests (TestBuildCommentReplyInstructions
IncludesTriggerID, TestInjectRuntimeConfigDirectsMultiLineWritesToStdin,
TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments,
TestInjectRuntimeConfigCommentTriggerUsesHelper) all depended on
`runtimeGOOS` defaulting to non-Windows; they would silently fail on
a Windows test runner. Pin them to `runtimeGOOS = "linux"` via
save+restore and drop t.Parallel so they don't race with the
GOOS-mutating Windows tests.
Test additions:
- TestInjectRuntimeConfigWindowsRecommendsContentFile now asserts the
Windows AGENTS.md does NOT contain prescriptive stdin phrasings
(`MUST pipe via stdin`, `use --description-stdin and pipe a HEREDOC`,
`<<'COMMENT'`, `Agent-authored comments should always pipe content via
stdin`, `always use --content-stdin`) on top of the file-first
positive assertions. The ban list pins prescriptive substrings, not
bare flag names, so anti-prescriptive prose like "do NOT pipe via
--content-stdin" doesn't trip the ban.
- TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin gets the same
expanded ban list across the Available Commands, Codex paragraph,
and per-turn reply template surfaces.
- The non-Windows side of TestInjectRuntimeConfigWindowsRecommendsContentFile
pins that the Linux stdin/HEREDOC contract is still in place, so a
future refactor can't accidentally move every host to file-first.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Both `apps/desktop/build/icon.ico` (Windows installer + Multica.exe) and
`apps/desktop/build/icon.png` (Linux deb/rpm/AppImage) were the default
electron-vite scaffold "atom" placeholder. They were never updated when
the macOS `icon.icns` was switched to the Multica asterisk in #1074, and
have shipped as-is in every v0.2.x release including v0.2.26 — closes
GitHub #2195.
Source: 1024×1024 PNG extracted from the existing build/icon.icns
(icon_512x512@2x), so all three platforms now share the same artwork.
- icon.ico: BMP frames at 16/24/32/48/64/128 + PNG-compressed 256×256.
Matches electron-builder's "≥256×256" requirement and the BMP-then-PNG
format mix Windows Explorer / NSIS render best across Win10/11.
- icon.png: 1024×1024 RGBA, replacing the previous 512×512 placeholder.
No electron-builder.yml change needed — buildResources: build picks
both files up automatically.
Co-authored-by: multica-agent <github@multica.ai>
The chat window used to fire two parallel session queries (active subset
+ full list) and surfaced them through two UI entry points (the title
dropdown + a History icon panel). The two caches drifted during the
WS-invalidate window — visible as "completed → reload → ghost row"
flickers — and the History toggle was a redundant entry into the same
underlying data.
Collapse to one cache (full list, ?status=all) and one entry point
(dropdown). The dropdown groups locally into Active / Archived; the
archived group is collapsed by default with a count, and per-row
delete moves into the dropdown via hover-revealed trash + confirm
dialog. Backend stays untouched: old desktop builds still hit
GET /chat-sessions without ?status and continue receiving the active
subset, so installed clients are unaffected.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Importing a skill from a github.com URL probes the commits API to
disambiguate slash-bearing refs. On self-hosted servers the IP is often
already over GitHub's 60-req/hour unauthenticated limit, so the very
first probe returns 403 and the previous code aborted the entire
import ("validating ref \"main/skills/pptx\": github API returned
status 403").
Two changes make this resilient:
* Forward GITHUB_TOKEN as a bearer token on every api.github.com request
via a new doGitHubAPIGet / addGitHubAuthHeader helper. With a token,
the limit becomes 5000 req/hour and the issue disappears entirely.
* When the API still returns 401/403/429 (no token, or limit exhausted
on the higher tier) treat the probe as indeterminate via
errGitHubAPIBlocked, keep trying remaining candidates, and finally
fall back to parseGitHubURL's optimistic single-segment split. This
covers the common case (single-word refs like "main") even when the
API is fully blocked. A warn log points operators at GITHUB_TOKEN.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs(claude): add API Response Compatibility section
Narrows the existing "no backwards compat" rule to internal code only,
and adds a new section that codifies the defensive boundary at API
edges: parse-don't-cast, never pin UI to a single field, enum drift
must downgrade not crash.
Driven by #2143/#2147/#2192 — all three were the desktop client white-
screening on backend response shape changes the client wasn't built
against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(core): add zod-based API response validation layer
Introduces a defensive boundary so a malformed backend response
degrades into a safe fallback (empty page, [], etc.) instead of
throwing inside React render.
- Adds zod to the pnpm catalog and as a @multica/core dependency.
- New parseWithFallback helper in core/api/schema.ts that runs
safeParse, logs a warn with the endpoint + zod issues on failure,
and returns the caller-supplied fallback. Never throws.
- Schemas in core/api/schemas.ts are deliberately lenient (string
enums kept as z.string() so unknown values still parse, optional
fields default, nested records use .loose() for unknown keys).
- Wires setSchemaLogger from CoreProvider so warnings flow through
the same logger as the rest of the API client.
This is the primitive — see the next commit for the call-site wiring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(api): guard top 5 high-risk endpoints with parseWithFallback
Wraps the response of the five endpoints whose UIs white-screened in
past incidents (#2143/#2147/#2192) so a contract drift returns a safe
fallback instead of crashing the consumer:
- listIssues → ListIssuesResponseSchema, fallback { issues: [], total: 0 }
- listTimeline → TimelinePageSchema, fallback empty page
- listComments → CommentsListSchema, fallback []
- listIssueSubscribers → SubscribersListSchema, fallback []
- listChildIssues → ChildIssuesResponseSchema, fallback { issues: [] }
getIssue is intentionally NOT wrapped: there is no sensible "empty
issue" — the entire detail page depends on real fields. The page-level
ErrorBoundary (separate commit) catches that case.
Adds schema.test.ts with 9 cases covering the five failure modes
listed in MUL-1828: missing fields, wrong types, enum drift, null
body, and null arrays.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(ui): add ErrorBoundary and wrap high-risk pages
Section-level error boundary (no third-party dep — class component +
default fallback in @multica/ui). Supports a fallback render prop and
resetKeys for auto-recovery on resource navigation.
Wraps the surfaces that white-screened in past incidents:
- IssueDetail (web + desktop + inbox split-pane) — keyed on issueId
so navigating to a different issue clears the boundary automatically.
- IssuesPage (web + desktop).
Boundaries are placed at consumer call sites rather than inside
IssueDetail itself so we don't have to refactor the 1100-line
component, and so a crash inside one inbox split-pane doesn't take
down the inbox list next to it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): make all API schemas .loose() to preserve unknown fields
zod 4 z.object() defaults to STRIP, which silently drops fields the
schema didn't list. That makes the schema layer a sync point: a future
PR adding a TS field but forgetting the schema would have the field
disappear at runtime while TS still claims it exists — the exact bug-
class this PR is meant to prevent, just inverted.
Apply .loose() to every object schema (TimelineEntry, TimelinePage,
Comment, Issue, ListIssuesResponse, Subscriber, ChildIssuesResponse)
so unknown server-side fields pass through unchanged. Add a regression
test that feeds a payload with extra fields at both entry and page
level, and a direct unit test for parseWithFallback decoupled from any
endpoint. Update the listIssues fallback test to use a wrong-type
payload — under .loose() the previous "{ unexpected: true }" payload
parses successfully (every declared field has a default) instead of
triggering the fallback path it was meant to exercise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude): strip field-specific examples from API Compatibility section
The original wording embedded current schema field names (entries,
has_more_before, has_more_after, cursor, status, type) directly in the
rules. CLAUDE.md should state the rule, not the implementation — once a
field is renamed the doc drifts out of sync with the code, and the
specific names don't add anything the abstract rule doesn't.
Keep the rule, drop the field-level archaeology.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): guard IME composition on Enter-to-submit handlers
Chinese/Japanese/Korean IMEs use Enter to commit a multi-key
composition. When that Enter also triggers a submit/create handler,
the form fires before the user has finished typing.
Add a shared `isImeComposing` predicate in @multica/core/utils that
checks both `nativeEvent.isComposing` and `keyCode === 229` (Safari
clears isComposing on the commit keydown but keyCode stays 229).
Apply the guard to every Enter→action handler in packages/views where
the input can hold IME text: workspace name, agent name/description,
skill name, label name/edit, mention suggestion picker, property
picker search, delete-workspace typed confirmation.
Tiptap submit-shortcut already guards via `view.composing`; left as is.
Skipped numeric/email/URL/file-path inputs where IME does not apply.
Co-authored-by: multica-agent <github@multica.ai>
* style(agents): align Escape handling with early return in inspector
Three onKeyDown handlers in agent-detail-inspector.tsx now follow the same
shape as labels-panel: handle Escape with an explicit return, then the IME
guard, then Enter submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(timeline): include merge-truncation case in has_more_before (#2192)
Older comments became unreachable on issues where activity-log entries
crowded them out of the latest 50-entry page. The 'show earlier' button
was hidden and no cursor was emitted because the has_more_before formula
only caught the per-table SQL cap case and missed the in-memory merge
truncation case.
Reproduces with 48 comments + 49 activities, default limit 50: neither
table individually returns >= limit rows, but their sum (97) exceeds the
merged page size, so the merge silently drops 47 older comments. The old
formula reported has_more_before=false; the client never asked for page 2.
Fix: extract hasMoreBeyond(c, a, e, limit) with the missing third
disjunct - comments + activities > entries - applied uniformly to
listTimelineLatest / Before / After / Around.
Backwards compatible: API contract unchanged. Pre-cursor clients
(<=v0.2.25) still hit listTimelineLegacy and never read these fields.
Newer clients see has_more_before flip from 'wrongly false' to correctly
true/false - no field renames, no shape changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(issues): show count badge when activities are coalesced (#2192)
The timeline coalesces consecutive same-actor + same-action activities
within a 2-minute window so 48 status_changed entries don't take 48 rows.
The count badge was only rendered for task_completed / task_failed; for
status_changed (and every other action) the coalesced batch silently
collapsed to a single line with no hint that N entries were merged.
Add a coalesced_badge translation and render '×N' next to the activity
text whenever coalesced_count > 1, suppressing it on task_completed /
task_failed which already include the count in their translation copy.
This pairs with the backend fix for #2192: once the older-comments page
becomes reachable again, the activity rows above it should make the
density of the merged batch visible rather than misleading the user
into thinking only one event happened.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): add Copy local workdir path to issue menu
Surface the daemon-pinned task work_dir on the AgentTaskResponse and add a
"Copy local workdir path" action to the issue dropdown / context menu. The
action picks the most recent task with a recorded work_dir and writes it
to the clipboard so users can jump straight to the local execution
directory to inspect results.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): preserve user activation in Copy local workdir path
Move the task list subscription out of useIssueActions and into
IssueActionsMenuItems, where Base UI lazily mounts the menu content
only after the user opens the menu. The click handler now reads
straight from the cached query result and writes to the clipboard
synchronously, so the awaited fetch no longer drops the browser's
transient user activation when the cache is cold (e.g. opening the
context menu on an issue list row that hasn't pre-populated the
ExecutionLogSection cache).
Per Emacs PR review.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add `multica workspace update` to edit workspace metadata
Closes the CLI-side gap for #2178: the `PATCH /api/workspaces/{id}`
endpoint and TS client method already exist, only the CLI subcommand
was missing. Supports partial updates of name, description, context,
and issue_prefix; long fields accept stdin via `--description-stdin` /
`--context-stdin`. `slug` stays immutable, `settings`/`repos` are out
of scope (deferred). Empty PATCH is rejected locally so we don't fire
a no-op `EventWorkspaceUpdated` broadcast. Permission gate is
unchanged (server-side admin/owner middleware).
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): address review on workspace update command
- Reject `--issue-prefix ""` (and whitespace-only) explicitly. The
server handler silently skips empty prefixes, so the previous
behavior was a 200 OK with no actual change — exactly the kind of
invisible no-op Emacs flagged in review.
- Restore the `## Issues` H2 in the zh CLI reference. The earlier
edit dropped it, leaving issue commands nested under the Workspaces
section.
Co-authored-by: multica-agent <github@multica.ai>
* docs(cli): list `workspace update` in the en + zh top-level reference
Mirrors the existing zh-only entry under apps/docs/content/docs/cli/
into the English overview so the new command is discoverable from
both locales.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Archiving the currently selected inbox item used to clear the selection
and leave the detail panel empty, forcing the user to click the next
item to keep going. Pick the next (older) item from the deduplicated
list, falling back to the previous (newer) one when archiving at the
bottom, and only clear when nothing is left.
Route the detail panel's onDone path through the same handleArchive so
the auto-select behavior is shared.
Co-authored-by: multica-agent <github@multica.ai>
PR #2101 swapped the openclaw runtime adapter from reading --json on
stderr to stdout. That fixed openclaw 2026.5+ but inverted the breakage
for pre-2026.5 builds — those still write JSON to stderr, so the
adapter now sees an empty stdout and falls through to the same
"openclaw returned no parseable output" failure that 2026.5+ users
saw before #2101.
Add a per-task version gate inside openclawBackend.Execute that runs
`openclaw --version`, parses the dotted version, and rejects anything
below 2026.5.5 with a hardcoded upgrade hint:
openclaw <detected> is below the minimum supported version 2026.5.5.
Run `openclaw update` to upgrade and try again.
The check is intentionally per-task and uncached so users who upgrade
do not need to restart the daemon — the next task automatically
re-checks. ~20ms per task is negligible vs. the typical run.
Co-authored-by: multica-agent <github@multica.ai>
Multica's openclaw runtime adapter has been reading agent output from
stderr since the early openclaw integration days. Current openclaw
(2026.5.5, c37871e) writes its --json blob exclusively to stdout:
$ openclaw agent --local --json --agent main --message 'say hi' >stdout 2>stderr
STDOUT bytes: 27401
STDERR bytes: 0
Result: every successful turn was followed by a daemon-generated system
comment 'openclaw returned no parseable output', visible to users,
looked like the agent broke when it didn't. Reproduced live on WOR-2,
turn at 2026-05-05 16:35 UTC; daemon log confirmed the full result JSON
arrived on the [openclaw:stdout] debug channel and was discarded while
the empty stderr pipe hit the no-events fallback.
Changes
- server/pkg/agent/openclaw.go: swap pipes, StdoutPipe() for the JSON
stream, cmd.Stderr = newLogWriter(...) for log overflow. Cleanup
goroutine now closes stdout on cancel. Comments and the read-error
errMsg updated to reflect the new pipe.
- server/pkg/agent/openclaw_test.go: TestOpenclawProcessOutputReadError
asserts on 'read stdout' (was 'read stderr'), string-only fix,
no behavior change. New TestOpenclawProcessOutputStdoutFixture feeds
a recorded openclaw 2026.5.5 --json blob through processOutput and
asserts result + messages parse cleanly.
- server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json: 27401-byte
fixture captured fresh from the openclaw CLI for the regression test.
Side effects (net positive)
- Log lines openclaw writes to stderr (security warnings, tool errors)
now show up under [openclaw:stderr] instead of being silently consumed
by the JSON parser.
- Daemon's success_pattern heuristic (empty-output -> 'blocked')
becomes meaningful again because result.Output actually populates.
Closes WOR-10.
* fix(skills): drop SKILL.md content from list endpoints (#2174)
`GET /api/skills` and `GET /api/agents/{id}/skills` were SELECT *'ing the
skill row and shipping the full SKILL.md `content` blob to every caller.
SKILL.md bodies routinely run 50–200KB each, so a workspace with 30–40
skills returned multi-megabyte JSON arrays — past the CLI's 15s timeout
on high-latency links and locking out non-US users entirely.
Add `ListSkillSummariesByWorkspace` / `ListAgentSkillSummaries` sqlc
queries that omit `content`, plus a dedicated `SkillSummaryResponse`
wire shape so the contract is explicit (versus stuffing
`Content: ""` back into the existing struct). Detail endpoints
(`GET /api/skills/{id}`, agent CRUD return values) keep returning the
full body.
`AgentResponse.skills` and the matching TS `Agent.skills` now use
`SkillSummary[]` — frontend list/columns code already only read
id/name/description/config.origin, so the type narrowing matches actual
usage and prevents new code from accidentally depending on a content
field that won't be there.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): narrow embedded skills to AgentSkillSummary; gofmt agent.go
GPT-Boy review of #2180: the previous commit typed AgentResponse.Skills as
[]SkillSummaryResponse, but the agent list batch query
(ListAgentSkillsByWorkspace) only joins agent_id/id/name/description, so
the wider type left workspace_id/config/created_at/updated_at as zero
values. Define a dedicated AgentSkillSummary {id,name,description} that
matches what the batch query actually returns and what the frontend
actually reads (`agent.skills.map(s => s.name|s.id)`); the standalone
GET /api/agents/{id}/skills endpoint keeps SkillSummaryResponse for
callers that need the source/origin info.
Switch GetAgent's per-agent skills load from ListAgentSkills (full Skill
rows including content) back to ListAgentSkillSummaries to avoid reading
SKILL.md bodies just to discard them.
Re-run gofmt on agent.go to fix the field-tag alignment that drifted when
Skills changed type.
Co-authored-by: multica-agent <github@multica.ai>
* docs(types): correct SkillSummary JSDoc — Agent.skills is AgentSkillSummary[]
GPT-Boy spotted on review: comment said SkillSummary was "embedded in
Agent.skills", but that field is now AgentSkillSummary[]. Re-point the
reader at the right type to avoid future confusion.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Markdown links like `[xx](/workspaces)` written in `*.zh.mdx` rendered
as bare `<a href="/workspaces">`, which Next's basePath rewrote to
`/docs/workspaces` and the docs middleware then routed to English —
silently kicking Chinese readers out of their locale on every internal
click.
Add a `LocaleLink` MDX `a` override that runs every internal href
through `prefixLocale(href, lang)` before passing it to `next/link`, and
wire a `DocsLocaleProvider` around the MDX body in both page entry
points so the override and `NumberedCard` know the active locale.
External links, in-page anchors, relative paths, already-prefixed
paths, and default-language pages are deliberately left untouched.
Closes the bug reported in https://github.com/multica-ai/multica/issues/2173.
Co-authored-by: multica-agent <github@multica.ai>
* feat(create-issue): add border beam to "switch to agent" button
Draws the eye to the manual→agent affordance so users discover quick
capture mode. Adds a reusable .border-beam utility (conic-gradient ring
on ::before, driven by an @property-animated angle) and applies it to
the switch-to-agent button alongside a brand-tinted background tint and
a hover icon flip. Honors prefers-reduced-motion.
Co-authored-by: multica-agent <github@multica.ai>
* style(border-beam): switch to magic-ui colorful palette
Replaces the single brand-color sweep with a rainbow trail
(#ffbe7b → #ff777f → #ff8ab4 → #a07cfe → #5b9dff), matching the
`colorVariant="colorful"` look from magic-ui's border-beam reference.
Static fallback under prefers-reduced-motion uses the same palette as a
linear gradient.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The "+" button in each status column/section opens the create-issue
modal. On the project detail page it was passing only `{ status }`,
so the new issue's project field came up empty even though the user
was clearly in a project context. Thread `projectId` through
BoardView/ListView down to BoardColumn/StatusAccordionItem and
include `project_id` in the modal payload when set.
Co-authored-by: multica-agent <github@multica.ai>
#2128 changed GET /api/issues/:id/timeline from a bare TimelineEntry[] to
a wrapped { entries, next_cursor, ... } object. Multica.app ≤ v0.2.25 still
in the wild reads the response body as TimelineEntry[] directly, so the
moment v0.2.26 backend rolled out, every old desktop hit
"timeline.filter is not a function" on any issue open — bug reports landed
within ten minutes of the v0.2.26 release (#2143, #2147).
The new client always sends ?limit=..., so absence of every pagination
param uniquely identifies a legacy caller. Detect that at the top of
ListTimeline and serve the old shape (ASC, []TimelineEntry, capped at 200)
through a dedicated listTimelineLegacy helper. New clients fall through
unchanged.
A new TestListTimeline_LegacyShapeForPreCursorClients pins the contract
(array shape, ASC order, "[]" not "null" on empty issues). Two existing
tests that used the empty query string have been updated to send
?limit=50, since the empty form is now reserved for the compat path.
The legacy branch can be deleted once desktop auto-update has rolled the
user base past v0.2.26.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(landing): align ZH copy with conventions and update tool list to 11
- Replace "Agent" with "智能体" in ZH marketing copy (lines 1-275) per
conventions.zh.mdx — landing was the only surface still using "Agent"
while UI, docs, and locales already use "智能体". Changelog-section
technical names (Agent SDK / Agent runtime / Cursor Agent) preserved.
- Replace the 4-tool list (Claude Code / Codex / OpenClaw / OpenCode)
with the actual 11 supported tools across hero card, how-it-works
step, and FAQ — this matches daemon-runtimes.mdx and the file's own
changelog entries that already record the rollout of Cursor, Copilot,
Gemini, Hermes, Kimi, Kiro CLI, and Pi.
- Drop the "plug in and go" line; replace with an honest sentence about
multica setup walking through OAuth + daemon start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): correct daemon/runtime drift across modals, onboarding, docs
- modals/zh-Hans: 4 places used "daemon" untranslated; conventions.zh.mdx
rules Daemon -> 守护进程. Aligned.
- onboarding/zh-Hans: line "把任务交给它们" was the only spot using "任务"
for the task entity; rest of the file already uses lowercase "task"
per conventions. Aligned.
- onboarding (en + zh-Hans) runtime_aside.what_suffix: said runtime IS
a background process. daemon-runtimes.mdx defines runtime = daemon ×
one AI coding tool (one machine + N tools = N runtimes). Replaced with
the correct definition so new users form the right mental model on
first contact.
- onboarding (en + zh-Hans) step_platform headline+lede: said "Connect a
runtime" but the next options are "install desktop / CLI / cloud
waitlist" — those install a runtime source, not connect to one.
Reworded.
- onboarding/zh-Hans: 4 places used "AI 编码工具"; docs use "AI 编程工具"
consistently. Unified on the docs term.
- daemon-runtimes (en + zh): added cross-link to /desktop-app for users
deciding between desktop daemon and CLI daemon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): localize starter-content (Getting Started project)
The Getting Started project + welcome issue + 10 sub-issues that land in
the workspace at the end of onboarding were hardcoded English. Chinese
users finished a Chinese onboarding flow and arrived to an all-English
workspace; the welcome issue's prompt to the agent was also English, so
the agent's first reply tended to be English regardless of what
templates the user picked.
This commit adds Chinese parity, fixes the runtime definition error
that was the source of similar drift in onboarding.json, and removes a
few hardcoded UI specifics that would silently rot.
Architecture:
- Long-form markdown (~600 lines per language) lives in TS sibling
files: starter-content-content-en.ts and starter-content-content-zh.ts.
JSON locales were considered, but multi-paragraph markdown becomes
unreadable single-line escape soup in JSON; keeping it in TS lets
reviewers see the rendered shape and catch markdown regressions in
code review.
- starter-content-templates.ts is now a thin orchestrator: imports both
content files, exports buildImportPayload({ ..., locale }), picks the
right one at runtime.
- StarterContentPrompt resolves locale from i18n.language (with a small
startsWith("zh") helper so "zh-Hans-CN" or future variants still hit
the ZH content).
Content fixes (apply to both EN and ZH):
- "A runtime is a small background process" was wrong (runtime = daemon
× one AI coding tool, per docs). Replaced with the correct definition
so the welcome agent doesn't seed an incorrect mental model.
- Removed hardcoded "tabs at the top: 6 tabs" / "(third row)" /
"6 templates" lists — those rot the moment product UI changes. Replaced
with descriptions that don't depend on exact counts/positions.
Conventions adherence (ZH):
- agent → 智能体, daemon → 守护进程, runtime → 运行时, workspace → 工作区
- task / issue / skill stay lowercase English (per conventions.zh.mdx)
- Product UI labels (Properties, Assignee, Status, Activity, Live card,
Inbox, Members, Settings, Runtimes, Configure, Repositories,
Instructions, Tasks, Skills, Autopilot, etc.) stay English so the
doc text matches what the user sees on screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(conventions): formalize mixed-rule for task / issue / skill in CN
The prior rule said issue/skill/task always render as lowercase English
in Chinese text. That worked for UI strings but never matched what the
sister docs actually do — tasks.zh.mdx is built around "执行任务",
issues.zh.mdx titles "Issue 与 project", skills.zh.mdx titles "Skills".
Three docs, three patterns, all sensible in their own context, none
matching the old rule. Conventions also explicitly cited the docs as
the voice standard, so the rule was internally inconsistent.
This commit promotes the de facto pattern to a written rule:
- UI strings, state names, code references → lowercase English
("排队中的 task", "创建子 issue", "为智能体注入 skill")
- Doc titles / section headings → Title-case English OR Chinese term
("Issue 与 project", "Skills", "执行任务")
- Doc prose where the entity is the running subject → Chinese term,
with English in parentheses on first mention
("**执行任务**(task)是智能体每一次工作的单位")
- API / DB fields → always task / issue / skill (`task_id`, etc.)
Provides the term mapping (task ↔ 执行任务) explicitly so future
translation PRs don't have to rediscover it.
No code or other doc changes — tasks.zh.mdx already follows this
pattern; this commit just formalizes it. Other ZH locale strings
remain lowercase per the UI rule (which the locale audit + PR #2139
verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add Projects page (en + zh) and Autopilot failure visibility note
The audit found that 'projects' was the most prominently missing docs
page — it appears as a sidebar nav item in onboarding's workspace
preview, but users clicking through to docs found nothing on the topic.
The other locale-but-no-doc pages (my-issues, labels, settings) are
listed as follow-ups; this PR ships the highest-impact one.
Also adds a missing piece in tasks.{mdx,zh.mdx}: the Autopilot
no-auto-retry callout explained the *why* but never the *how do I
notice* — added a sentence pointing users at Inbox + the issue
status revert + the Autopilot page's run history.
projects.mdx covers:
- What a project is (container for related issues)
- Fields: name, icon, description, lead, status, priority, progress
- Project-issue many-to-one relationship + how progress is computed
- Pinning to sidebar (personal preference)
- Resources section (GitHub repos passed to daemon)
- Delete behavior (issues unlinked, not deleted)
- Lead can be a member or an agent
Both pages registered in meta.json / meta.zh.json under "Workspace &
team" group, between issues and comments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(pr-template): add drift-prevention checkboxes for runtime/CN copy
Two failure modes the docs+onboarding audit found, both caused by
adding-a-thing without remembering all the places that thing surfaces:
1. New runtime / coding tool / UI tab gets recorded in changelog but not
in landing FAQ ("Multica supports 4 tools" while changelog shows the
11th was added) or starter-content tutorial ("6 tabs at the top:
Instructions / Skills / Tasks / Environment / Custom Args / Settings"
stays frozen the moment a tab is added or renamed).
2. Chinese copy added without checking the canonical glossary —
"Agent" survived in landing/zh.ts long after product UI standardized
on "智能体" because nobody routed landing through the conventions
review.
Adding two checklist items to the PR template so authors see the
specific paths to update at PR-creation time, before the drift ships.
This is the final batch (5 / 5) from the audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the assistant timeline into a Conductor-style "X steps"
outer fold that wraps every thinking/tool/intermediate-text item between
the first and last non-text item; the final answer renders below the
fold at full prose size. The inner per-row Collapsibles
(ThinkingRow / ToolCallRow / ToolResultRow) are unchanged.
Adds an inline footer "Replied in 38s · [Copy]" beneath each persisted
assistant reply. Copy puts the markdown source of the visible text
(preface + final, never middle) on the clipboard via the existing
`copyMarkdown` helper. Suppressed during streaming.
Pure carving + extraction lives in `chat/lib/copy-text.ts` with 11 unit
tests covering all timeline shapes (all-text, all-non-text, standard,
preface, multi-final, legacy fallback).
Also cleans up 7 pre-existing `text-[11px]` arbitrary values in this
file to `text-xs`, and uses standard `size="icon-xs"` Button variant
for the Copy button (no manual size overrides).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-live-card): self-heal stale "is working" banner via reconcile
The banner relied on receiving task:completed/failed/cancelled to clear
itself. When a WS reconnect dropped one of those events the banner stayed
forever and the elapsed timer kept ticking.
Replace the additive update paths (mount + queued/dispatch) with a single
reconcile() that refetches /active-task and replaces the local task set
with the server's truth, preserving accumulated TimelineItems for tasks
still active. Wire it to:
- mount / issueId change
- WS reconnect (useWSReconnect)
- task:queued / task:dispatch
- task:completed / task:failed / task:cancelled (after the optimistic
delete, so a missed sibling end-event also clears)
Per-task hydration guard (hydratedTaskIds) keeps the messages backfill
one-shot when reconcile fires repeatedly within a tick.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent-live-card): guard reconcile against out-of-order responses
reconcile() previously had no request-ordering protection, so a slow
getActiveTasksForIssue response could land after a newer one and clobber
the fresher state. Race scenario: task:queued fires reconcile A (response
includes T but is delayed); task:completed fires next, optimistically
removes T, and triggers reconcile B; B resolves empty and clears the
banner; A finally resolves with the stale snapshot and re-adds T —
permanent stale "is working" banner with no further events to clear it.
Add a monotonic reconcileSeq ref. Each call captures its issued seq;
the response only applies if mySeq === reconcileSeq.current (i.e. no
newer call was issued after this one). Drop the response otherwise.
Add a regression test covering the deferred-promise case plus a
companion test for the WS reconnect self-heal path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The 60vh value is the magic number that keeps the tab content area
usably tall when the parent stacks inspector + overview on mobile and
delegates scroll to the page. Add a short note next to the className
so future maintainers know what the constraint is for and why `md:`
overrides it.
* feat(autopilot): auto-pause autopilots with sustained high failure rate
Adds a background monitor that pauses any active autopilot whose recent
runs are dominated by failures (defaults: ≥100 terminal runs in 7d, ≥90%
failed). The monitor leaves a severity=attention inbox notification for
the autopilot's creator (or the agent's owner if the autopilot was
agent-created) so a human learns about the auto-pause and can fix the
root cause before re-enabling.
Motivated by MUL-1336 §6 #2: a single broken cron autopilot
(`Registro de ls cada 5 min`, 1,475/1,476 failed in 7d) was burning
~1.5k tasks/tokens per week with no human in the loop.
Tunable via AUTOPILOT_FAIL_MONITOR_{INTERVAL,LOOKBACK,MIN_RUNS,FAIL_RATIO,STARTUP_DELAY};
INTERVAL=0 disables the monitor entirely.
Co-authored-by: multica-agent <github@multica.ai>
* chore(autopilot): relax failure monitor defaults to daily / 50 runs
Per review feedback in MUL-1339: 30-min scan was overkill — the 50-run
threshold already provides multi-hour lag, and operational simplicity
matters. Lowering MinRuns from 100 → 50 keeps low-frequency autopilots
in scope (~7 runs/day reaches threshold within 7d window).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): tighten quick-create prompt to drop meta-instructions and apologetic Context
The quick-create prompt was producing descriptions that:
1. Echoed routing meta-instructions ("create an issue for me", "cc @X") into
the User request body, even though those phrases are handled by separate
CLI flags and are not spec content.
2. Emitted a Context section to apologize for resources it could not fetch
(e.g. an image attachment not piped through to the run), instead of
staying silent and letting the executing agent ask the user.
3. Preserved pure conversational fillers ("对吧?", "嗯", "那个…") because the
model treated removing them as forbidden paraphrasing.
Updates the prompt to call out each of these as explicit non-spec material
to strip before writing the description, while keeping the "high fidelity /
no paraphrasing of substantive content" invariant. Adds a regression test
that locks in the new rules at the substring level.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): preserve cc mention links in quick-create description
Stripping "cc @Y" wholesale would have lost the mentioned member's only
routing channel: `multica issue create` has no --subscriber/--cc flag, and
the platform auto-subscribes members by parsing `[@Name](mention://member/<uuid>)`
links from the description body. Without the mention link in the body, a
cc'd member would never get subscribed or notified.
Updates the prompt to:
- Strip only the verbal "cc" wrapper from the User request body.
- Append a trailing `CC: <mention links>` line to the description so the
platform's auto-subscribe logic still picks the mentions up.
- Spell out the contrast for assignee mentions, where --assignee-id is
the routing channel and the body should not double-encode the mention.
Also adds a substring assertion for the "Pure conversational fillers" rule
that was missing from the original regression test.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(daemon): trim quick-create prompt rules to general principles
Reviewers pointed out the previous rewrite traded one prompt smell (over-
permissive verbatim quoting) for another (too many specific rules and
exhaustive bilingual example tables). Rewrites the description block as
general principles with a single representative example each, trusting the
model to generalize:
- "Strip non-spec material before writing" replaces the multi-bullet list
of routing-meta-instruction and conversational-filler enumerations.
- "Include Context only when references were fetched and produced facts;
never use it as an apology log" replaces the three "Do NOT emit a
Context section to" sub-bullets.
- The CC exception (the only operationally non-obvious rule, since
`multica issue create` has no --subscriber flag) is kept inline as a
single sentence and is still locked in by the regression test.
Net: ~16 fewer lines of prompt text without losing any of the rules the
test asserts.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.2.26 entry for 2026-05-06 release
Summarizes the 32 PRs landed on main since v0.2.25:
i18n (en + zh-Hans) full rollout, system notifications toggle,
chat session deletion, Redis-backed runtime liveness, long-issue
Timeline keyset pagination, and a batch of daemon/runtime
stability fixes. Mirrored across en.ts and zh.ts.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): tighten v0.2.26 feature copy
Per review feedback — drop "so you can" / "across the entire app"
clauses, match the terse one-clause cadence used by the 0.2.24 entry.
Improvements/fixes copy is unchanged.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(notifications): add system notifications toggle in settings
Add a per-user, per-workspace toggle to enable/disable native OS
notification banners. Reuses the existing notification-preferences
endpoint by introducing a `system_notifications` key alongside the
inbox event groups; the realtime handler reads the cached preference
and skips desktopAPI.showNotification when muted.
Co-authored-by: multica-agent <github@multica.ai>
* fix(notifications): fetch system_notifications pref lazily
Settings is the only mounted reader of notificationPreferenceOptions,
so a fresh app start (or any session that never visits Settings) left
the cache empty and the muted preference silently fell back to default
"all". Switch the inbox:new handler to ensureQueryData so the value is
fetched on first use and cached for subsequent events.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
- Rename appearance-tab → preferences-tab; AppearanceTab → PreferencesTab
- i18n top-level key appearance → preferences; tab label "Appearance" → "Preferences" / "偏好设置"
- Swap icon Palette → SlidersHorizontal (preferences semantic)
- SettingsPage: read active tab from ?tab= via NavigationAdapter, write back with replace() on change; whitelist valid tabs (incl. desktop extras daemon/updates), unknown values fall back to profile
- Update conventions.mdx (en + zh) references to renamed file and i18n key
Why preferences over appearance: the tab held both theme and language; "Appearance" semantically excludes localization. "Preferences" follows Linear/Slack/Discord and leaves room to add timezone/date format later.
Why query param over path: settings tabs are UI modifier state, not resources; query persistence keeps the existing single Next.js route file and desktop memory router unchanged, gives a natural fallback for unknown values, and avoids 404 risk.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent submit button rendered the shortcut hint twice — the i18n
string already contained '(⌘↵)' and the JSX appended another
formatShortcut() suffix. Drop the hardcoded shortcut from the
translations and rely on the platform-aware formatShortcut() in JSX.
Co-authored-by: multica-agent <github@multica.ai>
CI was running build + typecheck + test, but never lint. The i18n
guardrail (eslint-plugin-i18next on packages/views/**/*.tsx) was
configured but not enforced, so PRs kept landing user-facing English
strings (chat session delete, project resources, mermaid fallback,
invitations batch page).
Changes:
- .github/workflows/ci.yml: add `lint` to the turbo command
- packages/eslint-config/react.js: split React rules (JSX-only) from
react-hooks rules (apply to .ts too) — hooks live in .ts modules
like use-agent-presence.ts, and inline-disable comments need the
rule registered to resolve
- Translate the 10 lint errors that surfaced:
- editor/readonly-content.tsx mermaid render-error + rendering
- issues/issue-detail.tsx Archive tooltip
- invitations/invitations-page.tsx full page (new invite.batch.*)
- invitations-page.test.tsx wrap with I18nProvider so getByRole queries
match translated button labels
- core/auth/utils.ts intentional control-char regex: add eslint-disable
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)
Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.
This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.
API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
+ ?limit (default 50, max 100); response wraps entries with next/prev
cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
every row unbounded; the unbounded ListComments / ListActivities sqlc
queries are deleted.
Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
prepends to pages[0] only when the user is reading the live tail;
otherwise it just bumps a counter so the UI offers a "Jump to latest"
affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
core/issues/timeline-cache.ts) and use setQueriesData so all open
windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
during the brief load window plus subtle text-link load-more buttons
matching the existing Subscribe affordance (no Button chrome). Top
uses a divider for boundary clarity; bottom shows
"Jump to latest · N new" weighted slightly heavier when there's
unread state.
- highlightCommentId now flows into the hook's around parameter so
Inbox jumps fetch the surrounding 50 entries directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(agent): default comment list to 50 + prompt hint about long issues
The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.
The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): rollout phase — translate 9 namespaces (WIP)
Phase 1 complete (基建 + login + Settings language switcher),
phase 2 partial (Wave 4 done, search done). Pending namespaces
documented inline; another developer can pick up from here.
Infrastructure
--------------
- server: add users.language column + extend PATCH /api/me
(TestUpdateMeAcceptsLanguage / TestUpdateMePreservesLanguage)
- packages/core/i18n: types / pickLocale (intl-localematcher) /
browser-cookie-adapter / createI18n (initAsync false +
useSuspense false) / I18nProvider / LocaleAdapterProvider
- Split server-safe vs React entries:
@multica/core/i18n — for proxy/RSC/middleware (no React)
@multica/core/i18n/react — for client trees (createContext)
(RSC vendored React lacks createContext; mixed import would crash
proxy.ts at module load.)
- packages/views/i18n: useT hook + selector API augmentation
(i18next v26 default; auto-propagates to apps via the side-effect
import in use-t.ts).
- apps/web: proxy.ts (Next 16 renamed middleware) merges existing
legacy/root redirects with x-multica-locale header forwarding;
layout.tsx reads locale via headers() and pre-loads RSC resources.
- apps/desktop: webPreferences.additionalArguments injects
systemLocale (no sendSync — avoids main-thread blocking IPC);
renderer adapter reads via process.argv.
- ESLint: i18next/no-literal-string at file-scope for translated
files via packages/views/eslint.config.mjs TRANSLATED_FILES.
- glossary.md (packages/views/locales/) freezes term policy:
Issue / Workspace / Agent / Skill / Autopilot / Daemon / Runtime
stay English; Inbox / Project / Comment / Member translate.
Translated namespaces (9 / 19)
------------------------------
- auth: login page (web wrapper含 desktop-handoff 文案) + Settings
Appearance language switcher
- editor: 9 .tsx (bubble-menu / link-hover-card / readonly-content /
title-editor / extensions: code-block / file-card / image-view /
mention-suggestion) + 32 keys
- invite: 25 keys
- labels / members / my-issues: Wave 4 全部
- search: command palette 35 keys
- navigation: no user-facing strings (no-op)
Pending (10 / 19)
-----------------
issues (46 files / ~210 keys)
agents (29 files / ~155 keys; presence.ts + config.ts label maps
允许进 i18n)
onboarding (22 files / ~150 keys)
settings rest / skills / modals / workspace / chat / inbox /
projects / autopilots / layout
Workflow for picking up
-----------------------
- Glossary: packages/views/locales/glossary.md (mandatory read)
- Reference impls: auth/login-page.tsx + editor/* (selector API +
i18n-provider test wrapper pattern)
- Per namespace:
1. create locales/{en,zh-Hans}/{ns}.json
2. add to packages/views/i18n/resources-types.ts
3. useT('{ns}') + t($ => $.foo) in components
4. add files to TRANSLATED_FILES in eslint.config.mjs
5. typecheck + test + lint must pass
- Subagents currently CANNOT write files (sandbox deny). Run as
hybrid: subagent researches + outputs full JSON + tsx diff,
controller writes.
Other
-----
- scripts/init-worktree-env.sh: default
MULTICA_DEV_VERIFICATION_CODE=888888 in dev for deterministic
login (gated by isProductionEnv).
Verified: pnpm typecheck (6 pkgs ok), pnpm test (232 pass),
make test (Go).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(i18n): rewrite glossary aligned with docs zh voice
Switch translation policy to match the canonical CN voice already
established in apps/docs/content/docs/*.zh.mdx (20+ files). The new
rule splits product nouns into two classes:
- Typed entities (issue / project / skill / autopilot / task) — kept as
lowercase English in CN text, visually marking them as system types.
- Concepts (workspace / agent / daemon / runtime / inbox) — fully
translated (工作区 / 智能体 / 守护进程 / 运行时 / 收件箱).
Previous glossary kept Workspace / Agent / Daemon / Runtime as English
on "工程惯例" grounds, but docs zh and CN AI ecosystem (Coze / 腾讯元器
/ 百度) consistently translate these. App UI now matches docs voice so
users don't see split personality between the app and its own docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): register 6 namespaces and retrofit zh strings to new glossary
Two fixes that were blocking the previously-translated namespaces from
actually rendering in CN:
1. RESOURCES gap — locales/index.ts only loaded common/auth/settings,
but resources-types.ts declared 12 namespaces and 6 of them had real
translation content. At runtime i18next would fall back to raw keys
for editor / invite / labels / members / my-issues / search.
Register all 9 currently-translated namespaces.
2. Retrofit zh strings to the docs-aligned glossary:
- "Issue" → "issue" (lowercase entity)
- "Workspace" → "工作区"
- "Agent" → "智能体"
- "Runtime" → "运行时"
- "Skill" → "skill" (lowercase)
- "项目" → "project" (lowercase)
Touched: editor.json (sub_issue + mention.group_issues), invite.json
(3 Workspace occurrences), members.json (agents_section / more_agents),
my-issues.json (8 retrofits across page/header/errors), search.json
(13 retrofits across groups/pages/commands/empty).
Verified: pnpm typecheck (6/6) + pnpm test (238/238) all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate inbox namespace
First namespace through the sub-agent → main-agent integration pipeline.
JSON: en/inbox.json + zh-Hans/inbox.json — 60 keys across page / menu /
list / detail / types / labels / errors. Time-formatter labels are kept
compact in EN ("5m" / "3h" / "2d") and use full units in zh ("5 分钟" /
"5 小时" / "5 天") since raw "5 分" reads as "5 marks/points" in CN.
Component changes converted two module-level statics into hooks so the
strings can flow through i18next:
- inbox-list-item.tsx: `timeAgo` (pure fn) → `useTimeAgo` (hook
returning a fn). The local copy is a duplicate of @multica/core/utils
`timeAgo` that is only used by inbox-page; other consumers across
chat/agents/skills/issues stay on the core util for now and will be
translated when their namespaces land.
- inbox-detail-label.tsx: `typeLabels` (static const Record) →
`useTypeLabels` (hook returning the same Record shape). Call sites
keep the existing `typeLabels[type]` access pattern.
inbox-page.tsx now uses both hooks and `useT('inbox')` selector calls
for all hardcoded strings (~24 sites: header / dropdown menu / list
empty state / detail panel / mobile back / quick-create-failed flow /
all error toasts).
Wired up: resources-types.ts, locales/index.ts RESOURCES, ESLint
TRANSLATED_FILES (3 inbox tsx files now lint-protected).
Verified: pnpm typecheck (6/6) + pnpm --filter @multica/views test
(238/238) + ESLint clean on inbox/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate workspace namespace
Translates the three workspace shell views: create-workspace-form,
new-workspace-page, no-access-page. Also fixes the prior-art
no-unescaped-entities lint errors in no-access-page.tsx — the
apostrophes in "doesn't" / "don't" were JSX text literals that move
into JSON values after translation, so the lint rule no longer fires.
Tests wrapped: workspace/create-workspace-form.test.tsx,
workspace/no-access-page.test.tsx, modals/create-workspace.test.tsx
all now wrap render() with <I18nProvider locale="en"> so the en values
in workspace.json drive the rendered text and the existing assertions
continue to match.
Slug constants kept: WORKSPACE_SLUG_FORMAT_ERROR /
WORKSPACE_SLUG_CONFLICT_ERROR exports in workspace/slug.ts are still
imported by onboarding/steps/step-workspace.tsx (out of scope here).
The workspace shell now reads its strings from workspace.json directly.
Multica.ai brand prefix in the slug input affordance is wrapped with
an inline `// eslint-disable-next-line i18next/no-literal-string` per
glossary policy on brand names.
Renamed sign_in_other → sign_in_different to avoid colliding with
i18next's `_other` plural-suffix convention which the selector-API
typings treated as a plural form of `sign_in`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate projects namespace
Translates the projects list page, project detail page, project picker
dropdown, and project chip — all four user-facing surfaces under
packages/views/projects/components/.
New file: projects/components/labels.ts exposes three hooks that
replace the static `.label` field on PROJECT_STATUS_CONFIG /
PROJECT_PRIORITY_CONFIG and the previous module-level
`formatRelativeDate` helper. Core's `.label` stays untouched (it's
still consumed by search and the create-project modal, both
out-of-scope for this namespace) — those will flip when their
respective namespaces translate.
In zh, the "project" entity stays lowercase English per glossary
(`新建 project`, `还没有 project`, `从 project 移除`). Status / priority /
table column labels translate fully.
The cancelled / done / paused etc. status labels duplicate per-
namespace as `projects.status.*` rather than reading from a future
shared status namespace. This matches the auth/inbox/workspace
pattern of self-contained namespaces. If a generic "issue/project
status" pool emerges later, these can collapse.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on projects/ (1 pre-existing warning
about useEffect/sidebarRef dep, unrelated to i18n).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate autopilots namespace
Six tsx files: autopilots-page (list + 6 templates), autopilot-detail-page
(properties / triggers / run history / delete), autopilot-dialog
(create + edit dialog), trigger-config (cron form), and the agent /
timezone pickers.
Hook conversions for module-level helpers that need t():
- summarizeTrigger / describeTrigger → useSummarizeTrigger /
useDescribeTrigger (no external callers, removed the plain exports)
- formatRelativeDate → useFormatRelativeDate (per-component hook)
- formatCountdown → useFormatCountdown (per-component hook)
- TEMPLATES array now keyed by id; titles + summaries pull from
templates/{id}/{title,summary} JSON. Prompts stay raw EN since
they're injected directly into the agent task — translating them
would translate the agent's instructions, not the user's UI.
Status / execution-mode / run-status enums render via t($ => $.status[k])
with k typed against the core type (no separate hook needed).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on autopilots/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate skills namespace
Seven tsx files: skills-page (list + filters + intro banner),
skill-detail-page (the giant — properties + file tree + sidebar +
conflict banner + delete dialog, ~963 lines), create-skill-dialog
(chooser + manual + URL forms), runtime-local-skill-import-panel
(local runtime browse + import), skill-columns, file-tree, file-viewer.
Notable patterns:
- `createSkillColumns` factory → `useSkillColumns` hook so column
headers flow through useT. Column identity changes per render is
fine — DataTable handles it.
- `validateNewFilePath` (pure helper) → `useValidateNewFilePath` hook
so the 5 validation error messages can be translated.
- skill_files / used_by / description_with_agents use i18next plural
keys (`_one` / `_other`) — the type system collapses these into a
single PluralValue access, so call sites use
`t($ => $.foo, { count })` and i18next picks the form.
- Per glossary, "skill" stays lowercase EN in zh ("新建 skill",
"已删除 skill", "未找到该 skill").
Test wrapper: runtime-local-skill-import-panel.test.tsx now wraps
render() with <I18nProvider> so the assertion on /Import to Workspace/i
matches the EN translation.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on skills/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate chat namespace
Translates all 10 chat surfaces: FAB tooltip, input placeholders,
message list (replied-in / failed-after / tools group / show-details
/ tool result preview), session history (header + time-ago labels),
chat window (new-chat / restore / expand / minimize / agent + session
dropdowns / starter prompts / empty states), context-anchor button +
card tooltips, no-agent banner, offline / unstable banner, and the
task-status pill (queued / starting up / thinking / typing + tool
labels: running command / reading files / searching code / making
edits / searching web).
Hook conversions:
- formatTimeAgo (chat-session-history) → useFormatTimeAgo
- ElapsedCaption now takes a typed `variant` ("replied" | "failed")
instead of a free-text `verb` so the i18n key is enumerable
- pickStage (task-status-pill) refactored: pure pickStageKeys returns
StageKey + optional ToolKey; useResolveStage maps to localized labels
Translation policy notes:
- Starter prompts ("List my open tasks by priority", etc.) are user
UI when displayed AND the user's input when clicked — translating
them sends the agent the user's locale-native phrasing, which is
the right UX for a CN user using a CN agent.
- buildAnchorMarkdown (chat-window) stays in English: it's an
agent-bound markdown prefix injected into the outgoing message,
not user-facing UI.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate modals namespace
Translates all 11 modal sources: registry (no UI text), backlog-agent-hint,
set-parent-issue, add-child-issue, delete-issue-confirm, feedback,
issue-picker, create-workspace, create-project, create-issue (manual),
quick-create-issue (agent panel).
Notable patterns:
- create-project re-uses useProjectStatusLabels / useProjectPriorityLabels
hooks from views/projects/components/labels — same translation source
as the projects list / detail, no duplication.
- create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to
`toastId` to avoid shadowing the closure-captured useT() `t` function.
- Test wrapper added to modals/create-issue.test.tsx so the two assertions
on rendered modal text (success toast + Create another) match the EN
bundle. modals/create-workspace.test.tsx was already wrapped (workspace
ns commit).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate settings namespace (rest of tabs)
Builds on the appearance-tab + language switcher already shipped in
Phase 0. Translates the remaining 8 settings surfaces: settings-page
shell (left nav + tab keys), account / profile, notifications-tab
(5 group labels + descriptions), tokens-tab (create / list /
revoke / created dialog), workspace-tab (general fields + danger
zone + leave/delete confirmations), members-tab (invite + role
config + revoke / remove flows), repositories-tab, labs-tab,
delete-workspace-dialog.
Hook conversion: members-tab `roleConfig` static const → `useRoleLabels`
hook returning a Record<MemberRole, {label, description, icon}>. The
icon stays as a typed React component (Crown / Shield / User), so
rendering pattern is unchanged at call sites.
Test wrapper: settings/components/delete-workspace-dialog.test.tsx
now wraps render() with <I18nProvider> (custom render() helper)
because the test asserts on rendered button labels ("Delete workspace",
"Cancel", "Deleting...").
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes namespace (entry surfaces)
Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).
Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).
Deferred:
- runtime-list / runtime-columns (data table column headers + cell
bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
text.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate layout namespace (sidebar nav, help, loader)
Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
autopilots / agents / runtimes / skills / settings) — driven by
labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
/ Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip
Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).
Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding namespace (welcome + step header)
Translates the user-first-impression surfaces of the onboarding flow:
- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
Download Desktop / Continue on web / Start exploring / I've done
this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)
Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.
Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(i18n): add EN/zh-Hans key parity guard
Schema-level vitest that walks RESOURCES.en and RESOURCES["zh-Hans"]
namespace by namespace and asserts both bundles cover the same key
set. i18next plural rule is normalized before compare (`_one` /
`_other` collapse to a single logical key) so EN's plural pair
matches zh's `_other`-only form.
Catches retrofit drift where a new EN key lands without zh —
previously this would silently fall back to the English string in
production. Cheap to keep green: 39 tests across 21 namespaces in
under a second.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate issues namespace
Translates the entire issues surface — list / board / detail / comments /
sub-issues / activity feed / batch toolbar / pickers / context menu /
backlog-agent hint dialog / labels panel.
Component coverage:
- issues-page (page header, empty state, move-failed toast)
- issues-header (scope tabs, filter dropdowns w/ status/priority/
assignee/creator/project/label, display settings, sort, view toggle)
- issue-detail (page header, breadcrumb, properties / parent issue /
details / token usage sections, sub-issues, activity timeline,
formatActivity for status/priority/assignee/title/due-date changes,
subscribe/subscriber popover)
- comment-card + comment-input + reply-input (delete dialog, edit/save,
copy/edit/delete row, reply count, placeholders, expand/collapse)
- agent-live-card (is-working banner, tool count, stop / transcript)
- execution-log-section (section header, show/hide past runs, trigger
text builder, status labels, cancel-task)
- batch-action-toolbar (selected count, delete dialog with plurals)
- backlog-agent-hint-dialog (full dialog content)
- labels-panel (intro, create form, list, delete dialog)
- pickers (status / priority / assignee / due-date / label / property
search placeholder + no-results)
- issue-actions-menu-items (all dropdown / context menu items)
- use-issue-actions / use-issue-timeline (toast strings)
STATUS_CONFIG / PRIORITY_CONFIG label rendering routed through
$.status[enum] / $.priority[enum] at every call site; the core config
keeps its English fallback for non-i18n consumers but UI never reads
.label directly anymore.
Tests retrofitted: issues-page, issue-detail, and issue-actions-menu
RTL specs now wrap renders in <I18nProvider> with the EN bundle, so
their string assertions match the bundle (not hardcoded literals).
ESLint i18next allow-list extended to 24 issues files. Verified:
pnpm --filter @multica/views typecheck + test (277/277) all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate agents namespace
Translates the agents listing + detail surface and the create/duplicate
flow. Covers the high-frequency surfaces; deeper sub-tab editors
(activity / instructions / skills / env / custom-args bodies, and the
hooks-buggy runtime/model/concurrency pickers) are deferred — they
have their own pre-existing react-hooks rule violations and benefit
from a focused dedicated pass.
Component coverage:
- agents-page (page header w/ tagline + new button, scope segment,
search, sort dropdown, availability chips, archived toolbar, empty
state, no-matches messaging w/ search interpolation, list-load
error)
- agent-detail-page (back link, archived banner, archive dialog,
not-found state, all 4 toast strings)
- agent-detail-inspector (avatar editor, name + description popover,
description dialog, every PropRow label, validation message,
presence badge label sourced from $.availability[enum])
- agent-overview-pane (tab labels, discard-unsaved-changes dialog)
- create-agent-dialog (title / description / labels / placeholders /
duplicate-suffix / runtime filter buttons / runtime status copy)
- agent-row-actions (full dropdown items + cancel-tasks dialog with
pluralized "N running + M queued" summary + archive dialog + 6 toasts)
- agent-columns (every header cell, You / Archived chips, runtime
fallback labels, availability + workload labels via $.availability /
$.workload, activity tooltip body w/ created_today / created_days_ago
/ runs / failed-percent interpolation)
- inspector/skill-attach (Attach trigger label + aria)
availabilityConfig and workloadConfig now keep colors only — the
display label lives in the bundle, sourced via $.availability[enum]
and $.workload[enum] at every call site. Same pattern as
STATUS_CONFIG/PRIORITY_CONFIG in the issues namespace.
ESLint i18next allow-list extended to 8 agents files.
Verified: pnpm --filter @multica/views typecheck + test (277/277)
all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): clear 30 stray EN strings in translated files
Tail of literal strings missed in earlier passes — the ESLint i18next
allow-list flagged them but they slipped through review. Files touched:
- layout/app-sidebar.tsx (10 keys: Workspaces / Pending invitations /
Create workspace / Join / Decline / Log out / New Issue + shortcut /
Pinned / Workspace / Configure)
- runtimes/components/runtime-detail.tsx (Serving header + serving_count
pluralization, no_agents copy, running/queued chips with count
interpolation, Diagnostics header, CLI label, Delete runtime button,
Technical details toggle, last seen interpolation)
- onboarding/steps/step-welcome.tsx (entire WelcomeIllustration mock —
5 cards × actor names + body copy + 3 mention chips + 2 timestamps;
zh translation reads naturally instead of leaving the demo English)
- settings/components/labs-tab.tsx (`Co-authored-by: ...` git trailer
wrapped in {} so linter sees a JS string, not JSX text — magic
identifier git relies on, must not translate)
- settings/components/members-tab.tsx (✓ glyph wrapped in {})
- modals/feedback.tsx (⌘↵ shortcut wrapped in {})
ServingAgentsCard now reads availability/workload labels from
`agents` namespace (cross-namespace useT) so the bundle-truth pattern
holds: presenceConfig keeps colours only, label text comes from the
shared bundle.
Verified: typecheck + 277/277 tests + lint (only the pre-existing
react-hooks rule-of-hooks errors remain, which task #6 addresses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agents): rules-of-hooks + translate 4 model/runtime pickers
Three pre-existing react-hooks/rules-of-hooks violations + one missing
useMemo dep cleared, then the four pickers wired through useT.
Hook order fixes:
- concurrency-picker: useEffect now runs before the !canEdit early
return. Stale-draft reset still works the same way.
- runtime-picker: useMemo for the filtered list moved above the
!canEdit branch.
- model-dropdown: `models = data?.models ?? []` was minting a fresh
array each render and tripping the deps lint of the downstream
useMemo. Wrap in useMemo so the reference is stable.
Translation coverage:
- concurrency-picker: tooltip ("Concurrency · N max..."), range
helper text, Save button.
- runtime-picker: trigger label fallback ("No runtime"), tooltip
text composed from {{name}} + status, Mine/All filter buttons,
empty-list copy, "owned by {{name}}" + status fragments in row
tooltip, Cloud badge, online/offline aria.
- model-picker: trigger label, tooltip, "Managed by runtime"
fallback, search placeholder, "Discovering models…", default
badge, "No models available", "Use \"X\"" custom-id flow, Clear
button + its title.
- model-dropdown: every label string including the "Select a runtime
first" / "Default (provider)" / "Runtime offline — enter manually"
trigger fallbacks, the supported=false explanation block, discovery
failed badge, all popover items.
ESLint allow-list extended to 4 picker files. Verified: typecheck +
277/277 tests + lint (0 errors, only pre-existing react-hooks warnings
in unrelated files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes list + connect dialog + CLI updater
Three deep runtime surfaces wired through useT, with the agents
namespace doing double duty for shared availability/workload labels.
runtime-columns:
- 7 column headers via t-augmented createRuntimeColumns({ t }).
- HealthCell now reads from useHealthLabel() (already translation-aware)
instead of the EN-only healthLabel() helper.
- WorkloadCell sources the label from $.workload[enum] (cross-namespace
to agents) — colour stays via workloadConfig.
- CostCell delta "flat" copy + CLI cell "Desktop" badge + update-
available aria/tooltip + RowMenu's full delete dialog (title /
description with {{name}} interpolation / cancel / confirm /
deleting state) plus its admin-permission hint.
connect-remote-dialog:
- Three steps fully translated: instructions (header + 4 numbered
steps + security warning + troubleshooting list with mono code
snippets escaped as JS strings), waiting (loader + hint), success
(CTA pair).
- Mono CLI commands wrapped in {} so linter sees JS strings — those
are literal commands that must stay untranslated for the user to
paste into a terminal.
update-section:
- statusConfig collapsed to icon+colour only; labels move to
$.update.status[enum] for proper translation per-state.
- "CLI Version:" / "Latest" / "available" / "Update" / "Retry"
copy + the "Managed by Desktop" tooltip and disabled hint.
Layout helpers tagged: runtime-list passes `t` through to the column
factory the same way agent-columns does.
ESLint allow-list extended with the 4 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. usage-section.tsx
(KPI cards / WhenChart / TopUsageBreakdown / receipt table) is the
remaining runtimes surface — chart-heavy and benefits from a focused
pass next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate 5 agent detail tabs + skill-add dialog
The 5 tabs that fill the agent detail right pane plus the shared
skill picker dialog. Agents bundle gains a `tab_body` block with
sub-namespaces per tab + a `common` slot for save/add/unsaved.
Tab coverage:
- instructions-tab: intro paragraph, multi-line example placeholder
(full 18-line zh translation), Save / Unsaved.
- env-tab: read-only intro / empty state, editable intro with two
inline `<code>` env-var examples kept English (mono terminal
payloads), KEY / value placeholders, Show/Hide value aria, Add /
Remove aria, all 3 toasts (duplicate keys / saved / save failed).
- custom-args-tab: intro about whitespace splitting, launch-mode
prefix line + `<your args>` placeholder, --flag value placeholder,
Add, Remove aria, both toasts.
- skills-tab: intro, Add skill button, import-hint callout, empty
state title + hint + add-CTA, remove-failed toast.
- activity-tab: 3 section titles (Now / Last 30 days / Recent work),
active-task pluralization, performance subtitle, all 3 empty
states, runs/success%/avg-duration/failed pluralization with
interpolation, source labels (Issue / Chat / Autopilot / Untracked),
source fallbacks (Quick create / Creating issue / Chat session /
Autopilot run), issue-short fallback, "Triggered by" tooltip
header, open-issue / transcript / cancel-task tooltips and ARIAs,
cancelling state, started/dispatched/queued time prefixes, show
more.
- skill-add-dialog: dialog title + description, empty list copy,
Cancel button, add-failed toast.
skills-tab.test.tsx wrapped in <I18nProvider> with the EN bundle so
its `Local runtime skills are always available` assertion still
matches the resolved translation instead of the raw key path.
ESLint allow-list extended with the 6 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. Only the per-test
mock for skills-tab needed wrapping; the other 4 tabs ship without
test files of their own and inherit the I18nProvider chain via
agent-overview-pane / agent-detail-page test renders (when those
exist later).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding step-questionnaire + option-card
The user-profile step (3 questions) is the first deferred onboarding
deep step now wired through useT.
step-questionnaire:
- Eyebrow + headline + answered-progress counter with {{count}}
interpolation
- All 3 questions and their option labels (team size / role / use case)
- All 3 "Other" placeholders for free-text fallback
- Right-rail "Why three questions" / "What you get" panel: 2 eyebrow
rows, 2 unlock-item title+body pairs, learn-more link
- Back / Continue buttons via shared `common` block
option-card: shared "Other" radio label and aria.
Test wrapped in <I18nProvider>. EN value of `other_label` kept as
"Other" so the existing /^other$/i regex in step-questionnaire.test
keeps matching after the rendering pipeline switched from a hardcoded
literal to a bundle lookup.
ESLint allow-list extended with these 2 files. The remaining 4 deep
steps (workspace / runtime-connect / platform-fork / agent), the
2 ancillary surfaces (cli-install-instructions / starter-content-
prompt), and the 3 side panels (runtime-aside-panel / cloud-waitlist-
expand / compact-runtime-row) will be surfaced + swept by the global
ESLint switch (next commit).
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): flip ESLint to glob + drain remaining hardcoded EN
ESLint i18next/no-literal-string now applies to **/*.tsx by default
instead of an explicit allow-list. Files that genuinely still need
hardcoded EN are listed in STILL_HARDCODED — concrete, finite, and
the goal is to drain that list to zero.
Tail strings translated in this commit (surfaced by the global flip):
- common/task-transcript/agent-transcript-dialog.tsx — full dialog:
status badge (Running / Completed / Failed), sr-only DialogTitle,
Filter dropdown trigger + Clear filters, Copy all / Copy filtered /
Copied, tool-calls + events metadata chips with pluralization,
events-filtered "{{shown}} of {{total}}" interpolation, "Waiting
for events..." live state, "No execution data recorded." past
state. New `transcript` block in agents namespace.
- runtimes/components/charts/activity-heatmap.tsx — Less / More
legend labels around the contribution-style heat squares.
- search/search-trigger.tsx — sidebar Search... button label.
⌘ glyph wrapped in {} to satisfy the linter (mono shortcut symbol,
not translatable).
Holdouts (STILL_HARDCODED, ~14 files): the deep onboarding steps
(workspace / runtime-connect / platform-fork / agent / first-issue /
cli-install-instructions, plus 4 ancillary panels), the runtimes
usage-section + KPI cards, and 5 minor agent visual primitives
(sparkline / agent-presence-indicator / agent-profile-card /
visibility-badge / char-counter). Each one gets a dedicated future
pass; the global rule prevents new hardcoded strings from landing
elsewhere.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): drain agent visual primitives + onboarding small components
8 files removed from STILL_HARDCODED:
agents/components/:
- char-counter — over-limit text with {{count}} interpolation
- visibility-badge — uses new agents.visibility.{private,workspace}.
{label,tooltip} block; drops VISIBILITY_LABEL/TOOLTIP imports from
core in favour of bundle-driven copy
- agent-presence-indicator — availability + workload labels via
$.availability[enum] / $.workload[enum] (cross-namespace),
queue-badge "+N queued" with pluralization
- agent-profile-card — Agent unavailable / Detail link / Owner /
Skills / Runtime / Unknown runtime / Archived chip / availability
line via cross-namespace lookup
agents.json: new presence + visibility + profile_card + char_counter
blocks.
onboarding/components/:
- compact-runtime-row — online/offline aria via agents.availability
- runtime-aside-panel — full content (What's a runtime / Good to
know / Swap anytime / Add more later / docs link)
- starter-content-prompt — full dialog (title / description with
inline emphasis / both buttons / 3 toasts)
- cloud-waitlist-expand — intro paragraph + warning span / email
+ reason labels + placeholders + Optional badge / Join + on-list
states / both toasts
onboarding/steps/:
- cli-install-instructions — copy aria + intro + 2 step labels
onboarding.json: new runtime_aside / cli_install / starter_content /
cloud_waitlist blocks.
Tests for step-platform-fork + step-runtime-connect wrapped in
<I18nProvider> with EN bundle so /you're on the list/i etc. still
matches the resolved translations.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding deep steps
The 5 large onboarding steps that were deferred from earlier passes,
plus their support helpers, all wired through useT.
step-first-issue (final beat — flips onboarded_at):
- error_title / Retry / retry_failed toast / finishing / opening
states.
step-agent (creates the user's first agent):
- Templates moved from a module-level const to a useT-driven
useAgentTemplates() hook. Names + emoji stay constant (visual
identity), labels + blurbs + instructions resolve from the
bundle. coding / planning / writing / assistant — all four
templates ship a full zh translation that reads naturally.
- Recommended badge, eyebrow + headline + lede, footer hint,
Create {{name}} CTA, create_failed toast.
- Right-rail "About agents" panel (4 way-items + headline +
add-more hint + docs link).
step-workspace (create or pick existing):
- 5 footer states (open / creating / creating-pending / name-first
/ pick), all hint + CTA strings via interpolation.
- Name + URL + slug placeholders, issue-prefix preview spans,
Create-new card title + subtitle.
- 8-row WorkspacePreviewCard sidebar (Inbox / Issues / Agents /
Projects / Autopilot / Runtimes / Skills / And more) — every
label + meta strapped to bundle keys.
- 4 perks (assign / chat / invite / switch) + 3 next-steps
(runtime / agent / starter), 2 toasts (slug-conflict / failed).
- `multica.ai/${slug}` mono URL escaped via template-literal
expression so the linter sees a JS string.
step-runtime-connect (desktop scan flow):
- 3 phase headlines + ledes (scanning / found / empty), trust-strip
status (all online / N online / none online) with pluralization,
online/offline labels, Skip / Continue / Selected hint.
- Empty-view 2 cards (skip + waitlist) and the cloud waitlist
dialog wrapper.
step-platform-fork (web fan-out):
- Eyebrow + headline + lede, footer hint with 3 phase variants.
- Primary download card (before/after click) + 2 alt cards (CLI /
cloud) + CLI dialog with 4 elapsed-time stages (normal / midway /
slow / stalled), live-listening header, runtime-connected
pluralization, cloud waitlist dialog.
ESLint: STILL_HARDCODED list shrunk from 14 entries to 1 — only
runtimes/components/usage-section.tsx (chart-heavy KPI panel)
remains.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes usage panel + drop STILL_HARDCODED
Final i18n holdout: the runtimes usage panel (KPI hero, WHEN chart
tabs, cost-by breakdowns, daily breakdown table) is wired through
useT("runtimes"). With this drained, the eslint scaffolding for
explicit holdouts is removed — every JSX text node in @multica/views
now flows through i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): drain rollout gaps + add cross-device sync
Lands the post-review punch list for the i18n rollout: closes correctness
gaps that would have shipped silently, and adds the missing cross-device
locale sync the rollout's docs already promised.
Coverage:
- Register issues + agents namespaces in RESOURCES (90 useT call sites
were rendering keys-as-text in production)
- Harden parity test to compare RESOURCES keys against on-disk JSON
files, so a future missing namespace registration fails loudly
- Server-side language whitelist in UpdateMe + reject-unsupported test
- Safe SupportedLocale resolution in appearance-tab (no more `as` cast
on a region-tagged BCP-47 string)
- HTML lang attribute uses zh-CN (not zh-Hans) for screen reader / CJK
font-stack compatibility
- Cookie Secure flag on https
- Pulled createBrowserCookieLocaleAdapter out of the server-safe entry
into a new @multica/core/i18n/browser subpath; document.cookie access
can no longer leak into Edge middleware imports
Cross-device sync:
- New UserLocaleSync component mounted in CoreProvider; on login, if
user.language differs from the active i18n.language, persist via the
adapter and reload. Both apps benefit
- Desktop main process tracks system locale and emits IPC on focus when
it changes; renderer reloads only when the user has no explicit
Settings choice (their preference still wins)
Tests:
- pickLocale / matchLocale (11 cases incl. region-tagged BCP-47, malformed
tags, zh-Hant collapse-to-zh-Hans semantics)
- browser-cookie-adapter (6 cases under jsdom)
- Shared renderWithI18n helper at packages/views/test/i18n.tsx that wraps
the real RESOURCES map; future tests opt in instead of inlining a
per-file TEST_RESOURCES slice that goes stale silently
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(conventions): consolidate naming + i18n glossary into docs site
Single source of truth for code naming, i18n translation glossary, and
Chinese voice rules. Previously split between packages/views/locales/glossary.md
and scattered comments — now lives at apps/docs/content/docs/developers/conventions.{mdx,zh.mdx}
with both English and Chinese versions kept in sync.
Three sections per page:
1. Code naming — routes, packages, files, DB, Go, TS, commits
2. i18n translation glossary — entity vs concept rule, what to translate,
word combination, plurals, interpolation, key naming
3. Chinese voice + style — punctuation, principles, where to look in doubt
Side effects:
- packages/views/locales/glossary.md collapses to a stub redirecting to
the docs page; do not edit it
- CLAUDE.md gets a new top-level "Conventions reference" section so any
Claude session sees the pointer before any other rule
- apps/docs/content/docs/developers/ gets a stub English meta.json so the
conventions page is reachable on the EN side (contributing.zh.mdx /
architecture.zh.mdx remain ZH-only — separate work)
- Both root sidebars get a new "Developers" group
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): apply zh voice rules + translate project/autopilot
Two-part cleanup driven by the conventions doc landed last commit:
Voice violations (mechanical sweep across 10 zh-Hans namespaces):
- 「」 (Japanese-style brackets) → \" to match the EN source's straight
double quotes (~13 sites)
- … (single-char ellipsis) → ... three dots (~43 sites)
- Drop translation-ese pronoun "我们" where it's a pure narrator
("我们已发送" → "已发送", "我们替你托管" → "由 Multica 托管"); keep
"告诉我们" where "we" is the legitimate brand recipient
- "作为父级 / 作为子级" → "设为父级 / 设为子级"
- "任务" mistranslated as the task entity → `task` (lowercase EN entity)
- Dialog title "Autopilot" → "autopilot"
Translate project / autopilot per industry consensus:
- `project` → 「项目」 (~42 value sites). Feishu / Tower / Teambition /
PingCode / GitHub Projects all translate; no Chinese product keeps
`project`.
- `autopilot` → 「自动化」 (~34 value sites). Avoids the Tesla-style
「自动驾驶」 association; matches Notion / Feishu's industry term.
- Issue / skill / task remain lowercase EN per dev-team familiarity.
- Sidebar nav-label entities get Title Case ("Issue" / "Skill" / "我的
Issue") so the entry-point label reads as a proper UI signal; body
prose stays lowercase.
Conventions doc (EN + ZH) reflects the decision and adds a "why these
translate but issue/skill/task don't" rationale block.
Verification: parity test 45/45, full monorepo typecheck green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate chat session delete + project resources section
Two features main shipped while this branch was idle never went through
the i18n pass:
- Chat session delete confirmation dialog (#2115) and history toggle
tooltip (#2117): adds session_history.delete_dialog.* and
session_history.row_delete_*, plus window.history_show_tooltip /
history_back_tooltip.
- Project resources sidebar (#1926/#2080/#2111): entire component
including toasts, popover form, attach/remove tooltips. New
projects.resources subtree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server): return 500 for transient DB errors in daemon task lookup
requireDaemonTaskAccess used to turn any GetAgentTask error into
404 "task not found", including transient DB connection / pool errors.
Combined with PR #2107 — which added 404+"task not found" as a daemon
cancellation trigger — that means a single DB hiccup could kill an
in-flight agent run.
Distinguish pgx.ErrNoRows (real "task gone", 404) from other errors
(transient, 500 + warn log) using the existing isNotFound helper.
Tests cover both paths via the mockDB pattern already used by
TestFindOrCreateUserGating.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): honor task-deleted signal in post-runTask completion guard
The final pre-completion check in handleTask only looked for
status == "cancelled" and ignored errors. After PR #2107 added a 404
task-deleted cancellation path to the in-flight watcher, this trailing
guard fell out of sync — if the task was deleted between the watcher's
last poll and runTask returning, handleTask would still try to call
CompleteTask and only learn about the deletion via the 404 from that
callback.
Reuse shouldInterruptAgent so the same truth table (cancelled OR
404 task-not-found, but NOT transient errors) drives both polling and
the final guard.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
When the server deletes a task while the daemon's agent is still running
(issue removed, agent reassigned, workspace cleanup), GetTaskStatus
starts returning 404 "task not found". The previous polling loop only
checked for status == "cancelled" and silently swallowed the error, so
the local agent kept emitting tool calls against a dead task until its
own timeout fired — minutes of wasted model spend and patch_apply
operations against a workdir nobody would consume.
Changes:
- Add isTaskNotFoundError next to isWorkspaceNotFoundError so the daemon
can distinguish "task gone" 404 from "workspace gone" 404 (already
handled separately) and from generic network errors.
- Extract the cancellation polling goroutine in handleTask into
watchTaskCancellation, plus a pure shouldInterruptAgent decision
helper. The pure helper makes both signals (cancelled status and 404
task) easy to unit-test without spinning up a real backend.
- Trigger interruption on the new 404 path. Transient errors (5xx,
network) intentionally still don't cancel — the next poll will retry
and a flaky link should not kill an in-flight agent.
Tests cover the helper truth table, the existing "status cancelled"
path, the new "task deleted (404)" path, and a negative case ensuring a
running task is not interrupted.
Co-authored-by: “646826” <“646826@gmail.com”>
`ensureSymlink` previously short-circuited whenever `dst` already existed
as a regular file ("Regular file exists — don't overwrite"). On Windows
that branch is reachable via the createFileLink copy fallback that fires
when `os.Symlink` is unavailable, so once a per-task `codex-home/auth.json`
was written as a copy it would never be refreshed by subsequent
Prepare/Reuse calls. If the shared `~/.codex/auth.json` rotated (e.g.
Codex Desktop refreshed the token in the background), the daemon kept
handing Codex a now-revoked refresh_token, which the OAuth server
rejected with `refresh_token_reused` / `token_expired`. Renaming the
workspace directory was the only recovery path.
Treat any non-matching dst — wrong-target symlink, broken symlink, or
stale regular file — as something to delete and re-create via
createFileLink, so each Prepare/Reuse mirrors the current shared source.
Add a `logCodexAuthState` info log (file kind, link target, size, mtime —
never contents) so operators chasing the same symptom can see at a glance
whether the per-task home is tracking the shared auth or has drifted.
Tests cover: stale regular-file dst is replaced, copy-fallback dst is
refreshed when the shared source rotates, and a high-level
prepareCodexHome regression simulating the Windows + token-rotation
scenario from issue #2081.
Co-authored-by: multica-agent <github@multica.ai>
A non-trivial fraction of completed task workdirs (~28% in field reports)
end up with .gc_meta.json files containing issue_id: "". Empty issue_id
defeats the daemon's own GC loop (gc.go:139 calls
GetIssueGCCheck(meta.IssueID)) and external retention scripts that
cross-reference issue status before deleting orphaned workdirs.
Refuse to write the file when issueID is empty, logging a Warn so
operators have a starting point for debugging the upstream race
condition. Skip is preferred over a sentinel-marker file: it keeps the
data invariant clean (a .gc_meta.json file always carries a valid
issue_id) and matches the repo CLAUDE.md preference for not preserving
dual-state behavior.
WriteGCMeta now takes a *slog.Logger so it can emit the warning. The
package already uses log/slog (Prepare/reuseEnv), and daemon.go:884 has
taskLog in scope at the only call site.
Closes#1913
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
When the local state.db of an ACP backend (hermes, kimi, kiro) is wiped
— crash, config change, manual kill, container reset — the backend's
session/resume (or session/load, in kiro's case) silently creates a
brand-new session rather than failing, and returns the new id in the
response. Today the daemon ignores the response and stamps
sessionID = opts.ResumeSessionID across all three backends, so every
subsequent session/prompt is addressed to a session id the backend has
no record of. The task fails with JSON-RPC -32603 (Internal error) on
the very first turn, with no operator-visible signal that the problem
is a session-id mismatch one layer down.
The behavior is invisible: agent shows "started", then "failed" with a
generic Internal error. Reproducing in production took repeated runs
because nothing in the logs pointed at the silent reset.
Fix: route all three ACP backends through a small `resolveResumedSessionID`
helper that:
- prefers the id the backend returned in its response (the canonical
id; the one the backend will accept on the next call)
- falls back to the requested id when the response is malformed,
empty, or omits sessionId — defensive fallback so older / non-
conforming backends (notably kiro's current session/load shape)
behave identically to today
- signals (via a bool) when the id changed, so the caller logs a Warn
with `backend=<hermes|kimi|kiro>` and operators can grep for silent
state resets to correlate them with task failures
Why this is at the backend layer rather than the daemon's existing
session-resume fallback: server/internal/daemon/daemon.go:1554-1566
already retries with a fresh session when resume fails, but it gates
on `result.Status == "failed" && result.SessionID == ""`. The backend
WILL hand back a result.SessionID — just the new one it silently
committed to — so the daemon-level fallback never fires for this
failure mode.
The helper is also what session/new already uses (extractACPSessionID,
documented in code as "Shared by all ACP backends"). session/new
extracts the canonical id from the response; session/resume just
didn't, until now.
Coverage:
- hermes.go: confirmed bug, root cause of -32603 in production
- kimi.go: same code shape, same protocol method, same response
schema as hermes (per extractACPSessionID's comment) — same bug
- kiro.go: same code shape, different method (session/load). Current
observed response doesn't include sessionId, so the defensive
fallback means today's behavior is preserved. Routing through the
same helper means a future kiro release that DOES return a sessionId
on silent reset works the same way as hermes/kimi without another
diff.
Tests (server/pkg/agent/hermes_test.go — helper covers all three
backends, no per-backend duplication):
- TestResolveResumedSessionIDMatching — backend confirms requested id
- TestResolveResumedSessionIDDifferent — backend returned a new id;
caller is told to switch
- TestResolveResumedSessionIDEmptyResponse — older / malformed body;
defensive fallback to requested id (covers kiro's current shape)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): rewrite 404 page using design tokens
Replace editorial-style 404 (hardcoded cream/ink/terracotta colors,
Instrument Serif font, fluid clamp() typography) with a minimal version
using semantic tokens and the project's buttonVariants helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace): break NoAccessPage redirect loop by clearing stale cookie
The web proxy redirects / to /<lastSlug>/issues based on the
last_workspace_slug cookie alone, with no access check. When a user
gets evicted from a workspace, the cookie still points at it; clicking
"Go to my workspaces" then loops: NoAccessPage -> / -> proxy ->
same bad slug -> NoAccessPage.
Clear the cookie on mount so the proxy falls through to the landing
page, which resolves the correct destination via the workspace list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): mark not-found as client to allow buttonVariants import
buttonVariants is exported from a "use client" module, so calling it
from a server component is rejected by Next 16's directive checks.
Production build of /workspaces/new prerender failed because of this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): isolate runtime poll & heartbeat schedules per runtime
A daemon serving multiple workspaces ran a single round-robin poll loop
and a single HTTP heartbeat loop across every registered runtime. A 30s
HTTP timeout for any one runtime serialized that delay across all the
others — observed in production as one workspace's runtimes wedging
every other workspace's runtimes on the same daemon.
This change:
- Replaces the shared runtime-set channel with a multi-subscriber
watcher so taskWakeupLoop, heartbeatLoop, and pollLoop can each
react to runtime-set changes independently.
- Splits heartbeatLoop and pollLoop into supervisor + per-runtime
worker goroutines. Each runtime owns its claim cadence and its
heartbeat ticker, so a slow request on one runtime no longer blocks
any other.
- Stagers the per-runtime heartbeat first tick by a jittered delay up
to one full interval to avoid a thundering herd at startup.
- Sizes the WS writer channel to scale with the runtime count
(max(16, 2*N)) so a full per-runtime heartbeat batch always fits;
the previous fixed 8-slot buffer dropped heartbeats whenever a
daemon watched more than ~8 runtimes.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): acquire execution slot only after ClaimTask, drain pollers before taskWG
Two issues from review on the previous commit:
1. Acquiring the shared task slot before ClaimTask reintroduced the very
head-of-line blocking the refactor was meant to remove. With
MaxConcurrentTasks=1, a slow claim on one runtime parked the only slot
for the duration of the HTTP timeout (up to 30s), starving every other
runtime's claim attempts. Slots are now acquired after the claim
returns a task; other runtimes' pollers stay free to claim. The
already-dispatched task waits for a slot under MaxConcurrentTasks
bounds, which is the same backpressure shape we had before.
2. pollLoop's shutdown path called taskWG.Wait immediately after
cancelling pollers, but a poller could still be between ClaimTask
returning a task and taskWG.Add(1). When taskWG's counter is zero
that races with Wait — undefined sync.WaitGroup misuse, sometimes
panic. Added a pollerWG so the supervisor blocks until every poller
goroutine has actually returned before reaching taskWG.Wait.
Tests:
- TestRunRuntimePollerIsolatesSlowRuntime now uses MaxConcurrentTasks=1
(was 4) so it would have failed under the old slot-before-claim path.
- New TestPollLoopShutdownWaitsForPollersBeforeTaskWG drives the exact
race window — claim returns a task at the same moment shutdown fires —
under -race.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): acquire slot before ClaimTask so capacity-waiters never enter dispatched
The previous commit moved slot acquisition AFTER ClaimTask to address a
review concern about head-of-line blocking with MaxConcurrentTasks=1.
That introduced a strictly worse failure mode: server-side ClaimTask
flips the task to `dispatched` immediately (agent.sql:174-176), and the
runtime sweeper fails any task in `dispatched` for >300s with
`failed/timeout` (runtime_sweeper.go:25-28). When local execution
capacity is full and the next claimed task can't acquire a slot within
5 minutes, the user sees the exact failure this issue is fixing —
`dispatched_at` set, `started_at` NULL, `failure_reason=timeout`.
Reverted to slot-before-claim. The trade-off is the original review
concern: with MaxConcurrentTasks=1 and a slow ClaimTask, other
runtimes' claims are delayed by up to client.Timeout=30s. That's a
30s polling delay, not a failure — server-side those tasks remain
`queued` (no timeout in that state) until a slot frees. 30s ≪ 300s,
so other runtimes' tasks cannot get sweeper-failed because of this.
The pollerWG fix from the previous commit (avoiding sync.WaitGroup
misuse on shutdown) is preserved.
Tests:
- TestRunRuntimePollerIsolatesSlowRuntime: MaxConcurrentTasks back to
4 (the pre-issue baseline) — the headroom case where slot-before-
claim still gives full per-runtime isolation.
- New TestRunRuntimePollerSkipsClaimWhenAtCapacity: holds the only
slot and verifies the poller never calls ClaimTask while sem is
empty. The previous "claim first" path would have failed this.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): add resource_count breadcrumb to project responses
Closes#2087
`multica project get` previously returned project metadata with no signal
that resources existed. Agents that fetched a project this way had no way
to discover its attached resources without already knowing about
`/api/projects/{id}/resources` or the on-disk `.multica/project/resources.json`.
Rather than inline the full resource list into the parent payload (which
conflates parent metadata with a child sub-collection and locks the
resource_ref shape into the project endpoint's contract), this adds a
scalar `resource_count` breadcrumb to ProjectResponse. The actual list
stays at the dedicated sub-collection endpoint.
Changes:
- GetProjectResourceCounts :many — new batched sqlc query
- ProjectResponse.ResourceCount populated in GetProject, ListProjects,
SearchProjects, and the with-resources CreateProject echo
- multica project get prints a stderr hint pointing at
multica project resource list <id> when count > 0; the JSON on stdout
stays parseable
- Meta-skill (runtime_config.go) lists multica project get and
multica project resource list in Available Commands so agents that
read CLAUDE.md / AGENTS.md know about both paths
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): wire ResourceCount through Update + Create event payload
Review feedback on #2118.
- UpdateProject now reloads ResourceCount before responding/publishing.
Previously a title- or status-only PUT served (and broadcast over WS)
resource_count: 0 even when resources existed.
- The with-resources CreateProject path sets resp.ResourceCount before
the project:created publish, so the WS event payload matches the HTTP
echo. The hand-rolled response map collapses to an embedded
ProjectResponse + resources array — one source of truth for the
serialized shape.
- packages/core/types/project.ts: Project gains resource_count: number
to keep the TS contract aligned with the server response.
Tests:
- TestProjectResourceCountBreadcrumb extends to assert UpdateProject
preserves the breadcrumb.
- TestCreateProjectWithResourcesEchoesCount asserts the create echo
carries resource_count matching the attached resources.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The runtimes list page renders a CostCell per row that only displays a
7d cost total plus a 7d-vs-prior-7d delta. Until now each cell still
fetched a 180d usage window so the cache key matched the runtime-detail
page (clicking a row would pre-warm detail). The side effect was N
parallel 180d in-line aggregations against task_usage on every list
visit, one per runtime, which dominated DB load for this view.
Switch the cell to a 14d window — exactly the data it actually needs
for cost7d + costPrev7d. Detail still owns its own 180d query; the
worst case after this change is one extra request on first navigation
into detail, in exchange for a large steady-state reduction on the
list page (down to 14d × N instead of 180d × N, ~13× fewer rows
scanned per request).
This is the frontend half of the runtime-usage perf work tracked in
MUL-1748. The backend index + daily rollup changes will land
separately.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
ChatSessionHistory was already implemented but unreachable: nothing in the
app rendered it and there was no UI to toggle showHistory. The trash icon
on each session row was therefore invisible.
Adds a History icon button to the chat-window header that toggles the
panel; when on, it renders ChatSessionHistory in place of the message
list and input. Per-row delete (hover trash + AlertDialog) works as
designed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): support deleting chat sessions
Replaces the unreachable archive endpoint with a real hard delete and
exposes it from the chat history panel.
- DELETE /api/chat/sessions/{id} now hard-deletes the session and its
messages (CASCADE), cancels any in-flight tasks before removal so the
daemon doesn't keep running work whose result has nowhere to land,
and broadcasts chat:session_deleted.
- Frontend adds a per-row delete button with a confirmation dialog,
optimistically drops the session from both list caches, and clears the
active session pointer locally + on other tabs via the WS handler.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): make session delete atomic and keep archived sessions read-only
Address review feedback on #2115.
- DeleteChatSession now runs lock + cancel + delete in a single tx and
only broadcasts events post-commit. The new LockChatSessionForDelete
query takes FOR UPDATE on chat_session, which blocks the FK validation
of any concurrent SendChatMessage trying to enqueue a task for this
session — that insert fails after we commit, so it can no longer
produce an orphaned task whose chat_session_id is nulled by
ON DELETE SET NULL. Cancel failure now aborts the delete instead of
warn-and-continue.
- SendChatMessage refuses non-active sessions again. The archive code
path is gone, but legacy rows with status='archived' may still exist
in the DB; keep the guard until we explicitly migrate them.
- Frontend re-reads allChatSessionsOptions to disable ChatInput on
legacy archived sessions so the UX matches the server-side guard.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting
`multica issue {create,update,list}`, `issue assign`, and `issue subscriber
{add,remove}` accepted only fuzzy name matching, which fails in workspaces
where one user's name is a substring of another (e.g. agent "J" vs
"Cursor - J" / member "Jiayuan"). #1642 added UUID acceptance through the
existing flags, but there was still no explicit path that signals "this is a
UUID, not a name" — important for scripts that read IDs from
`multica workspace members --output json`.
Adds an `-id`-suffixed counterpart for every assignee-taking flag:
- `issue list` : --assignee-id
- `issue create` : --assignee-id
- `issue update` : --assignee-id
- `issue assign` : --to-id
- `issue subscriber {add,remove}` : --user-id
The new flags route through `resolveAssigneeByID`, a strict resolver that
requires a canonical UUID and fails with a clear error when the entity is
not in the workspace (no name fallback). A shared `pickAssigneeFromFlags`
helper enforces mutual exclusion between the name and id flags so a script
that accidentally sets both never silently applies one over the other.
Refs MUL-1254.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): detect assignee flag presence via Changed, not value-emptiness
`pickAssigneeFromFlags` previously branched on `flag value != ""`, so
explicitly passing an empty UUID silently routed through the "no flag set"
path:
multica issue list --assignee-id "" # listed every issue
multica issue create --assignee-id "" # created an unassigned issue
multica issue subscriber add --user-id "" # subscribed the caller
This is exactly the failure mode the strict-UUID flag was added to prevent —
a script interpolating `--assignee-id "$MAYBE_UUID"` against a missing env
var should fail loudly, not silently degrade to a different operation.
Switch the picker (and the assign-command top-level guard) to use
`Flags().Changed`, so an explicit empty value reaches `resolveAssigneeByID`
/ `resolveAssignee` and surfaces a clear "expected a canonical UUID" /
"no member or agent found matching" error.
Co-authored-by: multica-agent <github@multica.ai>
* docs(cli): cover --assignee-id / --to-id in user docs and quick-create prompt
Follow-up to the --*-id flag rollout: surface the new flags everywhere the
old ones are documented so users (and agents) can discover them.
- assigning-issues.{mdx,zh.mdx}: the page explicitly calls out the
duplicate-name footgun ("first one listed wins, so rename before
assigning") — replace that workaround with a --to-id <uuid> example
- cloud-quickstart.{mdx,zh.mdx}: add a --to-id hint after the substring-
match callout so first-time users learn about the strict path
- internal/daemon/prompt.go (quick-create injected prompt):
- default-to-self: pass --assignee-id <task.Agent.ID> instead of
--assignee <name>; the picker agent's UUID is already in scope and
UUID matching is unambiguous in workspaces with overlapping agent
names (J / Cursor - J / Pi - J etc.)
- user-named: tell the agent to prefer --assignee-id <uuid> using the
user_id/id from the JSON it already fetched; --assignee <name> stays
a fallback for unambiguous workspaces
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(storage): build region-qualified S3 public URLs (#2051)
The uploadedURL fallback (no CloudFront, no custom endpoint) wrote
"https://<bucket>/<key>" — missing the ".s3.<region>.amazonaws.com"
suffix — so any deployment that pointed S3_BUCKET at a real AWS bucket
without a CDN got broken image URLs back to the client. Avatar URLs
were persisted in this broken form on the user/agent rows, so profile
pictures uploaded via the SDK never rendered.
- Track S3_REGION on S3Storage and emit
https://<bucket>.s3.<region>.amazonaws.com/<key> by default;
fall back to path-style https://s3.<region>.amazonaws.com/<bucket>/<key>
when the bucket name contains dots, since the AWS wildcard cert
can't validate dotted virtual-hosted hosts.
- Teach KeyFromURL to recognise the new region-qualified hosts (both
styles) and keep recognising the legacy bucket-only host so historical
records can still be deleted/migrated.
- Document that S3_BUCKET is the bucket name only, not a hostname,
in env-vars docs (en+zh), self-hosting guides, and .env.example.
Co-authored-by: multica-agent <github@multica.ai>
* feat(storage): warn at startup when S3_BUCKET looks like a hostname
Catches the most common misconfiguration shape (S3_BUCKET set to
"<bucket>.s3.<region>.amazonaws.com") with a startup log line so
operators don't silently end up with a config that signs uploads
against an invalid bucket name.
A real bucket name can never legitimately contain "amazonaws.com",
so the check is a single substring match — no false positives
worth carving out.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The repo button in the Add Resource popover used the native `disabled`
attribute when a repo was already attached. Browsers suppress pointer
events on disabled form controls, so the tooltip on the URL text never
fired for attached rows — the issue spec calls out "hovering over any
URL should also show the complete URL in a tooltip".
Switch to `aria-disabled` plus a click guard so the row still announces
as disabled to assistive tech, looks the same visually, and is no
longer click-able, but hover still reaches the tooltip trigger.
Co-authored-by: multica-agent <github@multica.ai>
Replace `scope.replace("project:", "")` with the `projectId` already
held by `ProjectDetail`, so the create-issue handler in the empty
state no longer depends on the `project:<id>` scope-string format.
Co-authored-by: multica-agent <github@multica.ai>
When a project has no issues, show a [+ New Issue] button that opens
the create-issue dialog with the project pre-selected. Previously
users had to navigate to the issues page and manually assign the
project.
Also add tooltips to repository URLs in the Resources section so
truncated URLs can be read in full on hover.
Fixes#2078
Mobile project-detail mounted its <Sheet> with open=true for one render —
useIsMobile() reports false on first render and flips to true on the next,
so the mobile branch briefly mounted Base UI Dialog open, painted its
fixed inset-0 z-50 backdrop and locked scroll. The follow-up useEffect
toggled it closed within the same animation cycle, leaving Dialog's
pointer-events/inert/scroll-lock state stuck on mobile.
Mirror packages/views/issues/components/issue-detail.tsx by keeping
desktopSidebarOpen (default true) and mobileSidebarOpen (default false)
as separate states, binding the mobile <Sheet> to mobileSidebarOpen only.
The single-state pattern dates back to #1087, where issue-detail and
project-detail received mobile-Sheet support together but only
issue-detail used split state.
* refactor(quick-create): remove daemon CLI version gate
Local-source daemons report dev-suffixed versions (e.g.
v0.2.15-235-gdaf0e935) that the picker pre-check and server gate both
treat as too old, blocking quick-create during local testing.
Drops the gate end-to-end: removes MinQuickCreateCLIVersion +
CheckMinCLIVersion in pkg/agent, the checkQuickCreateDaemonVersion
handler and readRuntimeCLIVersion helper in handler/issue.go, and the
mirrored cli-version.ts plus the modal's pre-check, blocked-state UI,
and daemon_version_unsupported error branch.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(quick-create): skip daemon CLI version gate in dev
Restores the gate (reverts the full-removal commit) and bypasses it in
non-production environments instead. The motivation for the original
removal — local source-built daemons report a `git describe` version
like v0.2.15-N-gHASH that parses below 0.2.20 and blocks dev testing —
is now handled by checking APP_ENV on the server and NODE_ENV on the
client. Production keeps the original "needs upgrade" UX.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(quick-create): exempt git-describe daemons instead of env bypass
Replaces the per-environment bypass added in the previous commit with a
shared daemon-version signal. CheckMinCLIVersion / checkQuickCreateCliVersion
now treat any daemon whose CLI version matches the
`vX.Y.Z-N-gHASH[-dirty]` git-describe shape as OK; tagged releases keep
going through the normal min-version comparison.
Why: Emacs flagged that (a) NODE_ENV !== "production" also disables the
gate on staging and other non-prod deployments, undoing the protection
for the case the gate was originally written for, and (b) NODE_ENV (web
client) and APP_ENV (server) are not equivalent, so the modal pre-check
and server gate could disagree on the same request. Both go away when
the signal is intrinsic to the daemon's version string.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Consecutive "completed the task" entries from the same agent now merge
into a single line showing the count (e.g. "completed the task (7 times)")
regardless of time gap. Other activity types keep the existing 2-minute
coalescing window.
Closes MUL-1709
The sidebar search trigger, quick-create-issue modal, and feedback modal
hardcoded the Mac glyphs (⌘, ↵) for their keyboard hints, so Windows and
Linux users always saw Mac shortcuts even though the underlying handlers
already accept metaKey || ctrlKey.
Extract a small platform helper (isMac, modKey, enterKey, formatShortcut)
in packages/core/platform/keyboard.ts and route all four affected sites
(plus the editor bubble menu, which had the same logic inlined) through
it, so non-Mac users see Ctrl+K, Ctrl+Enter, etc.
Closesmultica-ai/multica#2056
The uniqueness check on workspace invitations only filtered by
status='pending', not by expires_at. Combined with the partial unique
index idx_invitation_unique_pending (also keyed only on status), a
past-due pending row permanently blocked re-inviting the same email.
Now, before creating a new invitation, the handler flips any past-due
pending row for the same (workspace_id, invitee_email) to 'expired',
freeing the unique slot. Also tightens GetPendingInvitationByEmail to
require expires_at > now(), matching the existing list queries.
Closesmultica-ai/multica#2055.
* feat(agents): make agent detail page mobile responsive (#1)
Stack the inspector + overview pane vertically below md, switch the
shell to page-level scroll so the inspector flows naturally, give the
overview pane a min-h-[60vh] floor so tabs stay usable, and let the
5-tab nav scroll horizontally on narrow viewports.
* fix(settings): make Repositories tab and Settings shell mobile-responsive (#2)
The Settings shell used a fixed w-52 sidebar with no responsive behavior,
leaving almost no room for tab content on phone-width viewports. Stack the
nav above the content on mobile, scale inner padding, and let the
Repositories tab's input/button rows wrap rather than overflow.
Pasting `line1\n\nline2` while the caret was inside a code block ran the
text through the Markdown parser, which split on the blank line and tore
the code block open, dropping the trailing content into a sibling
paragraph.
Detect the codeBlock parent on `handlePaste` and insert the clipboard
text verbatim instead. Code blocks have `code: true`, so newlines stay
literal — exactly what users expect when pasting code or logs.
Closes#1982
* fix(codex): handle MCP elicitation server requests correctly
Fixes#1942.
handleServerRequest responded with {} to unrecognized Codex server
requests including mcpServer/elicitation/request. Codex 0.125+ expects
{action, content, _meta} for elicitation — the empty object causes a
deserialization error and the MCP tool call is reported as user-rejected.
Changes:
- Add mcpServer/elicitation/request case with correct response schema
- Add respondError helper for JSON-RPC error responses
- Return proper JSON-RPC method-not-found error for unknown server
requests instead of silent empty object
- Add tests for MCP elicitation and unknown method handling
* fix: use cfg.Logger instead of global slog in codex handleServerRequest
Switch the unhandled-server-request warning from global slog.Warn to
c.cfg.Logger.Warn for consistency with all other log calls in codex.go.
This ensures the warning appears in daemon run-logs and per-task
pipelines where operators look during triage.
`onIssueLabelsChanged` patched the embedded `labels` field in the
issue list and detail caches but never touched `labelKeys.byIssue`,
the cache backing the issue-detail Properties LabelPicker. Mutations
already covered all three caches; WS-driven changes (agents, other
tabs) left the picker stale until remount, since `staleTime: Infinity`
plus `refetchOnWindowFocus: false` prevent recovery on focus.
When creating an issue with agent, the input content was lost when
navigating away (e.g., to view a ticket) and returning. Manual create
already persisted its draft - now agent create does too.
Changes:
- Add prompt field to useQuickCreateStore (persisted with workspace)
- AgentCreatePanel reads initial prompt from draft store if no transient
data.prompt is provided
- onUpdate now saves prompt to draft store (not just hasContent)
- clearPrompt() called after successful submit
Fixes: #1957
* feat(chat): support fullscreen mode similar to Linear
When the expand button is clicked, the chat window now fills the entire
content area (inset-0) instead of scaling to 90% of parent. Resize
handles are hidden in fullscreen mode.
* fix(chat): use stacked card layout for fullscreen mode
Fullscreen chat now uses inset-3 with rounded corners, ring, and shadow
to create a stacked card effect on top of the content area — matching
the Linear design — instead of a flush inset-0 fill.
* feat(chat): add motion.dev spring animations for expand/collapse
- Install `motion` in @multica/views
- Replace CSS transitions with motion.div layout animation for
expand/collapse (spring-based FLIP), giving a natural bouncy feel
- Open/close uses spring scale + smooth opacity fade
- Layout animations are disabled during drag-to-resize (instant updates)
* fix(chat): remove spring bounce from expand/collapse animation
Use critically damped springs (bounce: 0) so the animation settles
directly at its target without overshooting.
* fix(chat): fix text distortion during expand/collapse animation
Use layout="position" instead of layout (full FLIP). Full FLIP uses
scale transforms to animate size changes, which distorts text and
child content. Position-only layout animates translate only — size
changes are instant, text stays crisp.
* fix: regenerate lockfile with pnpm@10.28.2
The lockfile was previously generated with pnpm 10.12.4, causing
unrelated churn (lost libc constraints, deprecated metadata). Reset
to main and regenerated with the repo's pinned pnpm@10.28.2 so
the diff is scoped to the new motion dependency only.
* fix(daemon): remove Co-authored-by hook when workspace setting is off
The prepare-commit-msg hook is installed in the bare repo's shared
hooks dir, so once installed it persists across worktrees. CreateWorktree
only installed the hook when the setting was enabled, but never removed
it — so disabling the workspace toggle had no effect on subsequent
commits.
Add removeCoAuthoredByHook and call it in both CreateWorktree branches
when the setting is disabled. Use a marker comment in the hook script so
removal only deletes hooks the daemon owns; user-installed hooks at the
same path are left alone.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): recognize legacy Multica prepare-commit-msg hook on removal
The first cut of removeCoAuthoredByHook only recognized hooks installed
by the new code (containing the multicaHookMarker sentinel). Bare clones
already on disk from previous daemon releases carry the older script
without that line, so toggling the workspace setting off would have
treated them as user hooks and left the trailer in place — exactly the
state reported in MUL-1704.
Match against a list of known daemon signatures (current marker + the
legacy "Installed by the Multica daemon." comment), and add a test that
seeds the verbatim legacy hook before CreateWorktree(... disabled) to
keep recognition aligned with what production hosts actually have on
disk.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches
Desktop tab switches were emitting a $pageview every time the user clicked
between already-open tabs (or workspaces), since the tracker fired on any
change to the resolved active path. Real-data audit showed this was the
single largest source of PostHog quota burn — desktop accounted for 51% of
all $pageviews at ~34 pv/user/30d vs web's ~10 — and the re-emitted paths
add no signal because the original navigation already fired.
Detect "tab switch" as `(workspace, tabId)` identity changing while the
surface stays `tab`, and skip the capture in that case while still updating
the ref so the next in-tab navigation compares against the right baseline.
Login transitions, overlay open/close, and intra-tab navigation continue
to fire as before.
Co-authored-by: multica-agent <github@multica.ai>
* fix(analytics): only suppress $pageview for re-activations of known tabs
Prior commit suppressed every (workspace, tabId) change while the surface
stayed `tab`, which also swallowed the first $pageview for newly opened
tabs (`openInNewTab` / `addTab`) and for cross-workspace `switchWorkspace`
into a not-yet-seen tab.
Track an observed `(workspace, tabId) → path` map seeded from the
persisted tab store on mount. Suppress only when the active key is
already in the map AND its recorded path matches the current path —
i.e. genuine re-activation of an already-known tab. New tabs and
cross-workspace navigation to a fresh tab now correctly emit one
pageview.
Adds a vitest covering the three behaviors GPT-Boy flagged plus the
intra-tab navigation, overlay/login transitions, and persistence-restored
mount paths. Wires the `@/` alias into `vitest.config.ts` so component
tests can resolve renderer-relative imports.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(analytics): reuse tab-store helpers and inline observed-tabs seed
Replace the two ad-hoc tab selectors with the existing
`useActiveTabIdentity()` + `getActiveTab()` helpers from tab-store, which
already provide the (slug, tabId) primitive pair and the active tab
lookup with the same stability guarantees.
Move the observed-tabs Map seeding from a useEffect into a synchronous
first-render initializer. The seed runs once per mount before any
state-driven effect, so the previous useEffect-then-defensive-fallback
pattern in the second effect was unreachable.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix: handle square brackets in agent names for mention parsing (#1991)
The mention regex used [^\]]* to match labels, which broke when agent
names contained square brackets (e.g. David[TF]). The ] inside the name
caused the regex to stop matching prematurely, silently dropping the
mention.
Changes:
- Backend (mention.go): Switch to .+? (non-greedy) anchored on
](mention:// to correctly match labels with brackets
- Frontend (mention-extension.ts): Same regex fix in tokenizer, plus
escape [ and ] in renderMarkdown to prevent creating ambiguous
markdown syntax
- Add comprehensive tests for ParseMentions covering bracket names
Fixes#1991
* fix: add optional chaining for match group access
Fixes TS2532: Object is possibly 'undefined' on match[1] when calling
.replace() in the mention tokenizer.
* fix: tighten mention tokenizer to reject ordinary Markdown links
- Replace .+? with (?:\\.|[^\]])+ in start() and tokenize() regexes
so the label cannot cross a ]( Markdown link boundary
- Escaped brackets (\[ \]) from renderMarkdown() are still accepted
- Add frontend tokenizer/serializer round-trip tests:
- Plain mention
- Escaped brackets (David[TF]) round-trip
- Normal Markdown link + mention on same line (regression)
- Multiple links before mention
- Nested brackets (Bot[v2][beta])
- Issue mentions without @ prefix
Addresses review feedback on #1992.
* fix: add type assertions for tiptap MarkdownTokenizer interface in tests
The tiptap MarkdownTokenizer type allows start to be string | function
and tokenize to accept 3 arguments. Our extension always provides
single-arg functions, so cast them for TypeScript satisfaction.
Fixes CI typecheck failure in @multica/views package.
* fix: cast renderMarkdown to single-arg shape and reset file modes to 0644
Folds together everything that landed since the last public changelog
entry (0.2.21) into one 0.2.24 release note: repo checkout --ref,
agent avatar CLI, Hermes per-turn gate, multi-replica model picker
on Redis, Inbox long-timeline perf, and the rest of the smaller fixes
queued for tonight's release.
en.ts and zh.ts both updated.
Co-authored-by: multica-agent <github@multica.ai>
TestRegisterTaskReposSurvivesWorkspaceRefresh started flaking on CI
after #1988 (`feat: support repo checkout ref selection`) extended the
bare-clone path to run an extra `git fetch` to backfill
refs/remotes/origin/* under the new refspec layout. The race was
already latent: registerTaskRepos kicks off `go syncWorkspaceRepos(...)`
to clone a repo into the cache root, which in tests is `t.TempDir()`.
Once the test waited on `repoCache.Lookup` to return a path it would
proceed and return — but the bg goroutine was still inside
`ensureRemoteTrackingLayout` running git operations on the clone dir.
`t.TempDir`'s cleanup then races with those git commands and surfaces
either as "directory not empty" or "fatal: cannot change to ... No such
file or directory", with no hint that the failure is unrelated to the
test's actual assertion.
Track the background goroutine on the Daemon via a sync.WaitGroup and
expose `waitBackgroundSyncs()` for tests. `newRepoReadyTestDaemon`
registers a t.Cleanup that calls it, so every test that uses the
helper now drains in-flight syncs before t.TempDir cleanup runs. No
production-behavior change — registerTaskRepos still fires-and-forgets
from the caller's perspective.
Verified with `go test ./internal/daemon -run
TestRegisterTaskReposSurvivesWorkspaceRefresh -count=30` (was failing
within ~10 iterations before, 30 green after) and the full
`go test ./internal/daemon/...` suite.
Co-authored-by: multica-agent <github@multica.ai>
* perf(issue-detail): memoize timeline render to fix Inbox long-timeline freeze
On long-timeline issues (thousands of comments), opening from Inbox hard-freezes
the browser tab because every WS-driven parent re-render re-runs the full
react-markdown + rehype-* + lowlight pipeline for every comment. This is the
S3 mitigation for multica#1968:
- Wrap ReadonlyContent in React.memo so equal-content re-renders skip the
markdown pipeline entirely (the dominant cost per comment).
- Wrap CommentCard in React.memo so unrelated parent state updates don't
re-render every card.
- useMemo the timeline grouping in IssueDetail so the allReplies Map and
groups array references are stable across re-renders that don't change
timeline.
- Stabilize toggleReaction via a timelineRef so its identity doesn't change
on every WS event, which previously defeated CommentCard memoization.
Virtualization (S2) is the root fix for first-paint cost and lands separately.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issue-detail): destructure mutate/mutateAsync so CommentCard memo holds
Per review on PR #2025: TanStack Query v5 returns a fresh result wrapper
from useMutation on every render, with only the inner mutate / mutateAsync
functions guaranteed stable. The previous useCallback dependencies listed
the whole mutation object, so on every parent re-render the callbacks
flipped identity — defeating React.memo on CommentCard and leaving the
long-timeline mitigation only half-effective.
Pull just the stable handles into deps. Add a renderHook-based regression
test that re-renders useIssueTimeline twice and asserts the four callbacks
passed to CommentCard keep the same identity.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Hermes ACP can flush queued session updates from the previous turn
before the current turn actually starts — both as session/resume
history replay and as chunks queued before our session/prompt response
streams. Without a gate those updates were appended to output and
re-emitted to the UI, so the previous answer appeared duplicated next
to the new one. Closes#1997.
PR #1789 added the acceptNotification hook field to hermesClient and
the call site in handleNotification, but never assigned it for Hermes,
so the guard short-circuited and every notification was processed.
This change mirrors the working Kiro pattern (kiro.go:87/97/240):
- declare a streamingCurrentTurn atomic.Bool in the backend.
- assign acceptNotification, onMessage, onPromptDone gates that all
return early when the flag is false.
- flip the flag to true immediately before c.request("session/prompt").
Adds TestHermesClientAcceptNotificationGate as a regression test that
exercises the gate directly on hermesClient.
Verified with `go test ./pkg/agent`.
Co-authored-by: multica-agent <github@multica.ai>
Follow-up nits from PR #1988 review:
- Move the comment that documents getRemoteDefaultBranch's resolution
walk into the resolveBaseRef call site description, and rephrase the
"" branch so it's clear that path only fires for the default-branch
case (the requested-ref path returns an explicit error before
reaching it).
- Add TestCreateWorktreeWithRequestedTagRef to lock in the
refs/tags/<ref> candidate. The test tags the initial commit, advances
the default branch past it, then asserts the worktree HEAD matches
the tagged commit (so the tag must have been resolved, not the
default branch).
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): persist ModelListStore across replicas via Redis
The model picker uses a pending-request pattern: the frontend POSTs to
create a request, the daemon pops it on its next heartbeat, runs
agent.ListModels locally, and reports back. Until now the store was a
plain in-memory map per Handler instance.
That works for self-hosted single-instance deploys but fails in any
multi-replica environment (Multica Cloud). Each replica has its own
map, so:
POST /runtimes/:id/models → request stored in replica A
GET /runtimes/:id/models/<requestId> → polls land on B/C → 404
daemon heartbeat → only A sees PendingModelList
POST .../<requestId>/result → daemon's report has to land on A
Success probability ~1/N². The visible symptom is "No models available"
in the picker for every provider, even those (Claude/Codex) whose
catalog is statically populated end-to-end.
Same shape of bug, same Redis-backed fix as multica-ai/multica#1557 did
for LocalSkillListStore / LocalSkillImportStore. Reuse the operational
playbook (namespaced keys, ZSET-backed pending queue, atomic
ZREM+SET-running via the shared Lua script) so we don't introduce a
second concurrency model for the same primitive.
Changes:
- Convert ModelListStore from struct to interface with context-aware
methods. Add HasPending for cheap heartbeat-side probing.
- InMemoryModelListStore — single-node fallback, used when REDIS_URL
is unset (self-hosted dev / tests).
- RedisModelListStore — multi-node implementation using the same key
layout and Lua atomic claim as RedisLocalSkillListStore.
- Use RunStartedAt (not UpdatedAt) as the running-timeout reference
point, matching the local-skill stores so subsequent UpdatedAt
bumps don't reset the running clock.
- Heartbeat now uses the probe-then-pop pattern for the model queue
(matching local-skills) so a slow Redis can't stall every connected
daemon. Extends heartbeatMetrics + slow-log with probe_model_ms /
pop_model_ms / probe_model_timed_out for parity.
- Wire the Redis backend in NewRouterWithOptions when rdb != nil.
- Tests for both backends. Redis tests gate on REDIS_TEST_URL so
laptop runs without Redis still pass; CI provides it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): persist RunStartedAt + retry model report on transient failures
Two follow-ups from PR #2022 review:
1. RedisModelListStore was dropping ModelListRequest.RunStartedAt on
persistence — the field is tagged json:"-" so it doesn't leak into
the HTTP response, which made plain json.Marshal(req) silently
discard it. Across-node readers saw RunStartedAt=nil and
applyModelListTimeout's running branch became a no-op, so the 60s
running-timeout escape hatch never fired. CI's
TestRedisModelListStore_RunningTimeout was failing on this exact
case. Fix mirrors RedisLocalSkillImportStore's envelope pattern —
wrap in an internal struct that re-promotes the field. HTTP shape
stays clean. Adds a no-Redis unit test that pins the round trip.
2. Daemon's handleModelList called d.client.ReportModelListResult
directly and swallowed any 5xx, leaving the pending request
stranded in "running" until its 60s server-side timeout — exactly
the failure mode the multi-node store fix was meant to eliminate.
Generalize the existing local-skill retry helper into
reportRuntimeResultWithRetry (kind: model_list / local_skill_list /
local_skill_import) and wire handleModelList through a new
reportModelListResult helper. Renames the test-overridable
var localSkillReportBackoffs → runtimeReportBackoffs to match.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Latest Codex CLI ships with GPT-5.5 / GPT-5.5 mini, but the static
catalog still topped out at GPT-5.4 so users couldn't pick the new
model from the agent picker.
Add gpt-5.5 + gpt-5.5-mini to codexStaticModels and promote 5.5 as
the default badge. Keep the older 5.4 / 5.3-codex / gpt-5 / o3
entries for users on older Codex CLI builds. Add a regression test
mirroring TestGeminiStaticModelsExposesAliasesAndGemini3 so the
next OpenAI release isn't a silent miss.
Co-authored-by: multica-agent <github@multica.ai>
- Expand chat-resume comment in ClaimTaskByRuntime to spell out *why* the
task-row fallback exists (single failed turn must not drop chat memory)
and that it covers more than just legacy NULL rows.
- Replace the sessionRuntimeID := t.RuntimeID; sessionRuntimeID.Valid = ...
pattern in CompleteTask/FailTask with a clearer var-then-assign that makes
the "no session_id, leave runtime_id alone" coupling obvious.
- Add TestClaimTask_ChatLegacyNullRuntimeFallsBackToTaskRow covering the
case the prior PR's tests didn't reach: chat_session.runtime_id IS NULL
(legacy / unbackfilled) plus a matching-runtime task row, fallback
should resume. This is the dominant post-migration shape and was
previously only covered transitively.
No behavior change beyond the new test; runtime-guard semantics stay
identical to PR #1905.
Co-authored-by: multica-agent <github@multica.ai>
Copilot's backend (server/pkg/agent/copilot.go) and the public docs
site (apps/docs/) already treat it as one of the 11 supported agents,
but the root README, CLI guide, and self-host docs still listed only
10. Bring those to parity. Also brings README.zh-CN.md up to current
English content (was missing Copilot, Kimi, and Kiro CLI).
* fix(cli): make `multica login --token` accept the PAT as a value
The flag was registered as a Bool, so `multica login --token <PAT>` parsed
`--token` as `true` and dropped the supplied value as an unused positional
argument, then unconditionally prompted "Enter your personal access token:".
This contradicted the user-facing docs (`cli.mdx`, `CLI_AND_DAEMON.md`,
the in-app `connect-remote-dialog`) which show `--token <mul_...>`.
Switch `--token` to a String flag. Both `--token mul_...` and
`--token=mul_...` now bind the value and skip the prompt. Passing
`--token=` with an empty value (or `multica login --token=""`) still
falls through to the interactive prompt for users who don't want the
token in shell history. Updates the few internal docs that showed the
no-value form.
Fixes#1994
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): preserve `multica login --token` (no value) prompt path and tighten regression test
Addresses review feedback on #2017:
1. Restore the legacy no-value form. After the prior commit, `multica
login --token` (no value) errored with `flag needs an argument:
--token`, which broke the CLI_INSTALL.md / CLI_AND_DAEMON.md flow for
headless users. Set `NoOptDefVal` on the `--token` flag to a sentinel
that runAuthLoginToken treats as "prompt me," so:
- `--token mul_xxx` and `--token=mul_xxx` consume the value (the
#1994 fix is preserved),
- `--token` alone falls through to the interactive prompt,
- `--token=""` (explicit empty) also prompts.
pflag with `NoOptDefVal` won't bind the next positional as the flag's
value, so runAuthLogin recovers `--token mul_xxx` (the form from
#1994) by promoting a single positional arg into the token. loginCmd
gains `Args: cobra.MaximumNArgs(1)` so multi-positional typos still
error fast.
2. Tighten regression coverage. Split into TestLoginTokenFlagWiring
(asserts the production loginCmd.Flags().Lookup("token") is a String
flag with the prompt-mode NoOptDefVal — would fail if anyone reverts
the flag to Bool) and TestLoginTokenFlagParsing (drives all five
documented invocation forms through the same flag wiring + the
runAuthLogin space-form recovery). The synthetic-only test that the
reviewer flagged is gone.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add UploadFileWithURL and AttachmentResponse to APIClient
* feat(cli): add agent avatar command and show avatar_url in agent get output
* fix(server): include id and url in no-workspace file upload response
* fix(cli): remove dead HTTPClient timeout swap, extend ctx to 60s for avatar upload
The 30s context deadline was tighter than the 60s HTTPClient timeout
swap, so the swap was dead code and did nothing for slow connections.
Both Neo and Omni Mentor flagged this in review.
Fix: extend the command context to 60s and remove the HTTPClient
mutation. This is simpler, thread-safe, and actually works for slow
uploads.
* fix: align fallback upload response shape and honor context deadline
- file.go: fallback returns {id, url, filename} instead of {filename, link},
matching the no-workspace path response shape.
- client.go UploadFileWithURL: tolerate empty attachment ID (S3 succeeded
but DB record failed — the file is still usable via its URL).
- client.go UploadFileWithURL: use a context-deadline-aware HTTP client so
that the 60s upload timeout set by the avatar command actually takes
effect instead of being shadowed by the default 15s client timeout.
- client_test.go: update 'missing id' test to verify empty-id success
(fallback tolerance).
* fix(cli): shallow-copy HTTP client to preserve Transport on upload timeout
When the context deadline exceeds the default 15s HTTP client timeout,
UploadFileWithURL was creating a bare &http.Client{Timeout: remaining},
silently dropping any custom Transport, Jar, or CheckRedirect configured
on the original client. This causes obscure connection failures when the
CLI uses an authenticated proxy, custom TLS, or mock transport in tests.
Fix: perform a shallow copy of the original client struct and only
mutate the Timeout field on the copy.
* fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors
TestRegisterTaskReposAllowsProjectOnlyURL and
TestRegisterTaskReposSurvivesWorkspaceRefresh fail on GitHub Actions CI
because git clone --bare from local temp directories triggers git's
safe.directory ownership check when the runner UID differs from the
directory owner.
Set safe.directory=* via GIT_CONFIG env vars in gitEnv() so all daemon
git subprocesses trust any directory. The daemon manages its own bare
caches and worktrees, so the ownership check provides no security value.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): preserve existing GIT_CONFIG_* entries in gitEnv
Instead of resetting GIT_CONFIG_COUNT to 1, read the existing count
from the environment and append safe.directory at the next available
index. This preserves any env-scoped git config (auth, URL rewrites,
extra headers) injected into the daemon process.
Adds TestGitEnvPreservesExistingConfig to verify the append behavior.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Dragging an issue between kanban columns was forcefully switching the
sort mode to "position" (manual), resetting any user-chosen display
settings like sorting by title. Remove the auto-switch so the sort
preference is preserved across drag operations.
Fixesmultica-ai/multica#1960
Co-authored-by: multica-agent <github@multica.ai>
Remove the "mark as done" hover button from inbox list items since it
duplicates the one in the issue detail header. For done tasks, show an
archive button in the issue detail header instead.
Co-authored-by: multica-agent <github@multica.ai>
Switch Autopilot list rows to a stacked layout below the sm breakpoint,
hide desktop column headers on mobile, and match loading skeletons to
the mobile row shape. Desktop table layout is preserved at sm and above.
Closes MUL-1653
Co-authored-by: multica-agent <github@multica.ai>
The previous description rule ("stay faithful + keep it concise") caused
agents to over-compress user input into vague single-sentence summaries,
losing context that the executing agent needs.
Key changes:
- Replace "keep it concise" with structured two-section format:
User request (faithful restate) + Context (verifiable external facts)
- Add hard rules against information compression and semantic downgrading
- Remove "one-line description" phrasing (UI supports richer input)
- Strip redundant behavioral rules from issue_context.md (already
covered by AGENTS.md guardrails and per-turn prompt)
Co-authored-by: multica-agent <github@multica.ai>
PR #1868 conflated "has workspace" with "completed onboarding" —
restore `onboarded_at` as the single signal, and route invited users
through a dedicated /invitations page before they ever see onboarding.
- Backend: CreateWorkspace + AcceptInvitation atomically set
onboarded_at alongside the member insert, establishing the
invariant "member row exists ↔ onboarded_at != null" at the DB
layer.
- Migration 065: one-shot backfill closes the dirty rows produced
by PR #1868 (users with a workspace but onboarded_at == null).
- Entry points (web callback, login, desktop App): if onboarded_at
is null, look up pending invitations by email and route to the
new batch /invitations page; otherwise the resolver picks
workspace / new-workspace as before.
- OnboardingPage: stops bouncing on hasWorkspaces; only
hasOnboarded bounces. Unblocks the user from completing
Step 3 (workspace creation) → Steps 4 / 5.
- StarterContentPrompt: only shows when the user is the solo
member of the workspace, so invited users never get prompted to
import starter content into someone else's workspace.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(daemon): Redis empty-claim fast path for /tasks/claim polling
Daemons poll /tasks/claim every 30s per runtime; the steady-state
warm-empty case currently runs ListPendingTasksByRuntime against
Postgres on every poll. This collapses that path:
- New ListQueuedClaimCandidatesByRuntime query restricts to status =
'queued' (the old query also returned 'dispatched' rows that can
never be reclaimed) and is backed by a partial index keyed on
(runtime_id, priority DESC, created_at ASC).
- New EmptyClaimCache caches the negative verdict in Redis with a
30s TTL. ClaimTaskForRuntime checks the cache before SELECT and
populates it on confirmed-empty results.
- notifyTaskAvailable now invalidates the runtime's empty key before
kicking the daemon WS, so newly enqueued tasks become claimable
immediately rather than waiting out the TTL.
- AutopilotService.dispatchRunOnly now goes through
TaskService.NotifyTaskEnqueued so run_only tasks get the same
invalidate-then-wakeup contract as every other enqueue path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): close MarkEmpty/Bump race in empty-claim fast path
GPT-Boy's review on PR #1860 caught a real concurrency bug. Under the
prior implementation it was possible for a slow claim to write an
empty verdict AFTER a concurrent enqueue had already invalidated it:
T1 claim: SELECT -> empty
T2 enqueue: INSERT row, DEL empty key (no-op, key not set yet),
wakeup
T1 claim: SET empty (writes a stale "empty" verdict)
T3 wakeup: IsEmpty -> hit -> returns null
The just-queued task would then sit idle until the empty key's TTL
expired (up to 30s).
Replace the DEL-based invalidation with a per-runtime version
counter:
- CurrentVersion(rt) is a Redis INCR counter at
mul:claim:runtime:version:<rt> with a 24h sliding TTL.
- Claim samples version BEFORE the SELECT and passes it to MarkEmpty,
which stores the verdict's value as the observed-version string.
- IsEmpty MGETs both keys and trusts the verdict only when the
empty-key value equals the current version.
- Enqueue Bumps the version (INCR + EXPIRE) before the wakeup,
causing any verdict written under a prior version to be rejected
on the next read.
Also bound every Redis call from this cache with a 250ms timeout —
notifyTaskAvailable uses a background context so a wedged Redis
must not block enqueue.
Tests against a real Redis (REDIS_TEST_URL) cover:
- MarkEmpty + IsEmpty under matching version returns hit
- Bump invalidates a prior empty verdict (race-fix pin)
- A MarkEmpty written under a stale pre-Bump version is rejected
- TTL clamping, per-runtime isolation, nil-cache safety
- notifyTaskAvailable Bumps before the wakeup fires
Co-authored-by: multica-agent <github@multica.ai>
* chore(daemon): renumber claim-candidate index migration to 067
Slot 064 was taken on main by 064_notification_preference. The
migration runner tracks per-version in schema_migrations and would
silently skip the second 064_*, leaving the index uncreated.
Rename to 067 (next free slot).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The `POST /api/issues/batch-update` handler walked every issue ID and
incremented `updated` regardless of whether the iteration carried any
mutation. When the caller's payload had no recognized field in
`updates` — e.g. status placed at the top level instead of nested,
"update" misspelled as singular, or "updates" missing entirely —
the loop ran N no-op UPDATEs (each if-guard skipped, each COALESCE
preserved the existing value) and the response cheerfully reported
`{"updated": N}` while nothing changed. Reporters mistook the
positive count for success and chased a phantom persistence bug.
Detect at the top of the handler whether any known mutation field is
present in the parsed `updates` payload; if none is, short-circuit
with `{"updated": 0}`. The wire shape stays 200 + `{updated}`
so existing callers don't break — only the count becomes truthful.
Tests cover the three caller shapes that hit this path (status at top
level, empty `updates: {}`, misspelled "update") plus a positive
case that locks in happy-path persistence and counting.
Closes#1660.
* fix(daemon): reclaim disk on long-open issues + correct cancelled-status check
Two related fixes for GitHub #1890 (self-hosted disk space growth):
- The GC's done/cancelled branch compared `status.Status` against `"canceled"`
(single l), but the issue schema and the rest of the daemon use `"cancelled"`
(double l). Cancelled issues therefore never matched and only fell out via the
72h orphan TTL, which itself doesn't fire because cancelled issues are still
reachable. Aligning the spelling lets cancelled-issue task dirs be reclaimed
on the normal TTL path.
- Add a third GC mode, artifact-only cleanup, for the common case the report
flagged: an issue stays open for days while many tasks complete on it, so
per-task `node_modules`, `.next` and `.turbo` directories accumulate without
ever becoming GC-eligible. The new branch fires when `.gc_meta.completed_at`
is older than `MULTICA_GC_ARTIFACT_TTL` (default 12h), the env root is not
currently in use by an active task, and the issue is still alive. It removes
only directories whose basename matches `MULTICA_GC_ARTIFACT_PATTERNS`
(default narrow: `node_modules,.next,.turbo`); source, `.git`, `output/`,
`logs/` and the meta file are preserved so subsequent tasks can still resume
the workdir. Patterns containing path separators are dropped, `.git` subtrees
are never descended into, symlinked matches are not followed, and every
removal target is verified to live inside the task dir.
Bookkeeping: `Daemon` now tracks active env roots with a refcounted set so the
GC loop never reclaims a directory that is mid-execution; `runTask` claims the
predicted root early plus the prior workdir on reuse paths. The cycle log is
extended with bytes reclaimed and per-pattern counts so self-hosted operators
can see what was freed.
Docs: extend the daemon configuration table in CLI_AND_DAEMON.md with the new
GC env vars and add a Workspace garbage collection section explaining the
three modes and the artifact-pattern contract.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): protect active env root from full GC removal too
Address GPT-Boy's PR #1931 review: the active-root guard only fired in the
artifact-cleanup branch, leaving a real race on the full-removal paths. A
follow-up comment on a long-done issue dispatches a task that reuses the prior
workdir, but `CreateComment` does not bump issue.updated_at — so the issue
still satisfies the done+stale GCTTL window and `gcActionClean` would
`RemoveAll` the directory mid-execution. The orphan-404 path is similarly
exposed when a token's workspace access is in flux.
Move the `isActiveEnvRoot` check to the top of `shouldCleanTaskDir` so all
three delete actions (clean, orphan, artifact) skip an in-use env root in one
place, and drop the now-redundant guard from the artifact branch.
Add tests covering the three at-risk paths: active root + done/stale issue,
active root + 404 issue past orphan TTL, active root + no-meta orphan past
TTL.
Also align two stale comments noted in the same review: cleanTaskArtifacts now
documents that symlinks are skipped entirely (the previous note implied the
link itself was removed), and GCOrphanTTL no longer claims that 404s are
cleaned immediately — the implementation gates them on the same TTL.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The toolbar button was previously visible on all issue detail views.
Gate it on the `onDone` prop, which is only passed from InboxPage.
Co-authored-by: multica-agent <github@multica.ai>
Sync the "Why Multica?" content from the landing page About section
into both README.md and README.zh-CN.md, explaining the name's
connection to Multics and the multiplexing philosophy.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(repos): drop unused description + tighten create-project layout
Two related changes that touch the workspace-repos surface together.
1. Remove the per-repo `description` field everywhere it was threaded.
The only place it ever surfaced was a markdown table column the daemon
wrote into the agent runtime config, where most rows just read "—"
anyway. Agents already discover project structure by running
`multica project` / `multica issue` against the CLI, so the human-
readable description string carried no real value while taking up an
extra Settings input row and propagating through six layers (settings
UI → workspace.repos jsonb → handler RepoData → daemon RepoData →
repocache.RepoInfo → execenv.RepoContextForEnv).
- Settings → Repositories drops the description input; the URL field
now spans the whole row.
- WorkspaceRepo TS type loses `description`; backend RepoData /
RepoInfo / RepoContextForEnv all collapse to URL only.
- Daemon's runtime_config Repositories block changes from a
`| URL | Description |` markdown table to a simple bullet list.
- Tests updated; jsonb residue in existing workspaces is dropped at
normalize time, so no migration needed.
2. Tighten the Create Project modal footer: pull the Status / Priority /
Lead / Repos pills onto the same row as the Create Project button
(Linear-style single-row footer) instead of stacking them above it,
and swap the Repos pill icon from `FolderGit` to a real GitHub mark
(lucide-react v1 dropped brand icons, so the mark lives inline as a
small SVG component in this file).
I tried promoting Repos to its own "Resources" strip above the footer
to separate the resources abstraction from project metadata, but with
a single pill it looked too sparse — leaving a TODO comment in the
footer to revisit once we add Linear / Notion / Figma / Slack
resource types.
* fix(daemon test): drop residual Description field on RepoData literals
* fix(repos): drop Description residue surfaced after rebase on #1929
Project-resource github_repo lift path (#1929) and registerTaskRepos
both still constructed RepoData{...Description: ...} after the rebase.
Two test sites in daemon_test.go and execenv_test.go also reintroduced
the field. Strip them so the Description-removal change builds and
tests pass with the latest main.
* feat(projects): project github_repo resources override workspace repos
When an issue's project has at least one github_repo resource, the daemon
claim handler now sends only those as resp.Repos — workspace-level repos
are hidden to avoid mixing two repo lists in the agent prompt. With no
project github_repos (or no project), behavior is unchanged: workspace
repos are surfaced as before.
Lifts each project github_repo's url (and label, when present) into a
RepoData entry so `multica repo checkout` and the meta-skill render the
same URLs. The full structured list still ships at
.multica/project/resources.json for skills that want everything.
Adds TestProjectReposReplaceWorkspaceReposInMetaSkill covering the
rendering side. Docs updated to spell out the new precedence.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): allow project repo URLs through the checkout allowlist
When ClaimTaskByRuntime narrows resp.Repos to project github_repo URLs,
the daemon receives URLs that may not exist in the workspace's
GetWorkspaceRepos response. The existing checkout flow rejected those
with ErrRepoNotConfigured because the allowlist (and cache) was built
only from workspace-bound repos.
Adds registerTaskRepos in daemon.runTask: before agent spawn, merge
task.Repos into a new task-scoped allowlist (separate from the
workspace-scoped one so a workspace refresh doesn't wipe project URLs)
and kick off a background cache sync. ensureRepoReady now treats either
allowlist as valid.
Tests:
- TestRegisterTaskReposAllowsProjectOnlyURL — project-only URL is
checkout-able and does not trigger a workspace-repos refresh
- TestRegisterTaskReposSurvivesWorkspaceRefresh — task URLs persist
across refreshWorkspaceRepos
- TestClaimTask_ProjectGithubReposOverrideWorkspaceRepos — claim
handler returns only project repos when present, no workspace leakage
- TestClaimTask_ProjectWithoutRepos_FallsBackToWorkspaceRepos — fall
back to workspace repos when project has no github_repo resources
Docs updated to spell out the daemon-side allowlist behavior.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
A sandbox="" iframe cannot run scripts, so users had no way to zoom or
pan rendered Mermaid diagrams beyond browser scrolling. Add a hover
toolbar with a fullscreen button that opens a portal-based lightbox
showing the same diagram scaled to 90vw x 90vh, while preserving the
sandbox isolation (the lightbox iframe is also sandbox=""). ESC or
clicking the backdrop closes the lightbox.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): rerun starts a fresh session, skip poisoned resume
When a task ended in a known agent fallback ("I reached the iteration
limit and couldn't generate a summary.", "Put your final update inside
the content string. Keep it concise.") the (agent_id, issue_id) resume
lookup would still pick that session, so a manual rerun inherited the
poisoned state and reproduced the same bad output.
Two complementary guards:
1. Daemon classifies poisoned terminal output and routes it through the
blocked path with failure_reason set ('iteration_limit' /
'agent_fallback_message'). GetLastTaskSession excludes failed tasks
with those reasons, so even comment-triggered tasks no longer resume
them. Tasks that failed mid-flight (timeout, runtime_recovery, etc.)
are still resumable, preserving MUL-1128's auto-retry contract.
2. Manual rerun marks the new task force_fresh_session=true. The daemon
claim handler skips the resume lookup entirely when the flag is set,
capturing the user-intent signal that "the prior output was bad" even
when poisoned classification misses a future fallback wording.
Auto-retry of orphaned mid-flight failures (MaybeRetryFailedTask →
CreateRetryTask) does not take this path, so it keeps resuming.
Tests: classifyPoisonedOutput unit test; integration tests assert the
SQL filter excludes poisoned classifiers, RerunIssue flips the flag,
and the normal enqueue path leaves it false.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): cap poisoned-output matcher to short trimmed text
GPT-Boy review on MUL-1630: the previous strings.Contains match would
classify any output that quoted the marker substring — including a
review/analysis that simply discussed the marker itself. Real fallback
messages are short single-sentence affairs, so cap the candidate at
~one paragraph and trim whitespace before matching. Adds regression
tests covering a long quoting review and a marker buried in a long
real conclusion; both must stay classified as completed.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): rename 065 force_fresh_session → 066 to clear collision
main introduced 065_project_resources after this branch was cut, so
both files shared the 065_ prefix. The readiness check
(server/cmd/server/health.go → migrations.LatestVersion) takes the
last entry by lexical order, which is 065_project_resources, leaving
this branch's 065_force_fresh_session unguarded — a deploy that
applied project_resources but not force_fresh_session would still
report ready, and the next enqueue / rerun / claim would crash on
"column force_fresh_session does not exist".
Renaming to 066_force_fresh_session puts it strictly after
project_resources so readiness blocks until it's applied.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): typed project resources + agent runtime injection
Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.
Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks
Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
via type-dispatched formatter so new resource types just add a case
CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove
Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove
Docs
- New "Project Resources" chapter explaining the abstraction and
exactly what code to touch when adding a new resource type
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): transactional resources[] on create + generic CLI ref + test fix
Addresses review feedback on PR #1926:
1. CI red: TestProjectResourceLifecycle delete step called withURLParam
twice, which replaced the chi route context and dropped the project id.
Switched to the existing withURLParams helper from daemon_test.go.
2. POST /api/projects now accepts resources[] and attaches them in the
same transaction as the project. Invalid refs roll back the whole
create — no more half-attached projects on failure. Web modal + CLI
`project create --repo` both use the new bundled payload.
3. CLI `project resource add` now accepts a generic --ref '<json>' flag
so a new resource_type works without a CLI change. Per-type
shortcuts (--url for github_repo) remain as a convenience but are no
longer the only way in. Docs updated to drop the CLI from the
"files you must touch" list.
Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The agent runs the daemon CLI, so issue.creator_type is `agent` and the
issue:created event listener only auto-subscribes the agent — not the
human requester. Result: the requester gets a single completion inbox
item but never sees follow-up comments or updates on their own issue.
Subscribe the requester (reason=`creator`, the only matching value
allowed by issue_subscriber's CHECK constraint without a migration)
inside notifyQuickCreateCompleted, after the issue lookup succeeds and
before the inbox write. Best-effort: log on failure, don't block the
inbox. On success, publish subscriber:added so the UI stays in sync
with manual subscribe and the listener-driven path.
Adds two integration tests in cmd/server: success path subscribes the
requester; failure path (agent finished without creating an issue)
leaves no subscriber rows.
Co-authored-by: multica-agent <github@multica.ai>
PropRow switched to CSS subgrid in #1919, which requires its parent to
declare grid columns. The Properties section's wrapper was updated, but
Details and Token usage in the same file were missed — their PropRows
collapsed to a single column, stacking label and value vertically.
Add the same `grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5` wrapper used
by Properties so all three sections render consistently.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): refresh agent cache after import and agent creation
Two paths could leave the workspace agent-list query cache stale by the
time the dashboard rendered the welcome issue, causing the issue's
agent assignee to resolve to "Unknown Agent":
1. StarterContentPrompt.onImport invalidated pins/projects/issues but
not agents, and didn't await any of them before navigating — so the
issue-detail page could mount and read the cache before TanStack
Query had marked the relevant queries stale.
2. OnboardingFlow.handleAgentCreated created the agent without
invalidating the agent list, so the dashboard's first mount would
read whatever was already cached from earlier in onboarding.
Both now invalidate workspaceKeys.agents, and the import flow awaits
all invalidations via Promise.all before pushing the navigation, so
the next page mount always refetches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(editor): drop editable prop, ContentEditor is editing-only
ContentEditor's `editable` prop had zero true callsites left in the
codebase — every read-only surface had migrated to ReadonlyContent
(react-markdown), and the prop only invited misuse: Tiptap's
`useEditor` reads `editable` at mount, so callers that toggled it
post-mount (like a chat input that needs to disable on no-agent)
silently got stuck in whichever mode the editor first created.
Changes:
- Remove `editable` prop and default; useEditor and createEditorExtensions
no longer take it.
- Remove the `"readonly"` className branch and the readonly content sync
useEffect (only the editing path remains).
- Remove the BubbleMenu and mouseDown editable guards.
- Drop LinkReadonly; rename LinkEditable to LinkExtension and use it
unconditionally.
- Update the docstring to point readers at ReadonlyContent for display
surfaces.
ReadonlyContent's `.readonly` CSS class stays in content-editor.css —
that file's selectors are still used by react-markdown's wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat): empty-state by session history, no-agent disabled state
Three independent improvements to the chat window's pre-conversation
states, sharing a new three-state availability primitive:
1. New `useWorkspaceAgentAvailability()` hook (`"loading" | "none" |
"available"`) so callers don't have to reinvent the loading-vs-empty
distinction. Treating loading as "no agent" — the easy mistake —
caused the chat input to flash a fake disabled state for the few
hundred ms after mount, even when the workspace had agents.
2. EmptyState now branches on session history, not agent presence:
never-chatted users get a short pitch ("They know your workspace —
issues, projects, skills"), returning users get the existing
starter prompts. Missing-agent feedback moved to the banner above
the input, keeping this surface focused on "what is chat for".
3. No-agent disabled state: when availability resolves to "none",
ChatInput dims and stops responding to clicks/keys, with cursor
`not-allowed` on hover. The disable lives at the wrapper level
(`pointer-events-none` on the inner card, `cursor-not-allowed` on
the outer one — splitting layers so hover bubbles to where the
browser reads cursor) — we no longer reach into the editor's
editable mode, which never switched cleanly post-mount anyway.
A `<NoAgentBanner>` (sibling of OfflineBanner, mutually exclusive)
states the prerequisite without linking out — no one should be
pulled out of chat mid-thought to a settings page.
Also: default chat width 420 → 380, since the chat docks at the
bottom-right and 420 was crowding everything else.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(views): align PropRow labels using CSS subgrid
The fixed `w-16` (64px) label column on PropRow broke whenever a label
rendered wider than 64px (e.g. "Concurrency" in the agent inspector) —
the label would overflow into the gap and collide with the value.
Switch to subgrid: the parent declares `grid grid-cols-[auto_1fr]` and
each PropRow becomes `col-span-2 grid grid-cols-subgrid`. The `auto`
track sizes to the widest label across all rows in that parent, so
labels always fit and value columns stay aligned across rows without
picking a magic pixel width.
Updated parents:
- agent-detail-inspector Section wrapper
- issue-detail Properties group
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(permissions): add core permission module and shared UI primitives
Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.
- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
reason + message so UI can render disabled state, tooltip, and banner
copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
admins can edit Y" banner shown on agent / skill detail when current user
lacks edit permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces
Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.
Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
concurrency picker, skill-attach) with read-only display when canEdit is
false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit
Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
(false) with "Only you and workspace admins can assign" via shared
VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
owns
Comments
- Restore admin override on comment edit/delete (backend already permits
it via comment.go:507-512; the frontend was incorrectly hiding the menu).
canModerate is computed once in issue-detail and threaded down.
Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
issues — by-design semantics, made transparent
Backend is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): hide personal agents from list and @mention for non-owners
Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.
This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).
- agents-page: visibleInView layer between active/archived and Mine/All
scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
ranked list; expand the test mock to cover the auth + visibility paths
and add two assertions (member hides others' personal agents; admin
still sees them)
Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three signal axes (color / label tiers / per-tool spinner) collapsed
into one (label only):
- Drop 60s amber warning color and 300s cancel-button threshold. The
cancel button duplicated ChatInput's Stop button (both call the same
handleStop) — single entry point is enough; users can judge from the
elapsed seconds whether to stop.
- Drop tiered thinking labels (Thinking / Reasoning / Working through
it / Taking a closer look) — collapse to a single "Thinking".
- Unify all spinners to `breathe` (was: helix / scan / cascade / orbit
/ breathe / pulse / braille mix). Tool-specific spinner choices were
cosmetic noise; one consistent spinner reads cleaner.
- Remove `onCancel` prop chain through ChatMessageList → TaskStatusPill.
Net: 209 → 152 lines in task-status-pill.tsx; no API/contract changes
beyond removing a now-unused prop.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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.
* 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
* 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>
---------
Co-authored-by: multica-agent <github@multica.ai>
Users can now mute specific notification categories (assignments, status
changes, comments & mentions, priority/due-date updates, agent activity)
from Settings > Notifications. Muted event types are silently filtered at
notification creation time — no inbox items are created for muted groups.
- Add notification_preference table (migration 064)
- Add GET/PUT /api/notification-preferences endpoints
- Filter notifications in notifyIssueSubscribers, notifyDirect, and
notifyMentionedMembers based on user preferences
- Add Notifications tab in Settings with per-group toggle switches
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
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.
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.
* 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.
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.
Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
When viewing an inbox notification's issue detail and clicking the "Mark
as done" toolbar button, the inbox item was not archived — only the issue
status changed. Add an onDone callback to IssueDetail so the inbox page
can archive the notification alongside the status update, matching the
behavior of the list-item Done button.
Closes MUL-1594
* feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page
Add a "Connect remote machine" CTA to the Runtimes page header and
empty state that opens a 3-step wizard dialog guiding users through:
1. Installing the Multica CLI on a remote machine
2. Configuring, logging in with a PAT, and starting the daemon
3. Monitoring for runtime registration via WebSocket
Includes security tips (IAM roles, no root keys), troubleshooting
guidance (daemon status/logs, CLI version check), and post-connection
flow to create an agent on the newly registered runtime.
Closes MUL-1588
* fix(views): improve connect-remote dialog layout and usability
- Widen dialog from sm:max-w-lg to sm:max-w-xl for longer commands
- Add max-h-[85vh] + overflow-y-auto so content scrolls on small screens
- Split monolithic code block into 4 separate labeled steps (install,
configure, login, start daemon) — each with its own copy button
- Make copy buttons always visible instead of hover-only
- Condense security tips into a single compact paragraph
- Tighten vertical spacing throughout
* feat(inbox): add one-click Done button to inbox items
Add a hover-visible "Mark as done" button (CircleCheck icon) to each
inbox item that has an associated issue not yet in done/cancelled status.
Clicking it sets the issue status to "done" and archives the inbox item
in one action, replacing the previous multi-step flow of opening the
issue detail sidebar to change status.
* feat(issues): add Mark Done button to issue detail toolbar
Add a "Mark as done" button (CircleCheck icon) to the issue detail
header toolbar, positioned to the left of the Pin button. The button
is only visible when the issue status is not already done or cancelled.
Clicking it sets the issue status to "done" via the existing
handleUpdateField action.
* fix(auth): route invitees to their workspace instead of forcing /onboarding
Workspace presence now wins over `onboarded_at` across every post-auth
entry point, so a user invited into an existing workspace lands inside
that workspace instead of being trapped in the new-workspace wizard.
The redesigned onboarding flow (#1411) intentionally flipped the
priority during frontend development so every login re-entered
/onboarding; the backend `onboarded_at` field shipped but the flipped
priority was never restored. Closes#1837.
- packages/core/paths/resolve.ts: has-workspace beats !hasOnboarded.
Onboarding is reachable only when the user has zero workspaces.
- apps/web/app/auth/callback/page.tsx: drop the early-return on
!onboarded so a `next=/invite/<id>` survives Google OAuth round-trips.
- apps/web/app/(auth)/login/page.tsx: same removal in both the
already-authenticated effect and the post-login handler.
- packages/views/layout/use-dashboard-guard.ts: stop bouncing in-workspace
users to /onboarding; rely on the resolver for zero-workspace cases.
- apps/desktop/src/renderer/src/App.tsx: window-overlay now opens
onboarding only when wsCount === 0 AND !hasOnboarded.
- apps/web/app/(auth)/onboarding/page.tsx: defense-in-depth — bounce
away if the visitor already has a workspace, even on direct URL access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(auth): fix URLSearchParams leaking state across callback tests
The previous cleanup `mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k))`
silently skipped entries because forEach advances its index while the
underlying URLSearchParams shrinks, so a `state=next:/invite/...` set
in one test bled into the next. Snapshot keys via Array.from before
deleting. Also rewrites the assertions to match the new policy: an
unonboarded user with a safe `next=` honors it, with a workspace lands
in that workspace, and only with zero workspaces falls back to
/onboarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When no model is explicitly selected, the model dropdown and inspector
picker no longer show "Default — Claude Sonnet 4.6". Instead they show
"Default (provider)" / "Default", avoiding confusion when the actual
CLI default differs from the hardcoded catalog entry.
* feat(views): keep quick capture open after submit for continuous creation
After successfully sending a prompt to the agent, the dialog now clears
the editor and stays open instead of closing. This lets users create
multiple issues in quick succession without reopening the dialog each
time. The user can still close manually via X or Escape.
* feat(views): add success feedback for quick capture continuous mode
After each successful submit, the Create button briefly flashes green
with a checkmark "✓ Sent" for 1.5s, then reverts. A persistent counter
("N sent") appears in the footer so the user knows how many prompts
they've dispatched in this session. No explicit mode toggle needed —
the counter implicitly signals continuous mode is active.
* feat(views): add "Create another" toggle to quick capture (Linear-style)
Replace always-on continuous mode with an opt-in toggle switch in the
footer, matching Linear's "Create more" pattern. The preference is
persisted per-workspace via the quick-create store so it remembers
across sessions.
- Toggle OFF (default): submit closes the dialog (original behavior)
- Toggle ON: submit clears the editor and stays open; button flashes
green "✓ Sent" and a counter shows how many have been dispatched
* fix(views): remove stale breadcrumb identifier test
PR #1872 removed the issue identifier from the breadcrumb but the
corresponding test was not updated, causing CI to fail.
The inbox notification for quick-create showed "Created MUL-1577: <title>"
which truncated the actual issue title. Now the title field shows just the
issue title (the most useful info), and the detail label shows "Created
MUL-XXXX" as context.
The issue detail page breadcrumb showed both the issue identifier and
title (e.g. "MUL-1567 Title"), making the ID appear twice. Remove the
standalone identifier span so only the title is displayed.
* feat(ui): make New Issue button open Quick Capture instead of manual form
The sidebar "New Issue" button and the search command's "New Issue" action
now open the agent-based Quick Capture dialog directly, matching the
platform's agent-first workflow.
Contextual issue creation (board columns, list view status groups, sub-issues)
still opens the manual form since those pass pre-filled data.
Closes MUL-1558
* test(search): update search-command test to expect quick-create-issue
Aligns the test assertion with the behavior change in the previous
commit where "New Issue" now opens Quick Capture.
The agent-mode Quick Capture dialog already supported image paste and
drag-drop through the ContentEditor, but lacked a visible file
attachment button. This made the feature undiscoverable.
Add a FileUploadButton (paperclip icon) to the footer, matching the
pattern already used by the manual create panel and comment input.
Without this guard, submitting during an active upload causes
stripBlobUrls to silently remove the in-flight blob image from the
markdown, so the agent never sees the pasted screenshot. Now the Create
button disables and shows "Uploading…" until all file uploads resolve.
Packaged builds are unaffected: scripts/package.mjs already injects the
git tag into electron-builder's extraMetadata.version, so the .app users
download from GitHub Release reports the right version through
app.getVersion() and the auto-updater's latest.yml comparison works
correctly.
Dev mode (`pnpm dev:desktop`) didn't go through that path though, so
app.getVersion() returned the static "0.1.0" from package.json — the
new Settings → Updates panel surfaced this and made it look like the
dev build was ancient. Add a tiny getAppVersion() helper that falls
back to `git describe --tags --always --dirty` only when !app.isPackaged,
and use it for the app-info IPC. No change to packaged behavior; if git
is unavailable for any reason, we silently fall back to app.getVersion().
Surface the running app version (from app.getVersion via preload's
appInfo) at the top of Settings → Updates so users have a clear place
to check which build they're on, instead of only seeing it inline after
clicking "Check now".
ProseMirror's default clipboardTextSerializer uses Slice.textBetween,
which flattens every node to its inner text. Copying `## 你好` from the
editor only put `你好` on the clipboard's text/plain channel, so pasting
into VS Code, terminals, or messaging apps lost all Markdown markers.
Add a markdown-copy extension symmetric to the existing markdown-paste:
on copy/cut/drag, route the selected Slice through editor.markdown.serialize
to write the Markdown source. The text/html channel is left at ProseMirror's
default so pasting back into another ProseMirror editor still preserves
exact node structure via data-pm-slice.
Registered for both editable and readonly modes — users frequently copy
from rendered comments/issue descriptions.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(quick-create): bound dialog height + scroll editor when content overflows
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.
- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
`!max-w-xl`); short prompts still render compact, tall content stops
at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
so it absorbs the remaining vertical space inside the now-bounded
DialogContent and scrolls internally instead of pushing the dialog.
* feat(quick-create): gate on daemon CLI version with pre-check + server enforcement
The agent-create flow depends on multica CLI behavior introduced in
v0.2.20 (URL attachment handling, no-retry semantics on
`multica issue create` failure — see PR #1851 / MUL-1496). Older
daemons either double-create issues on partial CLI failures or
mishandle pasted screenshot URLs. Per J's review on MUL-1496, gate
the flow at two layers — frontend pre-check for fast feedback,
server re-check as the trust boundary, both fail-closed on
missing/unparsable versions.
Server:
- New MinQuickCreateCLIVersion + CheckMinCLIVersion helper in
pkg/agent (with sentinel errors for missing vs too-old).
- QuickCreateIssue handler reads runtime metadata.cli_version and
returns a stable 422 { code: "daemon_version_unsupported",
current_version, min_version, runtime_id } before enqueuing.
- The check runs after the existing online + ownership validation,
so all rejections surface uniformly through the modal's existing
error path.
Frontend:
- New @multica/core/runtimes/cli-version with the min version
constant, parser, and runtime-metadata reader (tiny semver, no
new lib dep).
- AgentCreatePanel resolves the selected agent's runtime, runs the
same check, shows an inline amber notice below the agent picker
when missing/too old, and disables the Create button.
- Submit handler also catches the server's 422 (defensive race —
runtime can re-register between pre-check and submit) and
surfaces the same wording in the error row.
Switching to manual create remains a clean escape hatch — manual
mode doesn't talk to a daemon at all, so an outdated CLI doesn't
block the user from filing the issue.
A complete UX upgrade for chat sending → receiving → recovering.
* StatusPill replaces the orphan spinner — stage-aware copy
("Reading files · 12s", "Searching the web · 14s", "Typing · 24s"),
shimmer text, monotonic timer, derived effective status, > 60s
warning tone, > 5min cancel button.
* WS writethrough on task:queued / task:dispatch / task:cancelled so
pendingTask cache stays in sync with the daemon state machine without
invalidate-refetch latency. broadcastTaskDispatch now includes
chat_session_id when the task is for a chat session — the existing
payload only carried it on the generic task: events, leaving the pill
stuck at "Queued" until completion.
* Failure fallback — FailTask writes a chat_message tagged with
failure_reason (mirrors the issue path's system comment, gated on
retried==nil). Front-end renders an inline note ("Connection failed",
with a Show details collapsible) instead of the previous black hole.
* Elapsed timing — chat_message.elapsed_ms persists task.completed_at -
task.created_at on success/failure rows. UI shows "Replied in 38s" /
"Failed after 12s" beneath assistant bubbles. Format helper shared
between StatusPill and the persisted caption so the live timer and
final reading never disagree.
* Optimistic burst rebalanced — pendingTask seed + created_at moved
before the HTTP roundtrip so the pill appears the instant the user
hits send; handleStop is fire-and-forget so cancel feels immediate
(server confirmation arrives via task:cancelled WS).
* Presence integration — chat avatars use ActorAvatar (status dot +
hover card); OfflineBanner above the input on offline/unstable;
SessionDropdown shows per-row in-flight/unread pip plus a
cross-session aggregate pip on the closed trigger.
* Editor blur on send so the caret stops competing with the StatusPill
/ streaming reply for the user's attention.
* Chat panel isOpen now persists globally; defaults to OPEN for new
users (storage key absence) so the feature is discoverable. Existing
users' prior choice is respected.
* DB: migrations 062 (failure_reason) + 063 (elapsed_ms), both
ADD COLUMN NULL — fast, non-blocking, backwards compatible.
* WS: task:failed chat path now invalidates chatKeys.messages — fixes
a pre-existing bug where the failure bubble required a page refresh
to appear.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(execenv): default-disable Codex native multi-agent in per-task config
Recent Codex app-server releases enable features.multi_agent by default,
exposing spawn_agent / wait / close_agent tools that let a parent thread
spawn nested subagents. The daemon currently models only the parent thread,
so the parent's turn/completed is treated as task completion even when
spawned children are still running — leading to premature task completion
and dropped child output.
Disable features.multi_agent by default in the per-task CODEX_HOME/config.toml
so Multica's task lifecycle is the only orchestration layer in play. Strip
both the dotted-key form (features.multi_agent) at TOML root and the
multi_agent key inside a [features] table; siblings and unrelated tables
are preserved. Honor MULTICA_CODEX_MULTI_AGENT=1 as an opt-out for users
who explicitly want Codex native subagents inside a Multica task.
The user's global ~/.codex/config.toml is never modified — only the daemon's
isolated per-task copy.
Also widen managedBlockRe to consume `\n*` rather than `\n?` so reruns
don't accumulate blank lines when both the sandbox and multi-agent managed
blocks coexist.
* fix(execenv): inject managed multi_agent inside existing [features] table
Per PR review (codex_multi_agent.go:77-83 vs :112-115): when the user's
config.toml already has a top-level `[features]` table, writing
`features.multi_agent = false` at the TOML root implicitly redefines the
same `features` table. The strict TOML parser used by Codex (`toml-rs`)
rejects that with `table 'features' already exists`, so Codex would fail
to load the per-task config and refuse to start the thread. Verified the
strict-parser failure with pelletier/go-toml/v2; the previous
BurntSushi/toml-based regression test was permissive enough to miss it.
Detect a root-level `[features]` header and place the managed block
inside that table (`multi_agent = false` with marker comments). When no
such header exists, keep the existing root-level dotted-key form. The
managed-block regex matches both layouts so reruns and layout
transitions stay idempotent. A `[features.experimental]` sub-table
without a bare `[features]` header still uses the root dotted-key form,
which is spec-valid (no explicit redefinition).
Tests now use pelletier/go-toml/v2 to actually parse the output and
assert features.multi_agent decodes to false; the regression case from
the PR review is covered explicitly.
* fix(execenv): recognize feature table header variants
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
* feat(server/auth): cache PAT lookups in Redis with 60s TTL
Personal access tokens used to hit Postgres on every request: a SELECT
to resolve token_hash → user_id, plus a fire-and-forget UPDATE of
last_used_at. For a CLI / daemon making many requests per second this
is wasted DB load — the token is the same and the answer hasn't changed.
Add a Redis-backed cache (auth.PATCache) keyed by token hash, TTL 60s:
- On cache hit, the auth middleware skips both the SELECT and the
last_used_at UPDATE. last_used_at is now refreshed at most once per
TTL window per token, not per request.
- On cache miss the middleware falls back to today's behavior: query
Postgres, populate the cache, async-update last_used_at.
- On revoke, the handler invalidates the cache entry so revocation
takes effect immediately rather than waiting for the TTL to expire.
This required changing RevokePersonalAccessToken from :exec to :one
RETURNING token_hash.
The cache is nil-safe: when REDIS_URL isn't configured, NewPATCache
returns nil and the middleware degrades to today's always-hit-DB
behavior. JWT validation is untouched (already DB-free).
Tested with REDIS_TEST_URL — same gating pattern the rest of the
suite uses for Redis-backed tests. New tests cover nil-safety, set/
get/invalidate, TTL, and the middleware short-circuit on cache hit.
* fix(server/auth): clamp PAT cache TTL to token's remaining lifetime
GPT-Boy review caught: a PAT expiring in <60s would still be cached
for the full PATCacheTTL window, so the token could continue passing
auth on cache hit for up to ~60s after its expires_at. The DB query
filters expired tokens (revoked = FALSE AND expires_at > now()), but
that filter never ran on a cache hit.
Make Set take an explicit ttl, and add TTLForExpiry to compute it:
- no expires_at → full PATCacheTTL
- expires_at far → full PATCacheTTL
- expires_at <60s → time until expiry
- already expired → 0, Set skips caching (TOCTOU defense between
the SELECT and the Set, since the SELECT
already filters expired rows)
Regression test pins the clamp behavior end-to-end against Redis.
* feat(server/auth): cache daemon-token + PAT lookups in DaemonAuth, bump TTL to 10m
Daemon /api/daemon/* requests (heartbeat, claim task) hit DaemonAuth
which previously did its own GetDaemonTokenByHash on every request and
*also* duplicated the PAT lookup on the mul_ fallback — bypassing the
cache added in 1cdd674c. Today's daemons authenticate via mul_ PATs
(mdt_ minting isn't wired up yet), so the duplicate PAT path is the one
that actually matters for hot-path DB load.
Three changes:
1. New auth.DaemonTokenCache mirrors PATCache for the mdt_ path
(key = mul:auth:daemon:<sha256>, JSON value = {workspace_id, daemon_id}).
Forward-looking infrastructure for when daemon tokens get minted; the
middleware short-circuits the DB SELECT on cache hit. TTL clamped to
the token's expires_at via the shared TTLForExpiry helper.
2. DaemonAuth now also consults PATCache on its mul_ fallback, sharing
the same cache as the regular Auth middleware. A daemon making 4 hb/min
collapses from 4 GetPersonalAccessTokenByHash + 4 last_used_at writes
per minute to ~1 of each per AuthCacheTTL window (~10 minutes).
3. Rename PATCacheTTL → AuthCacheTTL and bump from 60s to 10 minutes.
The constant is now shared between PAT and daemon caches; 10m matches
the user-requested longer TTL for further DB write reduction. Revoke
latency on the happy path is still instant via active invalidation;
the worst-case (Redis Del miss / direct-DB revoke) grows from ~60s to
~10m.
Tests cover nil-safety, set/get/invalidate, TTL, clamped TTL on near-
expiry tokens, and the middleware short-circuit for both cache paths
(mdt_ via DaemonTokenCache, mul_ fallback via PATCache).
* feat(server/auth): cache PAT lookups on the WebSocket auth path
The third place a PAT is resolved — patResolver.ResolveToken used by
realtime.HandleWebSocket — was still hitting Postgres on every /ws
auth and firing an unconditional last_used_at UPDATE, bypassing the
cache added in 1cdd674c. Wire it through the same shared PATCache so
revoking a token through any path (Auth middleware, DaemonAuth PAT
fallback, or WS auth) hits all three caches with one Invalidate.
Also leaves a comment on DeleteDaemonTokensByWorkspaceAndDaemon —
the query has no caller today, but a future deregister/rotate flow
must remember to call DaemonTokenCache.Invalidate(hash) for each
deleted row, otherwise deleted daemon tokens stay valid until TTL.
PR #1744 fixed literal `\n\n` rendering for the CLI surfaces (`issue
create / update --description`, `issue comment add --content`) but the
agent-completion path bypasses the CLI entirely: the daemon POSTs the
agent's stdout to `/api/daemon/tasks/:id/complete`, and `TaskService.
CompleteTask` writes `payload.Output` straight into `createAgentComment`
and `CreateChatMessage` without decoding. Models (e.g. Codex) routinely
emit Python/JSON-style `\n` literals in their final output, which then
land in the DB as the 4-char escape sequence and render as one wall of
text in the issue/chat panel — exactly the bug report in #1820.
- Move `unescapeFlagText` from `server/cmd/multica/cmd_issue.go` to
`server/internal/util/text.go` as `UnescapeBackslashEscapes` so the
CLI and the service layer share one implementation. The full
contract-boundary test suite moves with it.
- Apply `UnescapeBackslashEscapes` to `payload.Output` before it
reaches `createAgentComment` and `CreateChatMessage` in
`TaskService.CompleteTask`. Same `\n / \r / \t / \\` decoding as the
CLI; other escape sequences (`\d`, `\w`, `\u`, etc.) pass through
verbatim so regex/format strings in agent output survive.
Closes#1820
Two coordinated fixes for a quick-create case where the agent ended up
creating duplicate issues. Repro: user pasted an image into the
quick-create prompt; the front-end uploaded it and embedded the URL as
markdown in the user input; the agent saw the URL, assumed it was an
attachment, and ran `multica issue create … --attachment "https://…"`.
The CLI POSTed the issue first, then failed to read the URL as a file
(`os.ReadFile("https://…")`) and exited 1. The agent treated exit 1 as
"create failed" and retried — but the first issue already existed, so
the workspace ended up with two of them.
CLI (`server/cmd/multica/cmd_issue.go`):
- `runIssueCreate` pre-validates `--attachment` BEFORE POSTing. URLs are
warned about and skipped (they are never local files); local-path
read errors fail before the issue is created so no half-baked issue
lands. Once the POST succeeds, post-create upload failures only
print a stderr warning and the issue metadata is still emitted —
never a non-zero exit, so callers cannot mistake "attachment upload
hiccup" for "create failed" and retry.
- `runIssueCommentAdd` already uploads attachments BEFORE the comment
is created, so its failure mode is fine; it just gets the same
URL-skip behaviour for consistency.
Quick-create prompt (`buildQuickCreatePrompt`):
- Tells the agent NOT to pass `--attachment` for prompt-embedded image
URLs (they are already part of the description as markdown).
- Hardens the "no retry" rule: even on a non-zero exit, do not retry
`issue create` — the issue may already exist.
Custom Next.js root not-found.tsx with cream/ink/terracotta editorial
palette and Instrument Serif hero. Replaces the bare default 404 on any
unmatched URL. Single CTA back to /, which routes appropriately based
on auth state.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.
- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
`!max-w-xl`); short prompts still render compact, tall content stops
at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
so it absorbs the remaining vertical space inside the now-bounded
DialogContent and scrolls internally instead of pushing the dialog.
GitHub #1839: when an issue is reassigned from agent A to agent B, B
often only reads the issue body and misses context A added in comments
(e.g. which repo to clone). The assignment-triggered workflow injected
into CLAUDE.md / AGENTS.md said "Read comments for additional context
or human instructions" — vague enough that agents routinely skipped
it. The comment-triggered branch already gives an explicit
`multica issue comment list` invocation, so behavior diverged.
Promote step 3 to a concrete CLI call, mark it mandatory, and surface
the most common failure mode (stale instructions on reassignment) so
the agent recognizes when it matters. Reorder so comments are read
*before* flipping status to `in_progress`, matching how a human would
catch up on a thread before claiming work.
* feat(quick-create): default assignee to picker agent when user didn't name one
The quick-create prompt previously told the agent to OMIT --assignee
when the user's input didn't name a person. That left almost every
quick-created issue unassigned, which doesn't match user intent — the
user opened quick-create with a specific agent picked, so that agent
is the obvious owner.
Both prompt surfaces (BuildPrompt for the dispatched message, plus
the workflow block in injected CLAUDE.md / AGENTS.md) now instruct
the agent: if the input doesn't name an assignee, pass
`--assignee "<your name>"`. The picker agent's name is interpolated
into the prompt at task-build time so the agent has a literal value
to use rather than guessing its own name. The "explicitly named
assignee → resolve via members" branch is unchanged.
* refactor(execenv): drop duplicated quick-create field rules from CLAUDE.md/AGENTS.md
The quick-create field rules (title / description / priority / assignee
fallback / project / status) lived in two places — the per-turn user
message built by BuildPrompt, and the workflow block injected into
CLAUDE.md / AGENTS.md by buildMetaSkillContent. Same content, two
sources, easy to update one and forget the other (the assignee-default
change in this PR had to touch both).
Quick-create is one-shot, so the per-turn user message is always
present and is the natural single source of truth. The injected
file's quick-create section now keeps only the hard guardrails:
"do exactly one issue create, no issue get / status / comment add,
exit on CLI error". Those guardrails stay in BOTH surfaces because
they're the safety net for providers that don't propagate the user
message into resumed-session context.
renderQuickCreateContext (issue_context.md) was already
guardrails-only — no change needed there.
* fix(comment): don't inherit parent @mentions when parent author is an agent
When an agent posts a comment that @mentions another agent (typically a
one-shot delegation, e.g. a PR-completion comment that asks a reviewer
agent to review), member follow-up replies in the same thread were
auto-inheriting that mention and re-triggering the reviewer on every
plain question. Same root cause: the inheritance branch only required
the reply to have no mentions, not that the parent was member-authored.
Tighten the guard: only inherit when the parent (thread root) is
authored by a member. Member-rooted threads still inherit so a member
who started by @mentioning an agent can keep replying without re-typing.
Agent-authored roots are treated as one-shot — explicit @mentions in
later comments still trigger normally.
Extracted the decision into shouldInheritParentMentions for direct unit
testing, and added an end-to-end regression
(TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces
MUL-1535: J posts a PR completion @mentioning Reviewer; a member's
plain follow-up must not re-enqueue Reviewer.
* chore(comment): gofmt trigger_test.go
#1831 fixed the Toaster wrapper to follow next-themes' resolvedTheme,
but the desktop renderer was importing `Toaster` directly from `sonner`
and never going through the wrapper. So the success toast still rendered
light on a dark UI. Switch the import to `@multica/ui/components/ui/sonner`
to match the web app and pick up the theme + icon overrides.
* fix: polish quick-create UX (kind labeling, dark toast, placeholder)
Three small fixes shaken out from using the agent-create flow:
- AgentTaskResponse now carries a `kind` discriminator
("comment" | "autopilot" | "chat" | "quick_create" | "direct"), computed
from the existing FK shape with no extra DB access. The Activity row
uses it to label quick-create tasks as "Creating issue" instead of
falling through to the generic "Untracked" — once the agent finishes
and the new issue is linked, the row transitions to the normal
identifier+title display.
- Sonner Toaster reads `resolvedTheme` instead of `theme`, so toasts
follow the actual dark/light state. Forwarding "system" let sonner
pick its own answer from `prefers-color-scheme`, which in the Electron
renderer can disagree with next-themes' `html.dark` class — the toast
rendered light on a dark UI.
- Agent-create placeholder rephrased to a more conversational example
with a project reference: "let Bohan fix the inbox loading slowness
in the Web project". Drops the priority hint (priority isn't widely
used) and matches how people actually instruct the agent.
* fix(quick-create): link new issue back to task on completion
Addresses the review on PR #1831: completed quick-create tasks were
left with issue_id=NULL forever, so the activity row stayed on
"Creating issue" instead of transitioning to the normal MUL-XXX +
title rendering once the agent finished.
- Server: notifyQuickCreateCompleted now writes the resolved issue id
back to agent_task_queue.issue_id via a new LinkTaskToIssue query
(guarded by `issue_id IS NULL` so it only ever fills the unset
quick-create case). Best-effort: a write failure logs but doesn't
block the inbox notification.
- Frontend: defensive wording fallback — kind=quick_create rows in
terminal status (completed/failed/cancelled) now render as
"Quick create" instead of the active "Creating issue" label,
covering rows whose link write failed or whose agent never
produced an issue at all.
The agent-facing CLAUDE.md/AGENTS.md injected by InjectRuntimeConfig was
missing every doorway to non-core issue properties:
- `multica issue label list/add/remove` — the only way to label a newly
created issue from the agent. Without it, agents either give up
("no command for that, please add it manually") or hallucinate flag
names like `multica issue create --label foo` and fail.
- `multica issue subscriber list/add/remove` — same story for the
subscribe-on-behalf flow.
- `multica label list/create` — agents need to discover existing label
ids before they can attach one (we don't auto-create labels here).
- `issue create` flag list dropped `--project`, `--due-date`,
`--attachment` even though the CLI has supported them for a while.
- `issue update` flag list dropped `--status`, `--assignee`,
`--project`, `--due-date`, `--parent`, leaving agents thinking they
could only edit title/description/priority via update.
Also splits `issue status` from `issue update` in the doc so the agent
sees the shortcut, and notes the `issue create` body intentionally
does NOT accept labels/subscribers (use the post-create commands).
These were transitional handoff/design docs that fulfilled their purpose:
- docs/agent-runtime-status-redesign.md (802 lines) — design + plan for
PR #1794 (presence v3, availability + last-task split). Shipped.
- docs/agent-runtime-ui-design-brief.md (530 lines) — paired designer
brief for the same redesign. Shipped.
- HANDOFF_ARCHITECTURE_AUDIT.md (383 lines) — 4-task audit packaged for
the workspace URL refactor (PR #1138/#1141). The URL refactor itself
shipped; the other tasks are either resolved or live in code as the
source of truth. File:line snapshots inside have rotted.
Follows the precedent set by #1504 (chore(docs): remove shipped plan and
proposal docs). Code is the source of truth once the work is in.
Prod slow-log on the deployed v0.2.17 fix shows total_ms=4012,
auth_ms=4010, update_ms=1, all skill stages = 0 — meaning the bottleneck
on /api/daemon/heartbeat is now the auth section, not the Redis claim
path. To pinpoint which sub-stage dominates, decompose auth_ms into:
- decode_ms — JSON body decode
- runtime_lookup_ms — Queries.GetAgentRuntime (PG PK select)
- workspace_check_ms — requireDaemonWorkspaceAccess (string compare for
daemon-token, requireWorkspaceMember for PAT/JWT)
Also add auth_path ("daemon_token" | "pat" | "jwt") set by DaemonAuth
middleware so slow-logs disambiguate which token kind was used. PAT/JWT
takes an extra DB round-trip via requireWorkspaceMember and is a
candidate cause of long auth tails on daemons that haven't migrated to
mdt_ tokens.
The handler keeps the same external behavior; the change inlines and
instruments requireDaemonRuntimeAccess in DaemonHeartbeat only — other
callers of the helper are untouched. logHeartbeatEndpointSlow gains the
new fields.
Existing heartbeat tests pass; the slow-probe test output now shows the
new auth_path / decode_ms / runtime_lookup_ms / workspace_check_ms
fields populated.
Members and agents previously appeared in fixed buckets (members first,
then agents) following raw cache order. Replace that with a single ranked
list driven by the user's most recent mentions on this device, with an
alphabetical fallback for never-mentioned targets. Recency is stored in
localStorage per workspace and lazy-pruned at 200 entries.
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes
in-place from a footer switch button instead of a separate Advanced
shortcut. The two old modal types now route through one CreateIssueDialog
shell that owns the single <Dialog> and <DialogContent> — only the inner
panel body swaps on mode change, so the Portal/Backdrop/Popup stay
mounted and the switch is instant (no close→open animation flash).
Mode preference is persisted globally in localStorage via a small
useCreateModeStore, so the `c` shortcut always opens whichever mode the
user last used (or switched to). Carry payload (description / agent /
prompt) hands off through the shell's local state plus the existing
issue-draft store, so nothing the user typed is lost across switches.
Also drops the Shift+C → manual branch — `c` is now mode-agnostic and
the in-modal switch covers the same intent without users having to
remember a second shortcut.
Visible labels: "Quick create" → "Create with agent",
"New issue" → "Create manually".
Two related bugs that combined to leave every quick-create task in
'queued' from the user's POV:
1. ResolveTaskWorkspaceID returned "" for any task whose
issue_id / chat_session_id / autopilot_run_id were all NULL —
exactly the shape of a quick-create task. That made
requireDaemonTaskAccess 404 on the daemon's /start, /progress,
/complete, /fail endpoints, and silently dropped task:dispatch /
task:completed broadcasts. Even when the claim itself succeeded,
the daemon couldn't drive the task forward, so it stalled and
eventually got swept back. Read the workspace from the
QuickCreateContext JSONB so every downstream lookup works.
2. EnqueueQuickCreateTask never called notifyTaskAvailable, so the
daemon WS wakeup never fired for quick-create. The 30 s poll
fallback would eventually pick the task up, but combined with #1
that meant the task spent the bulk of its life looking like
"queued, never triggered". Match the chat / issue / autopilot
enqueue paths and signal the wakeup.
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable
Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.
- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
(getColumnPinningStyle), adapted from Dice UI's registry. The shell
renders <table> directly (skipping shadcn's <Table> wrapper) so its own
outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
cell renderers. Row click navigates to detail via onRowClick (instead of
wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
templates and the sticky-first-col / subgrid mechanics that came with
them. agent-list-item.tsx is removed; runtime-list.tsx and
skills-page.tsx are trimmed to thin wrappers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent): cap description at 255 chars (db + api + ui)
Symmetric enforcement across DB, server, and UI:
- Migration 060: pre-flight truncate of any oversize rows, then ADD
CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
(single source of truth shared by the create dialog + edit modal +
test suite) and a CharCounter component that warns at 90% and errors
past the cap.
- Description editor moves from a 288px popover to a roomy modal.
Editor body is mounted only while the dialog is open, so the local
draft state is locked in at mount time and never reset by an external
WS update — the React-recommended replacement for the
useEffect(reset, [value]) anti-pattern.
Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(views): data-table polish across runtime + skill lists
Builds on the DataTable migration in 2be0f287:
- Add ColumnMeta.grow flag — declared via TanStack module augmentation
in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
width so fixed table-layout assigns them the leftover container space
(no spacer column). The Title-grows / others-fixed pattern from
Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
<table> itself (fixed-layout ignores cell-level min-width per spec,
so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
switch to opaque bg + group-hover so they cover content scrolling
beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
themselves now — keeps DataTable single-purpose).
Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.
Runtime + skill list pages updated to use the new sizing model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): drop LastTaskState, introduce 3-state Workload
Continues the presence-model rework started in #1794 / #1798.
The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.
New model: presence is two orthogonal "right-now" dimensions:
AgentAvailability — runtime reachability only (online / unstable /
offline). Drives the dot colour everywhere.
Workload — current load (working / queued / idle). Three
states, never historical. Failure / completion /
cancellation are surfaced via Recent Work + Inbox,
not list-level state.
`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.
Activity tab cleanup that follows from the new model:
- failureReasonLabel relocated from agents/presence.ts to
tabs/task-failure.ts (presence no longer owns historical state).
- Recent Work paginates (5 initial, +20 per "Show more"); chat-session
tasks are filtered out of every Agent-scoped surface to keep
"team work" separate from private chat.
- Agents page drops the lastTaskFilter chip group; users find broken
agents via Inbox / Recent Work, not a list-level filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task): trigger summary snapshot + task:queued lifecycle event
Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:
1. trigger_summary snapshot (migration 061)
New nullable column on agent_task_queue. Comment-triggered tasks
snapshot the comment content; autopilot tasks snapshot the run title.
Truncated to 200 runes via strings.Builder so multibyte input counts
correctly without O(N²) concatenation. Snapshot survives source
edits/deletes — every task row self-describes across surfaces (issue
detail Execution log, agent activity tooltip, inbox) without joining
back to the originating row.
Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
the description stays meaningful across attempts. The UI is
responsible for stacking "Retry #N" context on top.
2. task:queued WS event
New protocol event covering the ∅ → queued transition. Front-end
types/events.ts registers it; use-realtime-sync's task: prefix path
already invalidates task caches via onAny, so old clients without
this exact-match subscription still refresh correctly. Specific
subscribers (sticky banner) get sub-second updates instead of
waiting for daemon claim.
Retry path now broadcasts task:queued (not task:dispatch) — same
status transition shape as enqueue, so all "new task created" paths
agree on one event type.
Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
the queued event is published into the WS bus before the daemon is
poked. Without this, a fast daemon could claim and emit task:dispatch
over the wire before the in-process queued broadcast fan-out reached
clients — race window is tiny but unsafe-by-construction.
Per-agent task list (agentTasksKeys.all) and per-issue task list
(["issues","tasks"]) added to the task: invalidation set so Activity
tab Recent Work and the Execution log section stay fresh.
Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issue): ExecutionLogSection — unified active+past runs panel
Replaces two pieces:
- the click-to-expand timeline that lived inside AgentLiveCard
- the standalone TaskRunHistory below the main content
with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.
Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.
Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).
Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.
AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.
issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ActorAvatar prop was renamed in #1794 (split presence into
availability + last-task) — `disableHoverCard` is now `enableHoverCard`
with inverted semantics. The QuickCreate modal landed against the old
API and broke main's frontend typecheck. The two avatars in the modal
already want the default (no hover card), so just drop the prop
instead of opting in.
Codex Desktop writes one [[skills.config]] entry per known skill into
~/.codex/config.toml. File-backed entries get path = "...", but
plugin-backed entries (e.g. name = "superpowers:brainstorming") only get
a name. Codex CLI 0.114's TOML deserializer treats path as required, so
it rejects the plugin entries with "missing field path" and fails
thread/start.
The daemon copies ~/.codex/config.toml verbatim into each task's
isolated codex-home, which propagated those broken entries into the
per-task config and blocked every Codex agent run for affected users.
Strip the whole [[skills.config]] array on copy. Multica writes the
agent's currently assigned skills directly to codex-home/skills/ and
Codex auto-discovers them from there, so the user-level skill registry
is redundant for a per-task run.
Closes#1753
* feat(server): add quick-create issue async task path
Adds POST /api/issues/quick-create which validates the picked agent's
reachability up front (not archived, has runtime, runtime online) then
queues an issue-less agent task whose context JSONB carries the user's
natural-language prompt + requester + workspace. Daemon claim resolves
the workspace from the context, and the prompt builder switches to a
quick-create template instructing the agent to translate the prompt
into a single multica issue create call.
Task completion writes a success inbox item to the requester pointing at
the newly-created issue (located by querying the agent's most recent
issue in the workspace since task start, so we don't depend on agent
stdout shape). Failures write an action_required inbox item carrying the
original prompt + agent id so the frontend can offer "Edit as advanced
form" without losing input.
* feat(views): quick-create issue modal + inbox failure CTA
Adds a streamlined create-issue UI bound to the c shortcut: pick an
agent, type one line, submit. The modal closes immediately and the
agent translates the prompt into a multica issue create call in the
background. Shift+c keeps the legacy advanced form for users who want
every field. The "Advanced" button inside the new modal seeds the
shared issue-draft store with the prompt + picked agent so switching
mid-flow doesn't lose input.
Last-used agent persists per (user, workspace) via a workspace-aware
zustand store so frequent users skip the picker on every open.
Inbox renders quick_create_done items with a status pin to the new
issue and quick_create_failed items with an "Edit as advanced form"
CTA that re-seeds the legacy modal with the original prompt.
ApiError now carries the parsed JSON body so the modal can branch on
the structured agent_unavailable code without parsing the error
message.
* fix(quick-create): execenv injection, claim race, private-agent permission
Addresses GPT-Boy review on #1786:
1. execenv was rendering the assignment-task issue_context.md / runtime
workflow even for quick-create, telling the agent to call
`multica issue get/status/comment add` against an empty IssueID.
Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create
branch in renderIssueContext + the runtime_config workflow that
instructs the agent to run a single `multica issue create` and
exit, with explicit "do NOT call issue get/status/comment add"
guards.
2. ClaimAgentTask serialized only on issue_id / chat_session_id, so
concurrent quick-creates on the same agent (both NULL on those
columns) ran in parallel — making the success-inbox lookup race
over "most recent issue by this agent". Adds a third OR clause
that treats "all four FKs NULL" as a serialization key for the
same agent, so quick-create tasks on a given agent run one at a
time.
3. QuickCreateIssue handler bypassed the private-agent ownership rule
that validateAssigneePair enforces elsewhere — a user could POST a
private agent_id they didn't own and trigger it. Now routes the
picked agent through validateAssigneePair before the runtime
liveness check.
4. Clarifies the quick-create-store namespacing comment to match the
actual workspace-aware StateStorage convention used by the other
issue stores (per-user is browser-profile-local).
* fix(quick-create): branch Output section + deterministic origin lookup
Addresses GPT-Boy's second-pass review on #1786:
1. The runtime_config.go Output section forced "Final results MUST be
delivered via multica issue comment add" for every non-autopilot
task — quick-create still got this conflicting instruction even
though there's no issue to comment on. Switched the Output block
to a three-way switch so quick-create gets a tailored "stdout is
captured automatically; do NOT call comment add" branch matching
the autopilot variant.
2. Completion lookup was "most recent issue created by this agent
since task.started_at", which races against concurrent issue
creates by the same agent (assignment task running alongside
quick-create when max_concurrent_tasks > 1). Replaced with a
deterministic origin link:
- Migration 060 extends issue.origin_type CHECK to allow
'quick_create'.
- Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a
quick-create task.
- multica issue create CLI reads the env var and stamps the new
issue with origin_type=quick_create + origin_id=<task_id>.
- Server CreateIssue handler accepts (origin_type, origin_id)
from trusted callers (only "quick_create" is allowed; the pair
is rejected unless both fields are provided together).
- notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on
(workspace_id, "quick_create", task.ID) — no more time-window
racing against parallel agent activity.
The old GetRecentIssueByCreatorSince query is removed.
On Windows the official cursor-agent installer ships cursor-agent.cmd whose
body is `powershell ... -File cursor-agent.ps1 %*`. CreateProcess for a .cmd
file goes through cmd.exe, and `%*` in a batch file is expanded by
re-tokenising the original command line, which mangles arguments containing
newlines or other whitespace - most notably a long, multi-line `-p <prompt>`.
The agent then only sees a truncated prompt and fails with "Workspace Trust
Required" or exits 1 immediately.
When LookPath resolves cursor-agent to a .cmd/.bat launcher and a sibling
cursor-agent.ps1 exists, invoke PowerShell directly with `-File <ps1>` so
Go's os/exec passes each argv as a discrete token. This is exactly what the
.cmd does internally; we just skip the cmd.exe re-tokenisation step.
PowerShell host resolution prefers pwsh.exe (PS 7) on PATH, then
powershell.exe on PATH, and finally falls back to
%SystemRoot%\System32\WindowsPowerShell\v1.0.
Platform-specific code is split via build tags
(cursor_invocation_windows.go / cursor_invocation_other.go) so non-Windows
builds carry no Windows-only dependencies. The lookup is exposed as a
package variable to make the Windows path fully unit-testable without
spawning real PowerShell. Five unit tests cover: passthrough on non-launcher
targets, successful rewrite with a multi-line prompt, .exe direct launch
(skip), missing .ps1 (skip), and missing PowerShell host (skip).
The change leaves macOS / Linux behaviour entirely untouched and stays on
the official cursor-agent launch chain - no node.exe direct invocation, no
prompt mutation, no extra flags.
Closes#1297
Made-with: Cursor
* fix(popover): stop click bubble + resilient presence loading
Two related bugs surfacing on production after #1794:
* Click-through: clicking a Detail link inside an agent hover card, or
a kebab item in agents/runtimes list rows, also fired the parent row
link's onClick. Base UI portals popovers in the DOM but React's
synthetic events still bubble through the React tree, so the
ancestor <a> wrapping the trigger still received the click. Fix at
the primitive level (HoverCardContent + DropdownMenuContent) so
every existing and future popover gets it for free — stopPropagation
on the popup's onClick, then forward consumer-supplied handlers.
* Presence loading forever: useAgentPresenceDetail returned "loading"
whenever any of its three queries had data === undefined. With prod
backend missing the new agent-task-snapshot endpoint (404), or with
an issue assignee referencing an archived agent (not in ListAgents),
the UI spun forever. Now: query errors degrade to empty arrays, and
a missing agent yields a synthesised offline+idle detail. The dot
still renders gray, hover card still shows "Agent unavailable" —
but no infinite skeleton.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(inbox): enable hover card on notification actor avatar
Originally excluded from the hover-card opt-in pass, but inbox
notifications are exactly the kind of "who sent me this?" surface
where seeing the actor profile on dwell is useful. Click-through to
the wrong target is no longer a concern — the popover stop-bubble
fix in this branch handles it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(autopilot): show agent presence dot on autopilots list rows
Autopilot detail / picker / dialog already render the dot — the list
was the lone holdout. With the autopilot-agent dependency this strong
("autopilot is dead if its agent is offline"), an at-a-glance dot is
the most useful signal in the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type
Lays the API + type contract for the front-end agent presence cache:
- New `GET /api/active-tasks` returns active (queued/dispatched/running)
tasks plus failed tasks within the last 2 minutes for the current
workspace. The 2-minute window powers a UI-side auto-clearing "Failed"
agent state without back-end pollers.
- `agent_task_queue` has no workspace_id column, so the query JOINs agent;
`SELECT atq.*` keeps `failure_reason` (migration 055) on the wire.
- Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend
classifiers (agent_error / timeout / runtime_offline / runtime_recovery
/ manual) to copy without parsing free-text errors.
- New `api.getActiveTasksForWorkspace()` client method; workspace is
resolved server-side from the X-Workspace-Slug header (no path param,
matching /api/agents and /api/runtimes conventions).
Includes the joint engineering plan and designer brief that scope the
broader Agent / Runtime status redesign — Phase 0 is this contract plus
the front-end derivation layer landing in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge
Adds the front-end derivation layer that turns raw server data into the
user-facing 5-state agent / 4-state runtime enums. UI files are
deliberately untouched in this commit — derivation lives behind hooks
(useAgentPresence, useRuntimeHealth) that any component can call with
zero additional network traffic.
Architecture:
- Derivation is pure functions in packages/core/{agents,runtimes}; the
back-end stays free of UI translation. Agents algorithm: runtime
offline > recent failed (2-min window) > running > queued > available.
Runtimes algorithm: status + last_seen_at -> online / recently_lost /
offline / about_to_gc.
- A single workspace-wide active-tasks query backs all per-agent
presence reads, eliminating N+1 across hover cards, list rows, and
pickers. 30-second tick re-renders the hooks so the failed window
expires even when no underlying data changes.
- WS task lifecycle events (dispatch / completed / failed / cancelled)
invalidate active-tasks via the prefix dispatcher. completed/failed
were removed from specificEvents so they go through both the prefix
invalidate and the existing chat ws.on() handlers. Reconnect refetch
picks up active-tasks too.
- Desktop bridges window.daemonAPI.onStatusChange directly into the
runtimes cache via setQueryData, giving the local daemon sub-second
feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace
switches automatically rebind the subscription; daemon_id matching
covers the same-daemon-multiple-providers case.
24 derivation unit tests cover all branches plus null/empty/boundary
inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing
completed_at). Full core suite: 112 tests passing. Typecheck green
across all 8 workspace packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-status): redesign agent runtime status as two orthogonal dimensions
Splits the conflated 5-state agent presence into two independent axes:
- AgentAvailability (3-state): online / unstable / offline — drives the
dot indicator everywhere a dot appears. Pure runtime reachability;
never sticky-red because of a past task outcome.
- LastTaskState (5-state): running / completed / failed / cancelled /
idle — surfaced as text + icon on focused surfaces (hover card,
agent detail page, agents list, runtime detail). Never colours the dot.
Major changes:
* Domain layer: AgentPresence union → AgentAvailability + LastTaskState.
derive-presence split into deriveAgentAvailability + deriveLastTaskState
+ deriveAgentPresenceDetail orchestrator. Tests reorganised into three
groups (availability invariants, last-task invariants, composition).
* Visual config: presenceConfig (5 entries) → availabilityConfig (3) +
taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips.
* Workspace-level presence prefetch: new useWorkspacePresencePrefetch
hook + WorkspacePresencePrefetch mount component, wired into
DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards
render synchronously with no skeleton flash on first hover.
* ActorAvatar hover: flipped default — disableHoverCard removed,
enableHoverCard added (default false). Opt-in at ~14 decision-moment
surfaces; pickers / decoration sub-chips stay plain. Status dot
decoupled (showStatusDot prop) so picker rows can show presence
without nesting popovers.
* Hover cards: AgentProfileCard simplified — availability dot only,
Detail link top-right (logs live on the detail page). New
MemberProfileCard mirrors the structure: name + role + email +
top-2 owned agents (sorted by 30d run count) with click-through to
agent detail.
* Agents list: split Status into two columns — availability (3-color
dot + label) and Last run (task icon + label, optional running
counts). Two independent filter chip groups (Status + Last run);
combination acts as intersection ("online + failed" finds broken-
but-alive agents).
* Other UI surfaces (issue list/board/detail, comments, autopilots,
projects, runtimes, mention autocomplete, subscribers picker)
updated to the new dot semantics; status dot now strictly 3-color.
Server changes accompany the client redesign — workspace-wide
agent-task-snapshot endpoint, runtime usage queries, etc. — to feed
the derive layer with the data it needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-detail): drop last-task chip from detail header + inspector
The Recent work section on the agent detail page already shows the same
data (with task titles, timestamps, error context) — surfacing
"Completed" / "Failed" / etc. up in the header was redundant chrome.
Detail surfaces now show only the 3-state availability dot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tables): handle narrow viewports across agents / skills / runtimes
Three table layouts were squeezing content into adjacent cells at
intermediate widths. Each fix is small and targeted:
* runtime-list: the Runtime cell's base name had `shrink-0`, so it
refused to truncate when its grid column was narrowed under width
pressure — the name visually overflowed into the Health column
("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health
column was also a fixed 9.5rem reservation for the worst-case
"Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it
competes fairly with Runtime.
* skills-page: had a single grid template with no responsive
breakpoints — all 6 columns were rendered at any width and got
visually jammed below md. Added a <md template that drops Source +
Updated; the row markup hides those cells via `hidden md:block` /
`md:contents`.
* agent-list-item: the new Last run column was reserved at minmax(8rem,
max-content); on narrow md viewports the 8rem floor pushed the row
past available width. Changed to minmax(0,max-content) so the cell
shrinks under pressure (its content already truncates).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-card): hover-only Detail + add Runtime row + breathing room
Three small polish tweaks to the agent hover card:
- Detail link gets `mr-1` + fades in only on card hover (group-hover).
It was visually flush against the popover edge and competing for
attention; now it stays out of the way during a quick glance and
surfaces only when the user is dwelling on the card.
- Runtime row is back, in the meta block (cloud/local icon + runtime
name). The earlier removal was over-aggressive — knowing where an
agent runs is part of "who is this agent". The wifi badge stays
dropped because the availability dot in the header already conveys
reachability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(runtime): wifi-style health icon (4-state) for runtime list + agent card
Replaces the 6px coloured dot with a wifi-shape icon that carries both
state (Wifi vs WifiOff) and severity (success/warning/muted/destructive).
Mapping:
- online → Wifi (success)
- recently_lost → WifiHigh (warning) — transient hiccup, fewer bars
- offline → WifiOff (muted) — long unreachable
- about_to_gc → WifiOff (destructive) — sweeper coming soon
Used in two places:
- Runtime list: replaces HealthDot in the dedicated leading-icon column.
Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized).
- Agent profile card RuntimeRow: derives runtime health from runtime +
clock (matching the 4-state semantics) and renders HealthIcon next
to the runtime name. Cloud runtimes always read as online. The
duplicate signal with the header availability dot is intentional —
it confirms WHICH runtime is the one currently in the dot's state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ignore Kiro ACP session/load history replay before the active prompt starts; keep task messages, usage, and tool state scoped to the current Kiro turn. Verified with go test ./pkg/agent -run TestKiro, go test ./pkg/agent, and git diff --check origin/main...HEAD.
* docs(changelog): publish v0.2.19 release notes
Today's release covers 23 commits since v0.2.18. Headline items are the
macOS dock unread badge with focus-gated inbox notifications, the daemon
WebSocket task wakeup path that drops task startup latency, and a
client-side label filter on the issue list. Improvements / fixes round
out comment linkify, optimistic label attach, agent-to-agent mention
loop prevention, Codex turn timeouts, Windows daemon survivability, and
the comment-delete task cancellation.
The Kiro CLI runtime addition is intentionally omitted pending a
chat-mode regression flagged before release.
* docs(changelog): include Kiro CLI runtime, drop assignee-default line
Per release sign-off: Kiro CLI ACP runtime ships in v0.2.19 once the
chat-mode regression is fixed, so it goes back into the headline. The
"create-issue remembers last assignee" line is dropped from features
to keep the list to four spotlight items.
* feat(desktop): dock unread badge + focus-gated inbox notifications
Wire two OS-level integrations for inbox activity. Both degrade cleanly on
web and unsupported platforms.
- Unread badge on the macOS dock / Linux Unity launcher. Derived from the
same inbox list the UI renders, deduplicated per issue, capped as "99+"
on macOS via `app.dock.setBadge` (setBadgeCount truncates at 99). New
`useInboxUnreadCount` hook (core/inbox) + `useDesktopUnreadBadge`
(views/platform) keep renderer and main in sync via a `badge:set` IPC.
- Native OS notification on `inbox:new`, fired from the renderer only when
`document.hasFocus()` is false — in-focus feedback is the existing inbox
sidebar's unread styling, so we don't fight macOS's deliberate foreground
suppression. Clicking the banner focuses the main window and navigates
to `/inbox?issue=<key>` via the shared `multica:navigate` bus.
Refactors `inbox-page.tsx` to read the unread count through the new hook
(was a per-render inline filter).
* fix(desktop): pin notification routing to source workspace + mark read on URL select
Two bugs GPT-Boy caught on PR #1445:
1. A notification from workspace A used `getCurrentSlug()` at click time,
so if the user switched to workspace B before clicking the banner (macOS
Notification Center persists banners), routing landed on `/B/inbox?issue=<A key>`
and 404'd. Fix: round-trip the emit-time `slug` through the IPC payload
and use it in the click handler.
2. Notification-click navigation set the URL param but never fired the
mark-read mutation (only InboxPage's click-handler did). The row stayed
unread and the dock badge didn't decrement. Fix: move the mark-read
logic from handleSelect into a useEffect keyed on the selected item —
it now covers both click-to-select and URL-param-select.
IPC payload gains `slug` and `itemId`; preload types + main handler + the
desktop bridge are updated to match.
Adds a Label submenu to the workspace issues filter dropdown, backed by
labelFilters in the shared issue view store. The filter is OR'd within
itself (issue matches if it carries any of the selected labels) and
AND'd with the existing status / priority / assignee / creator /
project dimensions, mirroring the multi-select semantics already in
place. Each label row renders via LabelChip for color parity with the
sidebar picker, and each row's count comes from the same
useIssueCounts pass that drives the other filter chips.
Filtering stays client-side, consistent with all other filters today.
The pagination caveat is a known limitation we'll revisit if real
workspaces start hitting it; this PR intentionally does not change the
fetch path.
Released Desktop builds bake VITE_API_URL/VITE_WS_URL/VITE_APP_URL
at build time and ship pointing at Multica Cloud — there is no
in-app 'Connect to a self-hosted instance' button. Reported in
multica-ai/multica#1768.
- Replace the misleading callout in desktop-app.mdx (and zh) with
the actual self-host path: build from source with custom env, or
use web + CLI. Link to #1371 for the runtime-config feature.
- Soften the corresponding 'Next steps' link in self-host-quickstart
(and zh) so it no longer implies one-click Desktop self-host.
Drop the filled status chip (bg-warning/text-white etc.) from the
list/board column headers — StatusIcon already carries the semantic
color, so the chip duplicated it on the text background, and the
bg-muted variants were nearly invisible against the muted/40 row
background. Wrap the shared icon + label + count in a new StatusHeading
component used by both list-view and board-column.
Remove the now-unused badgeBg/badgeText fields from STATUS_CONFIG.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): server-side label + filter querying for issue list
Extends GET /api/issues with label_ids, priorities, creator_ids,
project_ids, include_no_assignee, and include_no_project params, and
moves the existing single-value filters onto array-form. Each filter
becomes part of the SQL WHERE clause so paginated buckets reflect the
user's selection — fixes the bug where client-side filtering hid
matches sitting past the first page (#1491).
CLI gains a repeatable --label flag; legacy --priority/--assignee/
--project keep working via the single-value compatibility paths.
* feat(issues): drive workspace + my-issues filters from the server
issueListOptions and myIssueListOptions now key the React Query cache
on a normalized filter object, so each filter combination has its own
cache entry and a filter change re-fetches with the wire-shape filter
applied server-side. Drops the client-side filterIssues step on the
issues page, my-issues page, and project detail — that step silently
hid matches that lived past the first paginated page (#1491).
Adds a Label submenu to the workspace issues filter dropdown, plus
labelFilters in the view store. Mutations and ws-updaters fan their
optimistic patches across every filter-keyed list cache via
qc.setQueriesData on issueKeys.listPrefix(wsId), and the editor's
mention-suggestion reads from any matching list cache for instant
first paint regardless of which filter is active.
* fix(issues): route Members/Agents scope through server-side filter
The Members/Agents scope tabs on the workspace issues page were still
narrowing client-side via `assignee_type === 'member'`. That hits the
exact pagination-blind bug this PR is meant to fix: if the first 50
issues per status don't include the right assignee type, the tab
shows "No issues" while later pages have matches.
Adds an `assignee_types text[]` filter to ListIssues / ListOpenIssues /
CountIssues, threads it through the API client, normalizer and view
filter, and maps the scope tab to it. Each scope now keys its own
list cache and refetches with the correct first page.
Also disables the My Issues "My Agents" query when the user owns no
agents — `assignee_ids: []` was getting dropped by both the API client
and the query-key normalizer, so the request went out unfiltered and
surfaced unrelated issues under "My Agents".
Follow-ups to #1765 review nits:
- Tighten the per-turn prompt and AGENTS.md workflow instructions so
that "exit with no output" only applies when the trigger is from
another agent AND no actual work was produced this turn. If the
agent did real work, the standard "post results as a comment" rule
still applies — a result reply is not a noise comment.
- Add TestAgentExplicitMentionStillTriggers as a positive control
documenting the boundary the structural fix preserves: suppressing
implicit parent-mention inheritance for agent authors does NOT
block deliberate handoffs. An agent that explicitly @mentions
another agent in its own content still enqueues a task for the
mentioned agent and does not self-trigger.
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.
Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
when authorType == 'agent'. Explicit @mentions in the agent's own
comment still work for deliberate handoffs.
Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
explicitly forbid posting 'No reply needed' noise comments.
Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
(agent reply does not re-trigger) and the positive control
(member reply still inherits mentions).
Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
The create-issue modal now remembers the assignee picked at submit
time and prefills the picker with that value when the modal next
opens. Implemented by tracking lastAssigneeType/Id alongside the
draft and seeding clearDraft's reset with those values.
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)
util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.
Changes:
- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
unguarded user-input string reaching it surfaces as a recovered panic
(chi middleware.Recoverer → 500) instead of silently corrupting data.
Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
input (autopilot, comment, project, skill, skill_file, label, pin,
attachment, feedback, issue assignee, daemon runtime, workspace) to
validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
invalid input. Where a resolved entity.ID is already in scope, write
queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
notification_listeners, activity_listeners, scope_authorizer,
middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
regression tests in handler_test.go (the original #1661 bug + the
invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
is enforceable in future PR review.
* fix(server): address GPT-Boy review of #1748
P1 fixes from PR #1748 review:
1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
malformed input returns 400 instead of panic/500. Was missing on:
- issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
filters; batch issue_ids and assignee/parent/project fields in
BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
the existing per-row continue semantics).
- project.go: project id + workspace_id in GetProject/UpdateProject/
DeleteProject; lead_id in CreateProject/UpdateProject;
workspace_id in ListProjects + SearchProjects.
- handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
X-Task-ID headers; invalid UUID falls back to "member" (matches
pre-existing semantics) instead of panicking.
- issue.go: validateAssigneePair returns 400 on invalid workspace_id
instead of panicking.
2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
instead of the raw URL string. After an identifier-path delete
("MUL-7"), the previous payload would have leaked the identifier to
subscribers, leaving stale entries in frontend caches that key by
UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
(issue.go:1641). The slog "issue deleted" log line also now records
the resolved UUID so logs match the WS payload.
3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
assert issue:deleted.payload.issue_id is the resolved UUID, not
the identifier.
* fix(server): validate remaining reviewed UUID inputs
* fix(server): validate remaining handler UUID inputs
* fix(server): finish request boundary UUID audit
* fix(server): validate remaining request body UUIDs
* fix(server): validate runtime path UUIDs
* fix(server): validate remaining audit UUID inputs
---------
Co-authored-by: Eve <eve@multica.ai>
* fix(labels): apply attach optimistically so chips render before round-trip
Attach went through onSuccess only, so users waited for the server
before seeing the new chip — out of step with detach (already optimistic)
and with status/assignee/priority via useUpdateIssue. Mirror the detach
pattern: snapshot the byIssue cache, look up the full label from the
workspace list cache, patch byIssue + the issue list/detail caches via
onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and
onSettled keep the existing reconcile behavior.
* fix(labels): only patch attach when prev label set is known
GPT-Boy's review caught a corruption case: when byIssue cache was
unpopulated (user clicked before issueLabelsOptions resolved), the
optimistic patch fell back to an empty prev.labels, then mirrored
[label] into issue list/detail via onIssueLabelsChanged — wiping any
denormalized labels already on the issue. Worse, onError only restored
byIssue when ctx.prev existed, so the wipe persisted on failure.
Match useDetachLabel's invariant: skip the optimistic patch unless prev
is in cache. The chip will wait for the round-trip in the rare race
window, but caches stay consistent and rollback always works.
When a user deletes a comment that triggered an agent task, the agent
would still run with the now-deleted content baked into its prompt
(fetched at task claim time) — manifesting as "the agent still sees the
deleted comment". The FK ON DELETE SET NULL only nullified
trigger_comment_id; the queued task itself was never cancelled.
DeleteComment now cancels any queued/dispatched/running task whose
trigger is the deleted comment, before the comment row is removed.
The release job uses GoReleaser to bump the formula in
multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN
and should not publish to that tap, so the job currently fails on
every fork tag push (401 Bad credentials against the upstream tap).
This makes the workflow red on downstream forks even though the
actual artifact pipeline (verify → docker-backend-build →
docker-backend-merge) succeeds and produces a usable image.
Gate the release job on `github.repository_owner == 'multica-ai'`.
Upstream behaviour unchanged. Forks now see a clean green run for
docker artifacts only.
* docs(changelog): publish v0.2.18 release notes
Today's release covers 13 PRs since v0.2.17. Spotlight is the full Issue
Labels feature (backend + CLI + Web UI), plus the Labs settings tab,
sidebar invitation indicator, and the sharded Redis realtime relay.
Improvements and fixes round out comment rendering, project-icon usage
across the app, self-host env-var pass-through, and several
Windows-specific agent issues.
* docs(changelog): simplify v0.2.18 entries
Trim each line to a short, user-facing sentence; drop implementation
detail (sharded relay, build-id symlinks, --description-stdin, etc.) per
review feedback that the previous draft was too detailed.
* fix(comments): preserve newlines from agent CLI writes
Agents (e.g. Codex) routinely emit `multica issue comment add --content
"para1\n\npara2"` because Python/JSON-style string literals are their
default. Bash does not expand `\n` inside double quotes, so the literal
4-char sequence flowed through the CLI into the database and rendered
as text in the issue panel — comments came out as one wall of prose.
Three coordinated fixes so the platform behavior no longer depends on
whether a given model has strong bash-quoting intuition:
- CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for
`issue create / update / comment add` (callers needing a literal
backslash still have `--content-stdin`).
- Agent prompt: rewrite the comment-add example in the injected runtime
config to require `--content-stdin` + HEREDOC for any multi-line body,
and call out the same rule for `--description`. The previous wording
flagged stdin only for "backticks, quotes", which models read as
irrelevant to plain paragraphs.
- Renderer: add `remark-breaks` to the shared Markdown plugin chain so a
bare `\n` becomes a visible line break instead of a CommonMark soft
break — protects against models that emit single newlines for
formatting.
Tests: pin the new CLI helper, and pin the runtime-config guidance so
the multi-line wording cannot decay back into a footnote.
* fix(comments): address review feedback on newline-rendering PR
- Cover the issue panel: ReadonlyContent (used by every comment card and
the issue description) has its own react-markdown wiring; add
remark-breaks there too so the renderer fix actually applies to the
surface the bug was reported on, not just the chat panel. Pinned by
ReadonlyContent line-break tests.
- Make the prompt's `--description` guidance executable: add
`--description-stdin` to `issue create` / `issue update`, refactor
comment-add to share a single `resolveTextFlag` helper, and have the
injected runtime config name the real flag instead of an imaginary
"stdin / a tempfile" path. Pinned by the runtime-config guidance test.
- Document the unescape contract on each affected flag's help text and
pin the precise boundary in tests: `\n / \r / \t / \\` are decoded;
`\d / \w / \s / \u / \0` and other unrecognised escapes pass through
verbatim, so regex literals and Windows paths survive intact unless
they embed a literal `\n` / `\r` / `\t`. Callers that need the literal
sequence have `--content-stdin` / `--description-stdin` as the escape
hatch.
- my-issues page lost labels because myIssuesViewStore cherry-picked
name/storage/partialize from viewStorePersistOptions and dropped the
cardProperties-aware merge. Persisted snapshots predating the labels
toggle had cardProperties.labels = undefined, falsy-shorting the chip
render. Extracted mergeViewStatePersisted as a generic and wired it
into both stores.
- list-row chips now render right after the title (with a small left
margin for breathing room) instead of in the right-aligned cluster.
* feat(issues): render labels on list/board with bulk server-side fetch
ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response
via a new ListLabelsForIssues query so the client gets labels in a single
round-trip instead of N requests per visible issue. List-row and board-card
read issue.labels directly; an issue_labels:changed WS handler patches the
list and detail caches in place so chips stay live across tabs, and
attach/detach mutations mirror their result into the same caches for
immediate same-tab feedback.
Adds a "Labels" toggle to the card properties dropdown (defaults on).
* fix(issues): preserve cached labels and refresh on label edit/delete
Three fixes from gpt-boy's review of #1741:
1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't
load labels (UpdateIssue, batch updates, the issue:updated WS broadcast)
serialized labels:null. onIssueUpdated then merged that null into the
list/detail caches, wiping chips on every other tab whenever any non-
label field changed. Switched to *[]LabelResponse + omitempty: nil =
field absent (client merge keeps existing labels); non-nil (incl. empty
slice) = authoritative.
2. issue.labels is a denormalized snapshot, but useUpdateLabel /
useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving
stale chips in list/board after rename/recolor/delete. Mutations now
also invalidate issueKeys.all(wsId), and the realtime refreshMap maps
the label prefix to both labels and issues invalidation for cross-tab.
3. Persisted cardProperties from before this branch lacks the new `labels`
key. Render fell back to `?? true` but the dropdown switch read it raw
and showed unchecked. Added a custom Zustand merge that deep-merges
cardProperties so newly added toggles inherit defaults for existing
users; dropped the `?? true` fallbacks now that the store guarantees
the key.
Extract <ProjectIcon> with sm/md/lg sizes and a single 📁 fallback,
replacing 9 inline render sites that had drifted into 6 different
sizes and a mixed FolderKanban/emoji fallback.
Two visible fixes fall out of the centralization:
- ProjectPicker trigger now shows the selected project's icon (most
visibly in the issue detail right Properties panel, where it had
always been a generic FolderKanban).
- Sidebar parent nav (Projects, Issues, Settings, ...) now stays
highlighted on child detail routes via a small isNavActive helper.
Pinned items keep strict equality.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(labels): add issue label CRUD + attach/detach handlers (#1191)
The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:
- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
(ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
attach/detach idempotency, and cross-workspace isolation
* feat(cli): add label and issue label subcommands (#1191)
- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}
Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.
* feat(web): add labels UI — picker with inline create + management dialog (#1191)
Exposes the backend label feature to users via the existing issue-detail
sidebar.
- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
issue_labels:changed
- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
(ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
- Multi-select combobox in the issue sidebar
- When 0 labels: "Add label" trigger
- When 1+ labels: the chips themselves are the trigger; × on each chip
detaches without opening the picker
- Inline create: typing a new name + Enter creates with a hash-derived
color and attaches in one motion (matches Linear/GitHub)
- "Manage labels…" footer opens a dialog containing the full workspace
panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
Single-row create form (color swatch + name + Add button). Each label
row supports inline rename + recolor + delete (with confirm dialog).
Color input uses the browser's native picker for full-gamut access —
no preset palette clutter.
- `PropRow label="Labels"` added to the issue-detail sidebar below Project
Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
Electron apps share an identical upstream Electron binary, so its GNU
build-id is the same across every Electron RPM (Slack, VS Code, Discord,
etc.). The default fpm/rpm behavior owns /usr/lib/.build-id/<hash>
symlinks, which collide between packages and make `dnf install` fail
when any other Electron app is already installed.
Pass `_build_id_links none` to rpmbuild via fpm so the multica-desktop
RPM no longer claims those paths.
Fixesmultica-ai/multica#1723.
* fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts
The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`,
which silently truncates positional arguments at the first newline. The
daemon spawns OpenCode with a multi-line prompt (system prompt + user
message), so on Windows the agent only ever sees the first line and
responds generically as if it never received the user's message
(reported in #1717 with native-binary repro confirming the same prompt
arrives intact when cmd.exe is skipped).
When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd`
shim, walk to the native binary that npm bundles next to the shim:
<prefix>\opencode.cmd
<prefix>\node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe
If the native binary is missing (unusual install layout), keep the
original shim path so PATH lookup still wins. The resolver is a pure
function with an injectable `statFn`, so layout assertions are testable
on Linux:
- shim resolves to the bundled native binary
- missing native returns "" (caller keeps original path)
- non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution
- uppercase `.CMD` is accepted (PATHEXT entries can be either case)
Closes the user-facing failure mode without restructuring exec resolution
across the rest of the agent backends — the other shim-aware fixes can
follow the same shape if/when they land in similar repros.
* fix(agent/opencode): cover x64-baseline and arm64 npm package variants
`npm install -g opencode-ai` ships three Windows platform packages
(opencode-windows-x64, opencode-windows-x64-baseline for older CPUs
without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and
installs whichever matches the host. The previous resolver only knew
about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall
back to the .cmd shim and hit the multi-line prompt truncation again.
Iterate the three package candidates in GOARCH-preferred order. ARM64
hosts try arm64 first; everything else tries x64, then baseline, then
arm64 as a last resort. Cost is one extra statFn call per miss when
the GOARCH-preferred package isn't installed.
Surfaced by review on #1718.
* test(agent): add Windows counterpart to writeTestExecutable
writeTestExecutable in exec_fixture_unix_test.go is referenced by
claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix
constraint meant `go test ./pkg/agent` failed to build on Windows.
ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that
pathology, so a plain os.WriteFile is sufficient.
Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718.
docker-compose.selfhost.yml documents these as load-bearing in .env.example
but the backend service never received them, so allowlist / signup-gating
configs were silently ignored on self-hosted deployments. Wires the three
vars through with defaults matching .env.example.
* docs(changelog): publish v0.2.17 release notes
Covers commits between v0.2.16 (2026-04-24) and the v0.2.17 cut
(2026-04-26): --custom-env flag for agents, agent CLI stderr tail in
failure messages, configurable update download timeout, plus reliability
fixes around daemon cancellation, server heartbeat, Codex execenv, Pi
skills path, Windows console, CJK markdown URLs, attachment downloads
and autopilot run-only context.
Both en.ts and zh.ts updated.
* docs(changelog): trim small/internal items from v0.2.17 entry
Drops items that read as internal polish or were too narrow to belong in
release notes:
- Skills landing intro polish
- Codex execenv plugin-cache cleanup
- CLI exact-name/ShortID assignee resolution
- Settings invite role label rendering
- Skills SKILL.md fast-path
- CJK markdown URL-boundary fix
- Relative attachment download URLs
Keeps the user-facing wins: --custom-env, stderr-tail in failure
messages, configurable update timeout, cancelled-task classification,
heartbeat probe/claim split, plus the higher-impact fixes.
#1674 wired claude's post-handshake error path through withAgentStderr but
left the writeClaudeInput failure branch returning a bare "broken pipe"
error. That branch fires precisely when claude crashes during startup —
exactly when the stderr tail is most useful for root-causing V8 aborts,
Bun panics, or missing native modules. cmd.Wait() before sampling Tail()
flushes os/exec's internal stderr copy goroutine, matching the
Wait→Tail synchronization contract spelled out in stderr_tail.go.
Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the
codex test: a fake claude binary drains stdin, writes a V8-abort line to
stderr, and exits 3. Locks in the contract that Result.Error carries the
stderr tail in the post-handshake failure path on the claude backend too.
Merge the two symlink removal branches in exposeSharedCodexPluginCache —
they shared the same os.Remove + recreate path with only the error label
differing. The branch is now keyed off Lstat's ModeSymlink bit, with
Readlink reused only to fast-path an already-correct link. Behaviour is
unchanged; just less duplicated code.
CancelTasksForIssue silently dropped the list of affected tasks, so
whenever an issue transitioned to "cancelled" or "done" while a task was
still active (6 call sites in issue.go), the underlying agent was left
stuck at status="working" indefinitely and required a manual
`multica agent update <id> --status idle` to self-correct. This matches
the symptom reported in #1587: task rows move to "cancelled" via a
non-user-initiated path, agent status never reconciles.
Change CancelAgentTasksByIssue from :exec to :many (also tack on
completed_at = now() for consistency with CancelAgentTasksByIssueAndAgent),
then update CancelTasksForIssue to iterate the returned rows and call
ReconcileAgentStatus + broadcast task:cancelled per affected task —
mirroring the pattern already used by CancelTask and RerunIssue.
No test added; the change is small and mirrors well-covered paths.
Happy to add a mock-backed test in a follow-up if reviewers prefer.
Refs #1587
Refs #1149
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session.
Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
Hoist the existing stderrTail ring-buffer (previously codex-only) into
a shared pkg/agent helper so every Backend that supervises a child CLI
can include the last ~2 KB of that CLI's stderr in Result.Error. Wire
the claude backend through the same path.
Motivation: claude on Windows occasionally exits with a non-zero status
after ~5–8 minutes of a single long-running tool_use, and right now the
daemon only reports "claude exited with error: exit status 3" /
"exit status 0x80000003" — useless for root-causing V8 aborts, Bun
panics, native-module OOMs, or any other CLI-side crash. With the tail
attached, the failure message carries the real signal (panic line, V8
assertion, stderr-printed HTTP error) all the way into the task row's
error field that users see in the API.
Renames withCodexStderr to withAgentStderr(msg, label, tail) so the
helper is self-documenting across providers.
* fix(server): validate assignee_id existence on issue create/update
POST /api/issues and PUT /api/issues/:id silently accepted any
well-formed UUID as assignee_id (#1662). The new validateAssigneePair
helper consolidates the existing canAssignAgent check and adds:
- existence lookup against workspace members for assignee_type=member
- existence lookup against workspace agents for assignee_type=agent
- pair consistency: type and id must be both set or both null
- whitelist for assignee_type values (member|agent)
UpdateIssue and BatchUpdateIssues now run the same validator on the
post-merge assignee pair whenever the caller touches either field,
closing the parallel gap on the update path.
* fix(server): reject malformed assignee_id at handler entry
parseUUID silently returns an invalid pgtype.UUID for unparseable input
and validateAssigneePair treats (type unset + id invalid) as "no
assignee". Together they let `POST /api/issues` and `PUT /api/issues/:id`
silently drop a malformed assignee_id and return a successful response.
Reject the parse failure inline at every entry point — Create, Update,
and BatchUpdateIssues — so the validator never sees an unparseable id.
Adds two regression tests covering the create and update paths.
* feat(cli): add --custom-env to agent create/update
Adds a JSON-object flag on `multica agent create` and `multica agent
update` that writes the agent's `custom_env` map via the existing
handler API. Needed so runtime bearer tokens (e.g. SECOND_BRAIN_TOKEN)
can be provisioned from the CLI without falling back to curl or
admin-only UI access.
- `--custom-env '{"KEY":"value"}'` → sets the map.
- `--custom-env '{}'` or `--custom-env ''` → clears the map on update
(server treats a non-nil empty map as "clear all entries").
- Omitted flag → no change.
- Help text flags the value as secret material and never logged.
- Table-driven tests cover the parser (valid, clear, invalid JSON,
wrong shape) plus flag discoverability on both commands.
* feat(cli): add --custom-env-{stdin,file}; sanitize parse errors
Security review of the --custom-env flag (PR #1518) surfaced two issues:
1. Secrets on the command line leak via shell history and /proc/<pid>/cmdline
regardless of CLI logging. Add --custom-env-stdin and --custom-env-file
as mutually-exclusive alternatives, and update the --custom-env help
text to warn about shell history / 'ps' exposure so the "never logged"
claim is no longer misleading.
2. parseCustomEnv wrapped json.Unmarshal errors with %w; SyntaxError /
UnmarshalTypeError can surface fragments of the (secret) input. Return
a fixed, content-free message instead.
Refactor the body-assembly blocks in both agentCreateCmd and
agentUpdateCmd to go through a single resolveCustomEnv helper so the
three input channels behave identically. Tests cover every channel,
mutual exclusion, error sanitization, and help-text wording.
* fix(cli): require explicit '{}' to clear custom_env; sanitize --custom-args errors
Address PR #1518 review feedback from @Bohan-J:
1. parseCustomEnv now errors on empty/whitespace input. The clear signal
is the explicit '{}' object only. The previous behavior silently wiped
the secret map when an upstream pipe was empty (cat missing.json |
... --custom-env-stdin without set -o pipefail) or when --custom-env-file
pointed at an empty file. resolveCustomEnv emits channel-specific error
messages (e.g. "--custom-env-stdin: empty input; pass '{}' to clear").
2. Drop the '&& filePath != ""' guard so an explicit --custom-env-file ""
surfaces an error instead of being silently ignored.
3. Rewrite TestAgentUpdateNoFieldsMentionsCustomEnv into
TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags — the body now
actually runs runAgentUpdate with no flags and asserts the resulting
"no fields" error names all three --custom-env channels.
4. Extract parseCustomArgs helper. Replace the '%w'-wrapped json error
with a content-free message, mirroring parseCustomEnv. Although
custom_args is not a dedicated secret channel, callers regularly stuff
sensitive values like "--api-key=..." into it, so json.Unmarshal must
never echo input fragments. Adds TestParseCustomArgsErrorSanitization.
Also adds resolveCustomEnv subtests for stdin/file empty-input, empty
file contents, empty file path, and explicit '{}' positive cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Implementer (Multica Agent) <implementer@multica-agent.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The invite-member role Select rendered the raw value ("member"/"admin")
in the trigger because Base UI's SelectValue defaults to the value, not
the item text. PR #1672 worked around it with `className="capitalize"`,
but this file already owns a roleConfig map with proper labels and the
codebase has an established render-prop pattern for SelectValue (see
trigger-config.tsx and runtime-local-skill-import-panel.tsx).
Use roleConfig[inviteRole].label inside SelectValue and reuse the same
labels for SelectItem children. Single source of truth for role display
names; future role additions or i18n won't depend on CSS capitalize.
Follow-up to #1686. Locks in two nits flagged during review:
1. agent.Result.Status doc comment now lists "cancelled" alongside the
existing values, so the enum surface matches actual usage.
2. New TestExecuteAndDrain_ContextCancelled_ReportsCancelled exercises
the path added in #1686: when the parent context is cancelled before
the backend produces a Result, executeAndDrain must return
Status="cancelled" (not "timeout"). A regression here would silently
restore the misleading log line we just fixed.
DeleteIssue passed the raw URL parameter through parseUUID(), which
returns a zero UUID for human-readable identifiers like "API-123".
This caused DELETE requests with identifier-style IDs to silently
succeed (204) without actually deleting the issue.
Use issue.ID from the already-resolved issue object instead, consistent
with BatchDeleteIssues and all other operations in the same handler.
Fixes#1661
When the server cancels a task (e.g. assignee changes during execution,
explicit user cancel, or workspace_isolation check fail), the daemon's
cancellation poll fires runCancel() on the run context. The drainCtx
derived from runCtx then signals Done(), but executeAndDrain() was
returning Status: "timeout" regardless of *why* the context ended.
The "agent finished status=timeout" log line is then misleading — it
suggests an actual deadline timeout when really the task was cancelled
by upstream. We spent hours misdiagnosing a healthy handoff as a
broken timeout because of this.
Distinguish context.Canceled from context.DeadlineExceeded in
executeAndDrain, and add a "cancelled" case to runTask so the status
propagates through the existing log path.
No behaviour change for genuine timeouts; no behaviour change for
the cancelled-by-poll discard path in handleTask. Only the daemon
log line and TaskResult.Status get the more accurate label.
PR #1632 updated the Pi project-level skill dir from
.pi/agent/skills/ to .pi/skills/, but missed two references:
- server/internal/daemon/execenv/runtime_config.go:20 — the comment
block here lists project-level paths for every other provider, so
using Pi's global path was inconsistent and misleading.
- docs/docs-rewrite-plan.md:88 — planning doc still listed the old
path in the Skills row.
Follow-up to #1632.
Mitigates #1637 and the related model-discovery failure in MUL-1397 by bounding the /api/daemon/heartbeat hot path with an ack-safe probe/claim split, adding structured slow-log attribution, and closing the ModelListStore running-state gap. See PR description for details.
Closes the functional gap the reporter hit on alchaincyf/huashu-design
(skills.sh/alchaincyf/huashu-design/huashu-design) without expanding
candidatePaths unconditionally, which would let an unrelated root
SKILL.md hijack a different skill URL in a multi-skill repo.
Try SKILL.md at the repo root before falling into the recursive tree
fallback added in #1432. Verify the frontmatter name matches the
requested skill so only genuine single-skill repos take the fast path.
For those repos this also shaves the recursive tree API call.
Also clarifies the candidate-path comment so the root case is
explicit.
* fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521)
CREATE_NO_WINDOW strips the console entirely. When the agent CLI then
spawns a console-subsystem grandchild (bash, cmd, netstat, findstr,
timeout) without itself passing CREATE_NO_WINDOW, Windows allocates a
brand-new visible console window per invocation — trading one popup per
agent run for N popups per tool call.
Switch to CREATE_NEW_CONSOLE + HideWindow=true so the agent gets a
hidden console that grandchildren inherit. Stdio pipes still work via
STARTF_USESTDHANDLES; no changes needed at the 17 hideAgentWindow call
sites.
Add a Windows-only regression test asserting CREATE_NEW_CONSOLE is set
and CREATE_NO_WINDOW is not, per the #1474 Windows-test follow-up.
Root-cause diagnosis by @matrenitski (verified against the shipped
multica.exe and the Claude Code CLI it spawns) in issue #1521.
* test(agent): use CREATE_NEW_CONSOLE-compatible flag in preservation test
CREATE_NEW_PROCESS_GROUP is silently ignored by Windows when combined
with CREATE_NEW_CONSOLE, so asserting it 'survives' was only bitwise-true,
not semantically meaningful. Switch the example to
CREATE_UNICODE_ENVIRONMENT (documented compatible) and also assert a
non-flag field (NoInheritHandles) survives to exercise full struct
preservation.
`multica issue assign --to <name>` matched agent/member names with a plain
`strings.Contains` check, so an exact match on `reviewer` became ambiguous
whenever a longer agent like `peer-reviewer` also existed. There was also
no way to disambiguate by ID.
Rework `resolveAssignee` to bucket candidates by priority:
1. Full UUID or 8-char ShortID (matches `truncateID` output) — case-insensitive.
2. Case-insensitive exact name (with surrounding whitespace trimmed).
3. Substring fallback — preserves the existing partial-name UX.
The first non-empty bucket wins. Ambiguity inside a higher-priority bucket
still errors and short-circuits lower-priority matching.
All six call sites (`issue assign/update/create/list`, `issue subscriber`,
`project`) are fixed by this single change.
Fixes#1620
linkify-it only recognizes ASCII characters as URL boundaries. In Chinese
or Japanese text a URL followed by "。" (or any other full-width
punctuation) was greedily swallowed into the URL along with everything up
to the next whitespace, producing hrefs like
`https://.../pull/1623。merge` that 404 when clicked.
Truncate the detected URL at the first CJK full-width punctuation
character and re-scan the tail, so adjacent URLs separated only by
full-width punctuation are still each linked individually. The
terminator character set mirrors the fix applied in mattermost/marked#22.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skills): restore page description, link to docs, polish intro layout
The previous card-layout refactor (#1614) dropped the page-top
description entirely; without it the page jumps straight from the
PageHeader to a brand-colored banner that explains *how sharing works*,
with nothing answering "what IS a skill?". Bring the description back,
add a docs entry point, and tighten the visual hierarchy so the intro
block reads as one coherent unit above the table card.
- Restore a one-line description as the page's primary intro:
"Instructions any agent in this workspace can use." — uses "any agent
... can use" (capability, not factual usage) since skills must be
manually attached to take effect.
- Add an inline "Learn more about Skills →" link mirroring the
onboarding docs-link pattern (muted underline, new tab) — opens
https://multica.ai/docs/skills.
- Visual hierarchy: description is text-base + text-foreground (primary),
link is text-xs + text-muted-foreground (auxiliary). Same line, eye
follows weight order.
- Banner padding bumped from px-3 py-2 to px-4 py-3 so it breathes and
its inner text lands at the same x as the table content.
- Wrap description + banner in a shared `pl-4 space-y-3` so they read as
one intro block, indented to align with the table card's content.
- Loading skeleton updated to mirror the new structure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skills): keep docs link underline subtle, only animate text color on hover
The underline was inheriting text-decoration-color from the link's text,
so when hover bumped the text from muted to foreground the underline
got darker too — making the link feel more prominent on hover than at
rest, the opposite of what we want for a tertiary docs link.
Pin decoration-color to muted-foreground/30 explicitly so it stays
faint regardless of hover state. Only the text color transitions; the
underline stays as a constant low-key marker that the element is a link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The skills page rolled its own HeroHeader instead of the shared PageHeader,
which meant no mobile sidebar trigger and visual drift from other list
pages. The table was also edge-to-edge inside the dashboard container, so
it felt "naked" compared to the rest of the product.
- Replace custom HeroHeader with shared PageHeader (gives mobile hamburger
and h-12 chrome for free); move "New skill" into the PageHeader as the
page-level action.
- Keep search + scope filters in a toolbar, but move that toolbar *inside*
a bordered, rounded card together with the table, so the whole unit
reads as a single scrollable surface with internal padding.
- Use the existing useScrollFade hook on the row list so the top/bottom
edges fade while scrolling.
- Drop `divide-y` in favor of `border-b` per row — divide-y leaves the
last row without a bottom rule, which looks unfinished when only a
couple of skills exist and the scroll area is taller than the content.
- Drop the redundant description paragraph from the old hero; keep the
"Shared with your workspace" banner above the card since it carries
non-obvious UX context.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(autopilot): rename Routines → Autopilots to match product UI
Unify naming between docs and product. Sidebar label, URL route,
CLI command, and onboarding copy all call this feature "Autopilot";
the docs were the only surface that diverged. Aligning the docs to
the product (rather than the reverse) because the 830+ code-side
references would be a much larger rename to propagate.
- Rename routines.mdx / routines.zh.mdx → autopilots.mdx / autopilots.zh.mdx
- Update meta.json / meta.zh.json index entries (routines → autopilots)
- Drop the reconciliation note ("docs say Routines, CLI says autopilot")
that shipped in the original routines.mdx and the cli.mdx section header
- Update cross-references in cli, how-multica-works, tasks,
assigning-issues, chat, mentioning-agents, daemon-runtimes (EN + ZH)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): link to docs from key steps and starter tasks
Users who want to dig deeper now have a next hop from inside the flow
instead of having to dig through the help menu. Placed as secondary
links (muted, underline-offset-4) so they don't pull focus from the
primary CTA on each step.
Placement — one link per surface, placed in secondary regions:
- Welcome: "Learn how Multica works" below the subhead
- Questionnaire: "Learn how agents work" in the Why-we-ask aside
- Runtime aside (shared by desktop + web): "Learn about runtimes"
- Agent step: "Creating your first agent" in the About-agents aside
- StarterContentPrompt dialog: "Learn how Multica works"
Starter tasks (content/starter-content-templates.ts): added a single
"Learn about X" tail link per task, only on first occurrence of each
concept within a branch. 8 links on the agent-guided branch + 8 on
the self-serve branch + 1 on the welcome issue header (17 total).
URL scheme: absolute https://multica.ai/docs/{slug} throughout —
absolute so desktop (Electron) opens them in the system browser, and
the /en prefix is omitted because the docs middleware redirects it
away (English is the default, Chinese is /zh/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(landing): add docs link to footer and how-it-works section
Docs were previously reachable only from the in-app help menu. Landing
now surfaces them in two places, both locale-aware (/docs for English,
/docs/zh for Chinese):
- Footer Resources group: Documentation link was pointing at the
GitHub repo; replaced with the real docs URL
- How-It-Works section CTA row: added "Read the docs" between the
primary CTA and the GitHub link, same ghost styling
Locale resolution: href is picked per-render based on the landing's
current locale (cookie-driven via useLocale). The docs app itself
does not auto-detect language, so we must pick the right path
explicitly when emitting the link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): clean up Autopilot rename leftovers and link formatting
- comments.mdx: "not routine updates" → "not day-to-day updates"
(adjectival holdover now that the feature is renamed Autopilot;
zeroes out remaining "routine" mentions in user-facing docs)
- starter-content-templates.ts: move the arrow inside the markdown
link — "[text →](url)" instead of "→ [text](url)" — so the arrow
is part of the clickable region. 17 occurrences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): drop docs link from welcome screen and starter-content dialog
"Learn how Multica works" was showing up too often in the first two
screens users see. Keep the link in the post-import welcome issue
header (where users actually have time to explore); remove it from
the two earlier surfaces where it competes with the primary CTA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Menu primitives (context/dropdown/menubar/select/command) had rules like
`focus:**:text-accent-foreground` and `*:[svg]:text-destructive` that forced
descendant svg colors on focus, overriding icons that set their own color
(e.g. StatusIcon's `text-warning`). Remove them so icon color comes from
inheritance only: colored icons keep their color on hover, uncolored icons
still inherit the item's focus/destructive color as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreateSkillDialog used a controlled \`open\` prop while staying mounted,
so closing meant a data-open → data-closed flip on the already-mounted
Popup plus a tail re-render from \`useEffect([open])\` resetting \`method\`.
Visible as a double-blink: first the close animation, then a second
fade when the reset effect fired.
Align with the CreateIssue / CreateProject pattern: parent conditionally
renders the dialog and \`<Dialog open>\` is hard-coded. Close now unmounts
the component and Base UI's Portal owns the single exit animation. The
per-open method reset becomes unnecessary — fresh mount, fresh state.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Restrict /health/realtime metrics exposure (MUL-1342)
The realtime metrics endpoint was registered on the public router with
no authentication, exposing per-event/per-scope counters, redis.last_error,
and redis.node_id to anonymous callers. This enables information disclosure
and traffic profiling.
Move the handler behind a token + loopback policy:
- If REALTIME_METRICS_TOKEN is set, require Authorization: Bearer <token>
using a constant-time compare. Reject other callers with 401 plus a
WWW-Authenticate hint.
- If the env var is unset, only serve loopback callers and return 404 to
remote clients so the endpoint is not enumerable. This keeps local dev
workflows working without configuration.
The handler is extracted into health_realtime.go with focused unit tests
covering the token, loopback, and rejection paths. .env.example documents
the new variable.
Refs: https://github.com/multica-ai/multica/issues/1606
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fail closed for proxied /health/realtime requests (MUL-1342)
Addresses review on PR #1608: when the server runs behind a reverse
proxy (Caddy / Nginx -> localhost:8080), public callers reach the Go
handler with RemoteAddr=127.0.0.1, so the previous loopback shortcut
exposed the metrics surface in self-hosted deployments.
The no-token path now treats any forwarding header
(X-Forwarded-For / -Host / -Proto, X-Real-Ip, Forwarded) as a
'this request was proxied, can't attribute, fail closed' signal and
returns 404. Direct loopback callers without those headers still work
for local dev. Token-gated path is unchanged.
Tests cover all listed proxy headers (incl. multi-hop XFF chain and
RFC 7239 Forwarded) over both 127.0.0.1 and ::1, plus a regression
case ensuring an empty/whitespace forwarding header does not break
direct loopback access. .env.example updated to call out that proxied
deployments must configure REALTIME_METRICS_TOKEN.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(core): add skill detail path and query helpers
- paths.workspace(slug).skillDetail(id) → /:slug/skills/:id
- skillDetailOptions(wsId, skillId) for fetching a single skill
- selectSkillAssignments(agents) folds the cached agent list into
Map<skillId, Agent[]>; returns a stable reference so consumers can
memoize against agent-array identity without re-rendering on unrelated
agent updates
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): add cross-platform openExternal helper
On Electron, route through window.desktopAPI.openExternal so the
http/https-only guard in the main process kicks in — direct window.open
inside Electron opens a new renderer window instead of handing the URL
to the OS shell. On web, fall back to window.open with noopener+noreferrer.
SSR-safe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): extract edit-permission hook and origin helper
- use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator)
so the UI can hide/disable actions instead of waiting for a 403. Takes
wsId explicitly per the repo rule for workspace-aware hooks.
- lib/origin: discriminated view over Skill.config.origin (manual /
runtime_local / clawhub / skills_sh) so consumers don't spread JSONB
parsing across the UI tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): rewrite skills list page and collapse import UI
- SkillsPage rewritten: new hero header, single table layout with
columns (Name / Used by / Source · Added by / Updated), agent avatar
stack per skill, filter tabs aligned with Issues/MyIssues header
(Button variant=outline + Tooltip + bg-accent active state).
- CreateSkillDialog: dedicated dialog for the manual/import entry
points, replaces the inline row-triggered dialog.
- runtime-local import: dialog variant deleted; panel is now the single
entry point, embeddable inside CreateSkillDialog. Panel covered by a
new test.
- Deleted runtime-local-skill-row (no longer needed — row rendering
lives in SkillsPage directly) and the old skills-page.test.tsx
(structure diverged beyond salvaging; will be re-added alongside the
detail-page tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skills): add skill detail page and wire routes on web and desktop
- SkillDetailPage: dedicated view for a single skill (name, description,
origin, assignments, file listing). Uses skillDetailOptions and the
new origin / use-can-edit-skill helpers.
- apps/web: /:workspaceSlug/skills/:id Next.js route.
- apps/desktop: /:slug/skills/:id added to the memory router under
WorkspaceRouteLayout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(skills): bump runtime-local-skill-import-panel timeouts for CI
The test chains a five-step async cascade (runtime list → setSelectedRuntimeId
effect → skills query → auto-select effect → row render). Comfortable on
local (~600ms) but tight against RTL's 1 s default on CI where jsdom +
Vitest import takes ~100s. Bump findByText and the two waitFor calls to
5 s each — no production behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Destructive actions in the autopilot detail page fired immediately on
click. Wrap "Delete autopilot" and per-trigger delete with AlertDialog
confirmation, matching the existing issue-delete pattern.
Also fix a latent bug in trigger deletion where the success toast was
shown synchronously after mutate(), so failures still reported success —
switch to mutateAsync + try/catch.
Hover and popup-open states now share the same bg-accent + border-accent
treatment. Drop the shadow-md hover (invisible in dark mode) and the
multi-property transition in favor of a single transition-colors.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace translucent tinted hover (border-accent/50 + bg-accent/20) with
a single-dimension shadow lift. The previous overlay was visually weak
because --accent is nearly identical to --card, so a 20% tint rendered
as almost no change. Active (popup-open) state now uses solid bg-accent
so hover and active are distinguished by different dimensions —
elevation vs color — instead of competing on the same axis.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop priority and project_id from autopilot. project_id was never exposed
in the UI and priority duplicated the agent's own task queue priority.
Redesign the create/edit modal as a Runbook (left) + Configuration (right)
layout. Rework the Schedule section around a single visual shell so every
picker aligns pixel-for-pixel on the same row:
- TimeInput (new): segmented HH:MM control adapted from openstatusHQ/time-picker,
driven by keyboard (ArrowUp/Down to step, ArrowLeft/Right to jump segment,
digit typing with a 2s two-digit window). Replaces <input type="time">,
whose native UI broke the design system. Supports a minuteOnly variant
for hourly schedules.
- TimezonePicker (new): searchable Popover with a fixed-width left check
slot so rows stay aligned and GMT offsets never collide with the selected
indicator.
- Runbook editor now lives in a bordered card, giving the placeholder an
input surface instead of bare document flow.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): add right-click context menu on list rows and board cards
Extract the detail page's ⋯ dropdown (~180 lines of inline JSX) into a
shared `useIssueActions` hook plus two thin wrappers so the same action
set (status / priority / assignee / due date / sub-issue ops / pin / copy
link / delete) can be mounted as both a DropdownMenu and a Base UI
ContextMenu. Right-click on any list row or board card now opens the
full action menu without entering the detail page.
Shell-level modals replace the detail-page-local state for set-parent /
add-child / delete-confirm / backlog-agent-hint, so any trigger (detail
page, list, board) can open them through `useModalStore`. Detail page
detects its own deletion via a query-transition effect, avoiding the
need to smuggle callbacks through the store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): hover and active styling on list rows and board cards
Mirror the sidebar's same-color/different-intensity pattern for the new
right-click context menu states. Base UI adds `data-popup-open` to the
ContextMenuTrigger when the menu is open; `hover:not-data-[popup-open]`
suppresses hover feedback on the already-active item.
List rows apply the pattern directly to background color (`accent/60`
hover, `accent` active). Board cards additionally modulate the card's
border and a lighter background tint (`accent/20` hover, `accent/40`
active) so the card's own bg/border/shadow identity stays intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(modals): show target issue banner in SetParent/AddChild pickers
When triggered from an issue's action menu, the IssuePickerModal now
displays a banner at the top showing "Setting parent of" / "Adding
sub-issue to" followed by the originating issue's status, identifier,
and title. Previously the operation target was only implied by the
modal's sr-only title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(modals): create-issue gains ⋯ overflow menu with parent issue linkage
Add a dropdown-menu with "Set parent issue..." / "Remove parent" at the
end of the property pill row. The ⋯ button is always the last DOM child
of the row so it stays at the tail even when the row wraps to multiple
lines. Menu state reflects current selection — unset shows a single
"Set parent…" entry, set shows the current identifier plus a separate
Remove option.
When a parent is set (either via the new menu or via `data.parent_issue_id`
from a "Create sub-issue" trigger), a chip appears in the pill row
showing "Sub-issue of {identifier}" with the same click-to-change /
click-×-to-clear semantics. This replaces the old header breadcrumb
disclosure that was neither editable nor visible in the form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(issues): group relationship actions under "More" submenu
Nest Create sub-issue / Set parent issue / Add sub-issue inside a
`More >` submenu in the issue actions menu (both Dropdown and
Context variants). Top-level keeps Status/Priority/Assignee/Due date
category submenus plus Pin and Copy link; the relationship ops are
lower-frequency and will grow with future relation types (blocks,
duplicates, related) that fit the same category.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(modals): create-issue adds Add sub-issue with deferred linking
The create modal's ⋯ menu gains an "Add sub-issue..." entry that queues
existing issues as children of the new one. Picked issues appear as
chips in the pill row (downward arrow, distinct from the upward parent
chip), each individually removable.
Linking is deferred because the new issue's ID doesn't exist at pick
time. Once createIssueMutation resolves, we run updateIssueMutation
for every queued child in parallel and surface any partial failures
via toast — the new issue itself is already committed and never rolls
back. Parent and child pickers exclude each other so a single issue
can't occupy both relations simultaneously.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(issues): add MoreHorizontal icon to "More" submenu trigger
The "More" label was visually misaligned because every other top-level
entry has a leading icon. Use MoreHorizontal (same icon as the outer ⋯
trigger — semantically "more options, nested") and drop the `inset`
padding hack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(modals): drop target-issue banner from IssuePickerModal
The banner sat directly above the search input and rendered the target
issue with bolder styling than the "Setting parent of" / "Adding sub-issue
to" caption, which made it read like a pre-selected search result rather
than a context label. Users opening the modal from a menu item already
carry the context, so the extra chrome was redundant.
Remove the contextIssue / contextLabel API from IssuePickerModal and
drop the now-unused issueDetailOptions query in SetParentIssueModal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(modals): exclude current parent from create-issue parent picker
Re-opening the parent picker to change the already-set parent used to
show that parent in the results — picking it was a silent no-op. Mirror
the child picker's exclude-list construction so the current parent is
always filtered out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docs-site): add @multica/ui bridge and dev:docs script
Link @multica/ui as a workspace dep of @multica/docs so the docs app can
consume the shared design tokens (tokens.css, base.css) via a relative
import — same pattern the web and desktop apps use. Add a top-level
pnpm dev:docs script for a one-command docs dev server (port 4000).
Preparation for the docs site rewrite tracked in docs/docs-outline.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): apply Multica tokens and pure-sans typography
Replace Fumadocs' neutral color preset with a @theme inline bridge that
maps the --color-fd-* chrome tokens to Multica's --background / --foreground
/ --border / --sidebar-* etc. Sidebar, nav, cards now pick up Multica's
cool-gray palette automatically, and switching Multica's .dark flips
Fumadocs chrome with it.
Typography: pure sans (36px / weight 600 / tight tracking h1, h2+h3 tuned
to match), landing continuity without serif display.
Code blocks: pinned to near-black (oklch(0.12 0.01 250)) regardless of
page theme so they read as a continuation of the landing hero surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plan): add rewrite plan and outline tracker
Two planning documents for the docs site rewrite:
- docs/docs-rewrite-plan.md — strategic rationale (positioning, reader
personas, design principles, visual direction, phase breakdown).
- docs/docs-outline.md — execution tracker. 25 v1 pages with per-page
entries (source files, audience, what-to-write, what-not-to-write,
⚠️ verify-before-drafting). Workflow: claim via Owner + Status,
read source, verify checklist, draft, review, ship.
Language: zh only for v1. Outline is the source of truth for scope and
status; the earlier "EN first, ZH as Phase 10" line in rewrite-plan.md
is superseded.
Welcome (§1.1) is claimed under this tracker and currently in 👀 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(docs-site): write first Welcome page (zh) — §1.1
Implements §1.1 Welcome per docs/docs-outline.md. Chinese-first (per
outline language decision); terms translated to their clearest Chinese
equivalents (issue → 任务, agent → 智能体, daemon → 守护进程, etc.),
product proper nouns and commands kept in English.
Voice: reference-style, not marketing. Follows google-gemini/docs-writer
skill rules (BLUF opener, second-person, active voice, no hype, overview
prose before every list).
Content:
- Opens by describing Multica as a 任务协作 platform and how humans + AI
智能体 share the same 工作区
- Two interaction modes: 分配任务 and 聊天
- 智能体在哪里运行: local daemon (today), cloud runtime (soon, waitlist).
10 providers listed from source (server/pkg/agent/*.go).
- Three usage paths split into back-end (Cloud / Self-host) and client
(Desktop) choices — Desktop bundles CLI and auto-starts daemon.
- Status: 👀 In review.
Also simplifies content/docs/meta.json to just ["index"] (placeholder
page entries removed; IA skeleton will be populated in Phase 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docs-site): wire up client-side Mermaid rendering
Add a <Mermaid> React component under apps/docs/components/ that dynamic-
imports the mermaid package in useEffect and renders the resulting SVG.
Deps added: mermaid@^11.14.0 and next-themes@^0.4.6 (transitively present
via fumadocs-ui but needs explicit declaration to be importable).
Design choices:
- Client-side render (not build-time). No Playwright / browser automation
in CI. Mermaid bundle (~400 KB) is loaded only on pages that use the
component, thanks to the dynamic import.
- Theme flips automatically — useTheme() from next-themes re-invokes
mermaid.initialize() with the correct theme on .dark toggle.
- SSR safe: the component returns a "Rendering diagram…" placeholder on
the server; the SVG appears after hydration.
- securityLevel "strict" — diagrams render as static SVG with no inline
script or event handlers.
Usage in mdx (explicit import, same pattern as Cards/Callout):
import { Mermaid } from "@/components/mermaid";
<Mermaid chart={`
graph LR
User --> Server
`} />
Verified by a scratch /app/mermaid-test/ route that compiled to 4665
modules and returned HTTP 200 (cleanup done pre-commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): adopt v2 editorial palette and typography
Replace the Linear/Vercel-style cool-gray token override with a warm
editorial palette (bg matches landing #f7f7f5, brand-color primary via
Multica's existing --brand hue 255) and wire Source Serif 4 for heading
typography. Italic is avoided sitewide — Chinese italic renders as a
synthetic slant against upright-designed glyphs and reads as broken;
emphasis is carried by serif/sans contrast, brand color, and weight.
Sidebar adopts the product app's active-fill pattern (solid
sidebar-accent background, no ::before mark). Code blocks drop the
always-dark hero treatment and follow page theme so the reading column
stays coherent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): add editorial MDX components
New components/editorial.tsx exposes Byline, NumberedCards/NumberedCard,
and NumberedSteps/Step — the "wow moment" pieces from v2-editorial
(ruled-divider bylines, No. 01 serif card numbering, large serif step
counters). All escape prose via not-prose so they run their own type
scale.
DocsHero is rewritten as an editorial showpiece: title accepts ReactNode
so callers can pass a brand-color em accent, eyebrow becomes a small
uppercase sans label, lede uses serif at 20px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(docs-site): rewrite welcome page as editorial showpiece
Welcome page now opens with an editorial hero (eyebrow + serif h1 with
brand-color em accent on "共处一方。" + serif lede), a ruled byline
strip carrying the section / updated / read-time metadata, and then
flows into prose.
The three deployment paths switch from fumadocs's <Cards> to
<NumberedCards> so each gets a No. 01/02/03 label, and the "next steps"
list becomes a <NumberedSteps> block with large serif counters. These
are the highest-impact visual moments on the page; the rest of the
guide pages still get the global editorial chrome without needing
per-page code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(docs-site): add bilingual flat content tree with i18n routing
Restructures the docs site from nested topic folders (cli/, getting-started/,
developers/, guides/) into a flat content tree, and adds Chinese alongside
English. The old nested structure forced contributors to think about both
the topic AND the user-journey grouping; the flat tree lets a single
meta.json control reading order with separator labels, and lets the same
slug serve both languages via the `foo.zh.mdx` parser convention.
Routing
- New `app/[lang]/` segment hosts layout, home, slug page, and not-found
- Self-contained basePath-aware middleware (fumadocs's built-in middleware
isn't basePath-aware, so its rewrite/redirect targets break under /docs)
- `hideLocale: 'default-locale'` keeps English URLs prefix-less; Chinese
lives under /docs/zh/
- Sitemap excluded from middleware matcher so crawlers don't get rewritten
into a non-existent locale-prefixed sitemap route
- Default-language redirect preserves search string (UTM safety)
- Home page declares its own generateStaticParams (Next layout params
don't cascade) so /docs/ and /docs/zh are SSG, not dynamic per request
SEO
- New app/sitemap.ts emits hreflang alternates for every page
- absoluteDocsUrl normalizes the home `/` so canonical URLs don't carry a
trailing slash that mismatches the page's own canonical link
- apps/web/app/robots.ts now advertises the docs sitemap
Search
- CJK tokenizer registered for the zh locale (Orama's English regex strips
Han characters; without this Chinese search either returns empty or
throws)
Chrome
- Custom DocsSettings replaces fumadocs's default icon-only sidebar footer
with two labelled buttons (language + theme), matching the editorial
design language
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The live timeline was rendered in a separate <div> from the persisted
messages list. When the streamed task finished and its ChatMessage
landed, the live <div> unmounted and a new <MessageBubble> mounted —
two different DOM elements showing the same content. useAutoScroll's
ResizeObserver + MutationObserver fired on both the unmount and the
mount, causing the visible jump-then-re-render.
Merge the two paths: inject a synthetic assistant message with the
pending task_id while streaming, and key every assistant bubble by
task_id. When the real message arrives (same task_id), React preserves
the DOM element across the invalidate → refetch window — no remount,
no double scroll, no flicker.
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
The ChatInput wrapper toggled between pb-8 (empty state) and pb-4
(has messages), causing a 16px vertical jump the moment hasMessages
flipped. EmptyState already centers itself inside flex-1, so the
extra padding wasn't needed — collapse to a single pb-4.
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* feat(chat): Chat V2 — sidebar entry + main-area page
Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).
- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
`ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
— the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
(react-router + tab-store icon mapping).
Addresses MUL-1322.
* fix(chat): align composer width with message column
The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.
* fix(chat): taller default composer (~3 lines visible, 8 max)
min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.
* fix(chat): restore anchor + in-flight indicator + cold-start session restore
Three issues surfaced by review:
1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
only matches issue/project/inbox pathnames, so moving chat to its own route
dropped 'bring the page I was on into the conversation'. Track the last
anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
+ lastAnchorLocation on useChatStore) and substitute it when on /chat.
2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
spinner while any chat task is in flight.
3. ChatPage restore effect latched didRestoreRef before the sessions query
resolved, so cold-start direct nav to /chat landed on the empty state even
when the server had an active session. Wait for isSuccess before locking
the ref.
* fix(chat): clear lastAnchorLocation on workspace rehydration
The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* feat(daemon): harden agent mention-loop instructions
Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.
- Replace the terse "mentions are actions" blurb with a full Mentions
protocol: `side-effecting` warning, explicit "when NOT to mention"
(replying to another agent, sign-offs, thanks) and "when a mention IS
appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
whether a reply is warranted at all, decide whether to include any
`@mention`, and clarify that the post-a-comment rule is mandatory *if*
you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
(`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
When the author is another agent, both surfaces now name that agent
and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
mention format — the word generalized to member/agent mentions and
encouraged the very behavior that causes loops.
Refs GH#1576, MUL-1323.
* fix(daemon): remove MUST-respond conflict and sanitize trigger author name
Addresses two blocking points on PR #1581:
1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
and unconditionally appended the reply command — directly conflicting
with the new agent-to-agent silence-as-valid-exit workflow. Models
were likely to keep following the older must-reply rule and fall back
into the loop this PR is trying to close.
Rewrite the header as "Focus on THIS comment — do not confuse it
with previous ones" (keeps the anti-stale-comment signal) and change
BuildCommentReplyInstructions to open with "If you decide to reply,
post it by running exactly this command" so the reply command is
available but conditional across both prompt surfaces.
2. Raw agent/user display names were being embedded directly into the
high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
member names are only validated as non-empty at write time, so a
name containing newlines, backticks, or fake mention markup would
turn the field into a cross-agent prompt-injection surface.
Add execenv.SanitizePromptField — strip control runes, collapse
whitespace, drop markdown structural characters (backtick, asterisk,
brackets, pipe, angle brackets, hash, backslash), truncate to 64
runes — and apply it at both embed sites (per-turn prompt and
CLAUDE.md). Defense-in-depth at the consumption layer so this works
for already-stored names without a migration.
Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.
* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer
Per PR #1581 feedback:
1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
runtime_config.go. It duplicated what workflow steps 4 and 5 already
say ("Decide whether a reply is warranted", "Never @mention the
agent you are replying to as a thank-you or sign-off"), so the
signal lands the same without the extra ~7 lines of CLAUDE.md. The
per-turn prompt preamble in prompt.go stays — that surface has no
numbered workflow below it and would otherwise lose the
silence-as-exit signal.
2. Delete execenv.SanitizePromptField + its test. Workspace agents are
created by trusted team members, so the cross-agent name-injection
surface it defended isn't realistic in the current trust model.
3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
populating them in daemon.go — they're no longer read by the
execenv package. The same fields on daemon.Task stay because
prompt.go still needs them to label the triggering author in the
per-turn prompt.
Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
* feat(agents): show profile card on agent avatar hover
Hovering an agent avatar now opens a preview card with name, status,
runtime mode + connectivity, model, skills, and owner. Wired through
the shared ActorAvatar wrapper so every render site gets it; opt-out
via disableHoverCard in pickers and the agent's own detail header
where the card would be redundant or interfere with click selection.
* fix(agents): keyboard-focusable hover card + opt out on settings avatar
- Make the agent profile-card hover trigger focusable (tabIndex=0 with
visible focus ring), so keyboard users can open the card. Drops
cursor-default so the trigger inherits the parent control's cursor
instead of fighting it.
- Disable the hover card on the agent settings avatar — it's a
click-to-upload target on the agent's own settings page, where the
card would be redundant and the trigger conflicted with the upload
affordance.
* fix(agents): scope hover-card tab stop to standalone avatars only
Detect a focusable ancestor (link/button/role=button/tabindex>=0) at
mount and only flip the agent profile-card trigger to tabIndex=0 when
none exists. Avatars rendered inside an existing focusable parent (issue
list rows wrapped in AppLink, button-style cards, etc.) keep the trigger
unfocusable so they don't add redundant nested tab stops or bloat
keyboard navigation. Standalone avatars (e.g. comment author, issue
detail meta) remain keyboard-accessible with a focus-visible ring.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
All chart components used `hsl(var(--chart-X))` but `--chart-X` holds a
full oklch value, not bare HSL components — making the expression invalid
CSS. Browsers silently fell back to black, so bars/areas/heatmap cells were
invisible against the dark background.
- Replace `hsl(var(--chart-X))` with `var(--color-chart-X)` across all
runtime chart components and the landing feature section
- Fix heatmap opacity using `color-mix(in oklch, ...)` instead of the
invalid `hsl(var(--chart-3) / 0.3)` syntax; switch to foreground color
so cells blend with the neutral theme in both light and dark mode
- Raise dark-mode chart-2 through chart-5 lightness values so they
contrast clearly against the dark background
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous note claimed the frontend's auto-derived WebSocket URL
worked on LAN without extra configuration. It does not: Next.js
`rewrites()` only proxy HTTP requests, so the `Upgrade` handshake
required for WebSocket never reaches the Go backend, and real-time
features (chat streaming, live issue updates, notifications) silently
fail when accessing the app via a non-localhost host.
Replace the incorrect sentence with a dedicated subsection that points
users at the reverse-proxy recipe (already documented above, includes
the correct /ws Upgrade headers) and, for setups without a proxy,
documents the build-time NEXT_PUBLIC_WS_URL + selfhost.build.yml
override path.
Refs: GH #1522
The expand button relied on the parent row's inherited color, which
flipped to text-foreground via group-focus-within while the editor was
focused. The attach and submit buttons set text-muted-foreground on
themselves and stayed muted regardless of focus, so expand was the only
one changing color — inconsistent with the "default muted" convention
the other icon-buttons in this editor follow.
Give expand its own text-muted-foreground and drop the now-unused color
classes from the button row container.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(daemon/gc): tighten GC defaults + flex duration suffix
Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy
AI-coding usage): the existing TTLs were sized for desktop/laptop
deployments and are too lenient for small-disk, long-running daemons.
- GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day
grace period in AI-coding workflows.
- GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories
without a month-long wait.
- Issue-deleted orphans (API returns 404) are now cleaned on the next GC
cycle regardless of mtime. The issue row is gone; there is nothing
left to protect.
- parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib
time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only
120h was accepted.
* fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser
Two issues flagged in PR review:
1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for
both "issue deleted" AND "daemon token has no access to the workspace"
(anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404
would let a scoped-down daemon token wipe taskDirs whose issues are
still live. Restore the mtime gate against GCOrphanTTL. With the new
72h default we still shrink the original 30d window dramatically
without the cross-workspace hazard. Lock the behavior in with a new
test that asserts a recent 404 is skipped.
2. parseFlexDuration mishandled decimals and swallowed Atoi errors:
"0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s,
and 20+ digit day values Atoi-errored silently to 0. Match the full
decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so
fractional days and oversized inputs both go through
time.ParseDuration correctly — fractions as sub-hour durations,
overflow as a returned error.
Review follow-up on PR #1557: the server-side change started returning
500 when the store write failed, but the daemon's handleLocalSkillList /
handleLocalSkillImport were discarding the ReportLocalSkill*Result error
return. Net effect was a silent drop — the daemon moved on, the request
stayed in "running" on the server, and the user saw the same "daemon did
not respond within 30 seconds" timeout the store refactor was supposed
to kill.
Fix: route both report calls through reportLocalSkillResultWithRetry,
which retries on 5xx + network errors with 0 / 0.5s / 2s / 4s backoff
(total ~6.5s, well inside the 60s server-side running timeout), stops
on 4xx (request expired / cross-workspace rejection — retry won't help),
bails on context cancel, and logs Error on exhaustion so ops has a
footprint to grep for.
Tests (server/internal/daemon/local_skill_report_test.go, 6 new cases):
- 500 twice then success -> 3 attempts, second retry lands
- 404 -> exactly 1 attempt (permanent, no retry)
- import 502 then success -> 2 attempts
- All-500 -> burns through all backoff slots then gives up with ERROR log
- Context cancel mid-backoff -> exactly 1 attempt, cancellation logged
- Smoke: report paths hit /api/daemon/runtimes/<rt>/local-skills{,import}/<req>/result
localSkillReportBackoffs is var-assignable so tests can swap in zero-delay
schedules without paying real sleep latency.
#1558 fixed the expand button covering trailing text, but also collapsed
the reply editor's "empty = 1 line, has content = 2 lines" behavior by
making the button row a permanent flex sibling below the editor.
Restore the original absolute-positioned button row on both editors:
- comment-input: back to `pb-8` container + `absolute bottom-1 right-1.5`
buttons (pre-#1558 layout; never had the overlap bug).
- reply-input: absolute buttons + `pb-7` gated on `!isEmpty || isExpanded`.
Empty → single-line compact; any content → two-row layout with buttons
below text (no overlap by construction).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): suppress agent terminal windows on Windows (#1471)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add hideAgentWindow to detectCLIVersion and avoid SysProcAttr overwrite
- Add missing hideAgentWindow(cmd) call in detectCLIVersion (claude.go:554)
so --version checks don't flash console windows on Windows.
- Refactor hideAgentWindow to preserve existing SysProcAttr fields
instead of overwriting the entire struct.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add an ArrowUpRight glyph next to Docs and Change log to signal they
open externally, and reorder so Feedback (internal modal) sits at the
bottom.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): shared-state runtime local-skill stores (MUL-1288)
Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.
Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.
What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
(score = created_at UnixNano) providing cross-node ordering. PopPending
wins the claim via ZREM == 1, so concurrent pops from different nodes
never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
Redis-backed stores. main.go hoists the existing Redis client (already
used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
Redis operations inherit request cancellation. Errors warn instead of
poisoning the heartbeat response.
Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
- Create/Get/Complete round trip preserves skills payload
- PopPending across two *store instances sharing one rdb (the exact
regression: node A creates, node B pops)
- N concurrent PopPending on one record => exactly one winner
- Pending-timeout threshold transitions the record and removes the zset
member so a later PopPending doesn't return a timed-out request
- Import store round-trips CreatorID (which is json:"-" on the public
struct — needs a Redis envelope so ReportLocalSkillImportResult can
still attribute the created Skill)
- Per-runtime isolation — a PopPending for runtime B does not disturb
A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
redis:7-alpine service and exports the URL so the suite actually runs
there.
Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.
* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)
Two real gaps GPT-Boy flagged:
1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
two separate round-trips. If the SET failed for any reason — transient
Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
request was already gone from the pending zset but the stored record
still said "pending", and no subsequent PopPending would re-dispatch
it. Exactly the "request disappears" class of bug this PR is supposed
to kill.
Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
atomic unit. If ZREM returns 0 (another node won the race), SET is
skipped and the caller retries.
2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
store failures at Warn and still returning 200 OK. That made the
daemon think the report landed when it hadn't, leaving the request
stuck in "running" until the server-side timeout and — worse for the
import flow — leaving the just-created Skill row orphaned in Postgres
so every retry collided with the unique-name constraint.
Fix: escalate to Error + return 500 so the daemon (and monitoring) can
see the write failed. For the import flow, Complete failure after the
Skill row is already committed also triggers a best-effort DeleteSkill
so a daemon retry lands on a clean slate instead of hitting
"a skill with this name already exists" forever.
Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
happy-path invariant: after one PopPending the record is "running"
AND a second PopPending returns nothing. Deliberately does NOT poke
Redis internals directly so the test survives any future key-layout
refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
continue to pass against the Lua-based claim path (verified locally
against a scratch redis-server; 8/8 Redis tests green).
* fix(cli): make browser-login work from a machine that isn't the server
The #923 callback host fix only worked when the CLI and the self-hosted
server ran on the same box. In a cross-machine setup — `multica login`
from a laptop against a self-hosted server on a NAS — the flow silently
wedged on two issues:
1. The callback host was derived from `--app-url`, so the `cli_callback`
URL pointed at the server's IP and the browser could never reach
the CLI's local listener on the laptop. The OAuth token never came
back and subsequent `/api/workspaces` calls 401'd on stale state.
2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket.
Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and
get "connection refused" even when the URL is otherwise correct.
Changes:
- Derive the callback host from the CLI's own outbound interface by
dialing the server (UDP, no packets sent — just asks the kernel which
source IP it would use). Falls back to loopback for public app URLs
and to the app IP for offline detection.
- Add `--callback-host` flag on `login` and `setup self-host` so
reverse-proxy / FQDN users can override auto-detection — this is the
follow-up @hassaanz asked for on #923.
- Pin the callback listener to `tcp4` so macOS never lands on an
IPv6-only socket.
- `multica setup self-host`: when the user explicitly passes a remote
`--server-url` but omits `--app-url`, infer app URL from the server
host and warn instead of silently defaulting to `localhost:3000`.
Unit tests cover the binding-decision matrix (public, localhost, same-
machine LAN, cross-machine LAN, outbound-detect failure, flag override)
and the new setup helpers.
Reported by @RafeRoberts in #1494 with very clear repro details.
* fix(cli): prompt for app_url instead of guessing on remote server_url
Per GPT-Boy's review on MUL-1260: deriving app_url as
http://<server-host>:3000 breaks for the common api.example.com +
app.example.com split and for https-fronted deploys — the setup flow
would still open a broken login URL, just slightly later.
Replace the guess with an interactive prompt. If the user hits enter
(or stdin is unavailable), fail loudly with a clear usage hint instead
of proceeding with bad data.
The comment and reply editors positioned their three trailing buttons
(expand, attach, submit) with `absolute` and relied on `pr-14` /
`pb-8` magic numbers to reserve space. The reserved 56px is smaller
than the actual 80px button row, so the leftmost button (expand)
visibly overlaps the trailing characters of a long line of text.
Restructure the button row as a normal flex sibling below the editor.
Text can no longer flow under the buttons, and the layout no longer
needs the `pr-14` hack, `pb-8` padding, or the ResizeObserver that
toggled `pb-7` when content overflowed.
Also align the expand button in comment-input with the reply-input
version (`h-6 w-6` + `h-3.5 w-3.5` icon) so the two entry points
match.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(runtimes): remove Test Connection / runtime ping feature
The Test Connection action invoked a real single-turn agent run to verify
runtime connectivity. In practice it was expensive (reuses none of the
normal task exec env, so it also gave misleading results) and low value —
daemon heartbeat + Online status already covers the "is the runtime
alive" question. Dropping the whole end-to-end probe path:
- deletes server handler and in-memory PingStore
- drops pending_ping from the heartbeat response and daemon poll loop
- removes daemon.handlePing, PendingPing, ReportPingResult
- removes the CLI `multica runtime ping` command
- removes the PingSection UI block and RuntimePing types / api methods
* docs: fix runtime CLI subcommand list in product-overview
Answers "did the user have an AI CLI installed locally when they hit
Step 3" — currently unanswerable from the existing funnel because the
bundled daemon fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits the 40% of
`completion_path=runtime_skipped` into "had CLIs, skipped anyway" vs "no
CLIs available, had no choice" — the two cases need opposite product
fixes.
Fires once per Step 3 mount in `step-runtime-connect.tsx` (desktop
only), when the scanning phase resolves — either immediately on first
runtime registration or after the 5 s empty timeout. Reports
`runtime_count`, `online_count`, sorted `providers`, convenience
booleans (`has_claude` / `has_codex` / `has_cursor`), and `detect_ms`.
Also writes `has_any_cli` + `detected_cli_count` via `$set` as cohort
signals.
Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web users
don't run the bundled daemon, so their runtime list can reflect
daemons on other machines and would corrupt the
"CLI installed locally" signal.
Refs MUL-1250.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(landing): add sticky date navigation to changelog page
Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").
* feat(landing): move changelog date nav to left as timeline sidebar
Moves the date navigation from the right to the left and restyles it
as a grouped timeline:
- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
many releases; on <lg the sidebar collapses and content falls back
to the existing centered layout.
- Entry headers now render the full localized date for clarity.
Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.
* fix(landing): align changelog nav day/version columns
Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.
---------
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* fix(agents): drop auto-loading Local Runtime Skills section from Skills tab
Every visit to an agent's Skills tab fired POST
/api/runtimes/<id>/local-skills + a polling GET, which:
- Created noise on every tab open (the section was rarely the user's
reason for entering the tab — they came in for workspace skills).
- Currently 404s under the dev backend's multi-replica deploy because
the runtime-local-skills request store is in-process; the polling
GET frequently lands on a different replica than the POST. The
protocol fix is tracked separately; this PR just stops the
unsolicited polling.
Removes the entire `Local Runtime Skills` inline section, the
`runtimeLocalSkillsOptions` query, and the per-skill Import dialog
mount on this tab. Users who want to import a local skill go through
the Skills page's `+ Add Skill` → `From Runtime` tab — the same flow
that handles all other skill creation, only triggered explicitly.
Top blue callout stays — still accurate: local runtime skills are
auto-available to the agent, importing creates an editable workspace
copy.
* test(agents): replace stale Local Runtime Skills assertion with negative case
The previous test required the inline section + auto-loading runtime
local skills query, both removed in this PR. Replace it with a
regression test that asserts the section is gone, the per-row import
button is gone, and the top informational callout still renders so we
know the tab body actually mounted.
Drops the now-unused @multica/core/runtimes mock; if a future change
re-introduces that import, the missing mock would surface immediately.
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics
Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138.
This change is intentionally behavior-preserving: it sets up the seams
needed for later phases (subscribe/unsubscribe protocol, scope-level
fanout, Redis Streams relay) without altering any wire protocol or
producer call sites.
What changed
- New realtime.Broadcaster interface covering the three fanout methods
producers already use on *Hub (BroadcastToWorkspace, SendToUser,
Broadcast). *Hub continues to satisfy it; a future Redis-backed
implementation can be dropped in without touching listeners.
- registerListeners now depends on realtime.Broadcaster instead of
*realtime.Hub, isolating the bus → realtime fanout layer behind an
interface.
- New realtime.Metrics singleton with atomic counters: connects,
disconnects, active connections, slow-client evictions, total
messages sent/dropped, and per-event-type send counters. Wired into
Hub register/unregister/broadcast paths and into every listener.
- New GET /health/realtime endpoint returning a JSON snapshot of the
metrics so we can observe baseline fanout pressure before phase 1.
Why phase 0 first
GPT-Boy's only-Redis plan and CC-Girl's review both call out the same
prerequisite: get a Broadcaster seam and visibility in place before
introducing scope-level subscriptions or a Redis relay. Doing this as
a standalone step keeps each later PR focused and trivially revertable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138)
Implements the final-version plan agreed in MUL-1138 on top of phase 0:
* Hub: 4 scope types (workspace/user/task/chat), per-client subscription
set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for
task/chat scope auth, first/last-subscriber callbacks for the relay,
workspace+user auto-subscribe on connect.
* RedisRelay: Broadcaster impl that XADDs every event into
ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which
this node has live subscribers. Per-node consumer group, heartbeat,
stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics.
* Listeners: route task:* events to ScopeTask, chat:* events to
ScopeChat; workspace remains the default for everything else.
* events.Event: optional TaskID / ChatSessionID hints so the listener
layer can pick the right scope without re-parsing payloads.
* Handler: publishTask / publishChat helpers; chat + task message
publishers updated to use them.
* main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and
pass the relay (instead of the hub) to registerListeners. A
db-backed ScopeAuthorizer enforces that task/chat subscribes belong
to the caller's workspace.
* Metrics: per-scope subscribe/deny counters, redis connect state, node
id, lag/dropped counters surfaced via /health/realtime.
Behavior in single-node mode (REDIS_URL unset) is unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(realtime): address PR #1429 review must-fix items (MUL-1138)
- listeners: keep task/chat events on workspace fanout until the WS
client supports scope-subscribe + reconnect-replay. Routing them
through BroadcastToScope today (without any client subscriber) would
silently drop every chat / task message and break the live timeline,
chat unread badges, and pending-task UI. The server-side scope infra
(Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay)
stays in place so flipping the switch in the client follow-up PR is
a one-line change.
- scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring
the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage /
MarkChatSessionRead). Without this, any workspace member who learned a
session_id could subscribe to chat:message / chat:done /
chat:session_read for a peer's private chat. The same creator-only
check is applied to ScopeTask when the task is a chat task
(task.ChatSessionID set). Issue tasks remain workspace-scoped.
- Refactor scope authorizer to depend on a narrow scopeAuthQuerier
interface so its decisions can be unit-tested without a live DB.
- Add tests:
* listeners_scope_test.go pins the workspace-fanout fallback for
task:message / task:progress / chat:message / chat:done /
chat:session_read.
* scope_authorizer_test.go covers chat creator-only access, chat-task
creator-only access, and issue-task workspace-only access (creator
allowed, peer denied, cross-workspace denied, missing session
denied, empty userID denied).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig
- hermes.go: include opts.Model in session/new params so Hermes uses
the configured model instead of its default (fixes local LLM failures)
- runtime_config.go: add "hermes" to the AGENTS.md provider list so
Hermes receives the Multica runtime instructions and skill discovery
Fixes: https://github.com/multica-ai/multica/issues/1195
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(hermes): drop false native-skill claim and add regression tests
The previous change added 'hermes' to the 'skills discovered automatically'
branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so
skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up
claiming native discovery while the files were somewhere else, which would
mislead Hermes (and future debuggers).
- Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points
Hermes at .agent_context/skills/ — matching where writeContextFiles actually
writes them.
- Extract buildHermesSessionParams so the session/new payload is unit-testable.
- Add regression tests covering:
* buildHermesSessionParams includes/omits 'model' correctly
* InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint
* writeContextFiles('hermes') writes skills to .agent_context/skills/
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
DesktopNavigationProvider stubbed `searchParams` to an empty
URLSearchParams, so any shell-level consumer of useNavigation() that
looked at query params read blanks. The miss surfaced in focus-mode:
on /inbox?issue=<id>, ChatWindow's useRouteAnchorCandidate couldn't
see the selection, so the Focus button stayed disabled.
Mirror the full location (pathname + search) from the active tab's
router — same subscription pattern TabNavigationProvider already uses
~30 lines below. InboxPage itself was fine because it's rendered
inside TabNavigationProvider; the bug only hit components mounted at
the shell root (ChatWindow, ChatFab, and any future sibling).
No test: the fix is an identical copy of a production-shipped pattern
in the same file, and the mock surface needed to exercise the adapter
(useActiveTabRouter + memory router + tab store) exceeds the fix
itself. Verified via pnpm typecheck across all packages.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(feedback): add in-app feedback flow and Help launcher
Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(feedback): harden submit path and cap request body
- Read editor markdown via ref at submit time instead of debounced state,
so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
user to wait instead of silently sending markdown with blob: URLs
that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
authenticated client can't bloat the metadata JSONB column with an
oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
and the hourly rate limit boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): instrument feedback funnel
Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:
- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
Source is currently always "help_menu" but the type is a union so
future entry points have to extend it explicitly. Workspace id is
attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
DB insert succeeds and the hourly rate-limit check has passed.
Message content itself is never sent to PostHog; the event carries
a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
image-presence flag, and the client platform / version pulled from
X-Client-* headers via middleware.ClientMetadataFromContext.
Affects no existing funnel; seeds a new Feedback funnel for product
triage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md is loaded into context every conversation; verbose race-condition
post-mortems and code-organization rationales rot fast and crowd out the
actionable rules they were meant to support. Strip the archaeology, keep the
load-bearing constraints.
- Workspace identity singleton + destructive ops (~22 -> 11 lines): keep the
"must call setCurrentWorkspace(null, null) when leaving context" rule and
the 4-step destructive order; drop the three-way race autopsy (already
documented inline in workspace-tab.tsx where it belongs).
- Drag region (~27 -> 3 lines): keep "every full-window desktop view must
mount <DragStrip /> as first flex child"; drop hit-testing rationale,
canonical-file inventory, and useImmersiveMode escape-hatch trivia.
- UX vs platform chrome (~3 -> 0 lines): delete entirely. The rule
duplicates "Cross-Platform Development Rules" above; the rest is purely
why-we-organized-it-this-way narrative.
Common Zustand footguns kept as-is - both items are real rules (stable
selector references, hooks accepting wsId as parameter), not archaeology.
Net: -36 lines, no rule lost.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(landing): scope landing route to always-light palette
The landing page sections use hardcoded light colors (bg-white / #0a0d12),
but shared components rendered inside — notably CloudWaitlistExpand on
/download — use semantic tokens that flip to dark values under next-themes'
`.dark` class, producing a mismatched dark card on an otherwise light page
when the user's OS is in dark mode.
Add a `.landing-light` class on the landing layout wrapper that re-declares
all color tokens to their light values for the subtree, so nested
token-driven components stay in lockstep with the hardcoded palette.
* test(agent): serialize fake-executable writes to avoid ETXTBSY on CI
TestKimiBackendInvokesACPSubcommand (and its Kimi/Codex siblings) write a
shell script to a per-test TempDir and then fork/exec it. With t.Parallel()
enabled across the package, a concurrent goroutine's fork can inherit the
still-open write fd to another test's new executable; Linux then rejects
the subsequent exec with ETXTBSY (seen as
fork/exec /tmp/.../kimi: text file busy
on GitHub Actions).
Introduce writeTestExecutable, which holds syscall.ForkLock.RLock across
OpenFile→Write→Close. Fork (which takes ForkLock.Lock) cannot run while we
hold RLock, so no sibling fork inherits our write fd. Ran the three callers
with -count=10 under -p=1 and the full package with no failures.
Adds a doc comment on GetConfig spelling out that the endpoint is mounted on
the unauthenticated route group (so the login page can fetch GoogleClientID /
AllowSignup before the user is signed in) and that only instance-level public
fields may be added. Prevents accidentally returning user- or tenant-scoped
data from this handler in the future.
The workspace query defaults `data` to `[]` before the first fetch, so the
bootstrap effect ran with an empty valid-slug set, wiped the persisted
`activeWorkspaceSlug`, then fell back to `workspaces[0]` once the real list
arrived — dropping the user on the default workspace on every launch.
Gate the effect on `workspaceListFetched` so validation runs only against
the real list, and re-read the store after `validateWorkspaceSlugs` to
avoid acting on a stale snapshot.
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
* docs(changelog): publish v0.2.14 + v0.2.15 release notes
Summarises the 25 commits shipped today across both releases for the public changelog page, in English and Chinese.
* docs(changelog): merge v0.2.14+v0.2.15 into one entry, trim, reclassify Gemini as fix
Per review: today's two releases read better as one set of notes; tightened
bullets; moved the Gemini 3 runtime-list update from Features to Fixes.
* docs(changelog): drop last 3 features from v0.2.15 entry per review
Follow-up to #1453. That PR fixed the Tasks tab crash by filtering empty
issue_id out of the detail lookup and rendering a neutral "Task without
linked issue" label, but every issue-less task — chat-spawned or
autopilot-spawned — looked the same. The server already stores the
origin in `agent_task_queue.chat_session_id` / `autopilot_run_id`; only
the HTTP serializer was dropping them.
Server:
- `taskToResponse` now populates `ChatSessionID` and the new
`AutopilotRunID` on `AgentTaskResponse`. Backward compatible: both
omit when UUID is invalid, and existing clients ignore unknown
fields.
Types:
- `AgentTask` (TS) gains `chat_session_id?` + `autopilot_run_id?` and a
comment clarifying when `issue_id` is empty.
Tasks tab:
- Row label for issue-less tasks is picked from the populated source
field: "Chat session" for chat tasks, "Autopilot run" for autopilot
tasks, "Task without linked issue" as the neutral fallback. Rows stay
inert (no anchor) in all three cases; existing issue-linked path is
unchanged.
Tests:
- Two new regression tests assert the chat and autopilot labels render
correctly and neither row becomes an anchor. Existing neutral-label
test stays as the "neither source populated" case.
'discoverOpenclawAgents' runs several 'openclaw' subprocesses under one
context; 5s was too short on cold starts or under load, causing empty
listings in the model picker. Increase the per-discovery cap to 30s.
PR #1502's IssueChip extraction moved the `issue-mention` class from the
outer <a> into IssueChip's inner <span>, breaking three consumers that
select on `<a>.issue-mention` directly:
- `.rich-text-editor a.issue-mention` underline-exemption in
content-editor.css (stopped matching -> mentions in editor gained a
spurious underline).
- `link-hover-card.tsx` classList check that suppresses the URL preview
on issue mentions (stopped matching -> hover card wrongly pops up
over mention chips).
- Tailwind Typography prose (`prose a { text-decoration: underline }`)
covers a separate path — markdown bubbles in chat. prose's specificity
(0,1,1) beats `.no-underline` (0,1,0), so `not-prose` is the right
escape hatch on the AppLink.
Put `issue-mention` back on the <a> in both wrappers (IssueMentionCard
and the editor's MentionView), and add `not-prose` only to the markdown
wrapper. IssueChip's BASE_CLASS keeps `issue-mention` too (inert on the
span; removing it is a separate scope that needs a full consumer audit).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /download visitors were seeing grayed-out macOS buttons in the 20-ish
minutes after a tag push because CI only builds Linux/Windows — Mac is
still packaged manually and uploads tens of minutes later. Swap the
`/releases/latest` fetch for `/releases?per_page=2` and, when the latest
release is under an hour old, render the previous (fully-populated)
release instead. After the freshness window, page auto-switches to latest.
Frontend-only change — GitHub "latest" marker, electron-updater, and
homebrew paths are untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gemini CLI has no `models list` subcommand, so Multica can't do real
dynamic discovery. Instead, swap the static catalog from fixed version
names (2.0/2.5 only) to the CLI's own aliases (`auto`, `pro`, `flash`,
`flash-lite`, `auto-gemini-2.5`) plus explicit pins for Gemini 3
preview and 2.5 variants. Aliases are resolved inside the Gemini CLI
per user entitlement + quota, so new model releases light up without
a Multica redeploy. Default is `auto`, matching Google's recommended
selection.
Fixesmultica-ai/multica#1503.
The focus toggle was only disabled when focusMode was already ON
*and* the current page had no anchor. Off-state on the same page
stayed clickable — clicking turned it on, and the button instantly
greyed out, making the missing fourth state visible.
Decouple "clickable" from focusMode: the button is disabled whenever
the current page has no anchor, regardless of the persisted on/off
preference. Both the chip render (context-anchor.tsx:173) and send
path (chat-window.tsx:176) already guard on candidate presence, so
leaving focusMode=true on an unanchorable page has no side effects —
the preference is preserved for the next anchorable page.
Tooltip now reads "Nothing to share with Multica on this page"
whenever the button is disabled, regardless of focusMode.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-arch images were built on a single amd64 runner with QEMU
emulating arm64. The Next.js build (Dockerfile.web) under emulation
took 30+ minutes per release and was the long pole of the workflow.
Split each image build across two native runners (amd64 on
ubuntu-latest, arm64 on ubuntu-24.04-arm), push by digest, then
merge into a manifest list with docker buildx imagetools. QEMU is
no longer needed.
Backend and web each become a (matrix build + merge) pair, replacing
the previous single docker-images job. Per-platform GHA cache scopes
avoid cross-arch cache eviction.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All 15 files deleted here correspond to already-merged PRs —
their execution is done, their rationale lives in commit
messages and PR descriptions, and nothing in the tree references
them (grep across *.ts / *.tsx / *.go / *.mjs / *.json returns
zero hits).
Removed:
docs/download-redesign.md → PR #1500
docs/download-positioning.md → PR #1500
docs/onboarding-redesign-proposal.md → PR #1411
docs/workspace-url-refactor-proposal.md → PR #1131 / #1138
docs/plans/2026-04-07-tanstack-query-migration.md
docs/plans/2026-04-08-board-dnd-rewrite.md
docs/plans/2026-04-08-drag-upload-enhancement.md
docs/plans/2026-04-08-image-view-enhancement.md
docs/plans/2026-04-08-monorepo-extraction.md
docs/plans/2026-04-09-desktop-app.md
docs/plans/2026-04-09-monorepo-extraction.md
docs/plans/2026-04-09-upload-attachment-fixes.md
docs/plans/2026-04-15-workspace-slug-url-refactor.md
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Empty `docs/plans/` directory goes with them (git drops empty
dirs automatically). Active, non-plan docs stay: analytics.md,
design.md, product-overview.md, codex-sandbox-troubleshooting.md.
Any future plan can live on a feature branch under
`.claude/plans/` (harness-scoped, not committed) or as a PR
description. No need to land them in-tree.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(views): extract IssueChip shared primitive from mention card
IssueMention (in editor NodeView) and IssueMentionCard shared 95% of their
markup — StatusIcon + identifier + title inside a bordered chip. They drifted
into two parallel implementations so changes had to be made in two places.
Extract the presentational chip into IssueChip. The navigable variants
(IssueMentionCard, the editor NodeView) become thin shells that layer
routing + cmd/shift behaviour onto the shared chip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat): add focus mode to share current page as context
Adds a Focus button next to the chat submit. When on, the chat auto-attaches
whatever the user is viewing (issue, project, or inbox-selected issue) as a
context prefix on outgoing messages, so the agent knows what "this" refers
to without the user pasting ids.
The attached object is derived from the route + react-query cache on every
render — no separate copy in state. Only the boolean focusMode is persisted
(global to the user, not per-workspace), matching the "my preference"
mental model.
The button has three visual states driven by two dimensions (focusMode +
whether the current route resolves to an anchorable object):
- off: ghost + muted, click turns on
- on + anchor: secondary (bright), click turns off
- on + none: disabled (nothing to attach here)
The derived anchor renders above the input as a chip — IssueChip for issues,
a new ProjectChip for projects — wrapped in AppLink so the visual target
matches the clickable target (mirrors IssueMentionCard's hover + navigation).
Prefix format reuses the editor's mention markdown:
Context: [MUL-1](mention://issue/<uuid>) — "Fix login bug"
Context: Project "Authentication"
so the agent sees an identical token whether the user @-mentioned inline or
focus-mode attached. Backend is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(download): add redesign plan and copy positioning source of truth
Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): /download page with OS auto-detection
New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.
Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.
Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.
Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.
i18n copy sourced verbatim from docs/download-positioning.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA
Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.
Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.
cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web,auth): desktop promotion on login + solid landing hero download
LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.
Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.
Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web/download): anchor dark LandingHeader with relative wrapper
LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.
Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy
Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(onboarding): update platform-fork assertions for /download route
The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Merge origin/main into NevilleQingNY/download-redesign
Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.
Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.
typecheck passes across all 6 packages; all 166 views tests
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): instrument download funnel across 5 surfaces + /download
Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.
Three new events, wired via `@multica/core/analytics`:
1. `download_intent_expressed` fires on any CTA pointing at
/download. `source` splits the five surfaces cleanly; every
authenticated emission also writes `platform_preference=desktop`
on the person (same convention Step 3 already uses).
2. `download_page_viewed` fires once per /download mount after OS
detect resolves. Carries `detected_os`, `detected_arch`,
`detect_confident` (Chromium userAgentData vs UA fallback), and
`version_available` so the Safari-on-Mac arm64-default cohort
and GitHub-rate-limited degraded sessions are each isolable.
Also $set_once's `first_detected_os/arch` on the person so every
downstream event gains a platform dimension without re-emitting.
3. `download_initiated` fires on every installer click — Hero's
primary CTA and each All Platforms matrix row. `primary_cta`
splits hero-recommended from manual picks; `matched_detect`
quantifies detect accuracy from the single event (no cross-join
to download_page_viewed needed).
Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.
New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.
docs/analytics.md update lands in the follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(analytics): document download_intent_expressed / page_viewed / initiated
Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): client_type super-property + Desktop $pageview (MUL-1253)
Register a `client_type` super-property ("desktop" | "web") plus optional
`app_version` inside `initAnalytics`, so every PostHog event from the
renderer can be split by client without relying on `$lib` (both Electron
and Next.js report "web"). `appVersion` flows in from `ClientIdentity`
via `CoreProvider` → `AuthInitializer`.
Add a Desktop `PageviewTracker` mounted in `DesktopShell` that fires
`$pageview` whenever the active tab's path changes, mirroring the Web
tracker. Restores the `/ → signup → workspace_created` funnel for the
desktop client and enables web-vs-desktop breakdowns.
* fix(analytics): preserve super-props on reset + cover overlay/login pageviews
Two blockers from PR review:
1. `posthog.reset()` wipes persisted super-properties, so after logout or
account switch the next session's events silently dropped `client_type`
and `app_version` until a full reload. Cache the set at init time and
re-register it inside `resetAnalytics()` so the breakdown survives the
auth transition. Added unit tests to pin the invariant.
2. Desktop `PageviewTracker` only watched the active tab path, which
missed pre-workspace overlays (`/onboarding`, `/workspaces/new`,
`/invite/<id>`) — those aren't tab routes on desktop — and also missed
the logged-out `/login` state. Move the tracker to the app root and
derive the visible path from `(user, overlay, activeTabPath)` with
overlay > tab precedence so the `$pageview` stream matches the
surface the user actually sees.
Replace separate CreateAutopilotDialog / EditAutopilotDialog with a single
shared <AutopilotDialog mode="create"|"edit"> that mirrors the issue create
modal — dynamic sizing, expand/collapse, richtext Prompt, pill toolbar.
- Tiptap ContentEditor replaces plain textarea for Prompt; detail page
renders description via ReadonlyContent for visual parity.
- Pills: Agent, Priority, Execution Mode, Schedule (Popover hosting
TriggerConfigSection). 0/1/N trigger strategy: add on 0, edit inline on
1, disabled with tooltip on 2+ (power users edit in detail page).
- Exposes priority + execution_mode at creation time (backend always
supported them; old UI only offered them in Edit).
- parseCronExpression reverse-parses stored cron back to TriggerConfig for
Edit-time prefill (with round-trip tests).
- PillButton extracted to packages/views/common for reuse across modals.
- DialogContent uses showCloseButton={false} so the shared header renders
the Maximize + Close buttons next to the Rocket-prefixed breadcrumb.
- Conditional mount at call sites (`{open && <AutopilotDialog/>}`) keeps
state fresh on each open.
- Schedule dirty detection compares cron+timezone payloads vs mount
snapshot, and edit-mode submits against a snapshotted trigger id so
concurrent WS refetches can't mis-target the update.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Publish stable GHCR self-host images, switch self-host deploys to official image pulls with a source-build fallback, and move self-host signup / Google OAuth config onto runtime /api/config.
* feat(analytics): capture onboarding funnel events + person-property $set
Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.
Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
answers first land; also $set's role/use_case/team_size on the
person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
with completion_path (full / runtime_skipped / cloud_waitlist /
skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
agent_guided / self_serve branch on both sides
Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.
* feat(analytics): wire frontend onboarding events + completion_path
- captureEvent / setPersonProperties helpers in @multica/core/analytics,
with the same pre-init buffering as identify/pageview so config races
don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
the three web-fork choices (download desktop / CLI / cloud waitlist),
plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
onboarding shell derives full / runtime_skipped / cloud_waitlist
from runtime + waitlist state (lifted to the shell so StepFirstIssue
can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
workspace_id on dismiss so the server can mirror the branch label
* docs(analytics): document onboarding funnel events + $set person properties
Two entry points to multica.ai/changelog so users actually find out
what shipped:
- Sidebar user menu (both expanded popover + collapsed dropdown
variants) gains a "What's new" item with a Sparkles icon, sitting
above Log out. Plain `<a target="_blank">` works on both surfaces:
web opens a new tab, desktop's main-process
setWindowOpenHandler intercepts and routes through
openExternalSafely. The shared view doesn't need to branch.
- Desktop's UpdateNotification "ready to restart" card grows a
secondary "See changes" button next to "Restart now", giving the
user a reason to actually restart instead of dismissing. Mirrors
Conductor's update prompt pattern. The "available" / "downloading"
states stay action-only — the changelog isn't useful before the
download finishes.
No version-detection / unread-tracking yet. Web users still need to
click into the menu to see the changelog; that's a follow-up if the
team wants Linear-style "new" dot.
`ListPins` used to join `issues` / `projects` so each pin row carried a
`title`, `status`, `identifier`, and `icon`. Convenient for the sidebar
but architecturally wrong: those fields live on a different cache key
than the pin query, so an `issue:updated` WS event invalidates
`issueKeys` and never touches `pinKeys`. The sidebar therefore showed
stale issue status / titles on pinned rows until a hard refresh —
and the same shape would silently re-emerge for any new enriched
field added later.
This refactor moves the join to the client so display data flows from
its real source of truth:
Server (`server/internal/handler/pin.go`):
- `PinnedItemResponse` keeps only pin-owned columns (id, workspace_id,
user_id, item_type, item_id, position, created_at).
- `ListPins` no longer fetches issues / projects in the loop and no
longer hides orphaned pins; the client decides how to render a pin
whose target was deleted.
- `formatIdentifier` helper deleted (was only used by the enrichment
branch); `strconv` import dropped along with it.
Types (`packages/core/types/pin.ts`):
- `PinnedItem` interface now mirrors the bare server shape. The four
enriched fields are removed.
Sidebar (`packages/views/layout/app-sidebar.tsx`):
- New smart wrapper `PinRow` resolves each pin's display data via
`useQuery(issueDetailOptions(...))` or `useQuery(projectDetailOptions(...))`
with `enabled` gates on `pin.item_type` so the hook order stays
stable. Loading renders a flat skeleton; error / 404 renders null
(orphan pins hide themselves).
- `SortablePinItem` becomes purely presentational: it now takes
`label` and `iconNode` as props instead of reading them off the pin
object. dnd-kit / navigation wiring untouched.
- Same pattern as `packages/views/search/search-command.tsx:151`,
which already uses per-row detail queries for Recent issues.
WS sync layer is unchanged: `onIssueUpdated` already patches
`issueKeys.detail`, so changing an issue's status now flows directly
into the sidebar without any cross-entity invalidate. The `pin:*`
prefix handler still invalidates `pinKeys` for create / delete /
reorder — that's still the correct signal for the pin LIST itself.
Verified: views typecheck + core typecheck + web typecheck +
desktop typecheck + go test ./internal/handler/... + vitest
(views: 165 tests, core: 83 tests) all pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(slugs): reserve homepage + expand reserved slug list (MUL-961)
- Fix: `homepage` was a live `/homepage` landing route in apps/web but not
in the reserved list, so a user could register a workspace slug that
shadowed the landing page. Now reserved on both backend and frontend.
- Add likely-future global routes (home, dashboard, profile, account,
billing, notifications, search, members) so we don't have to do another
audit/rename pass when these get wired up.
- Add API/ops prefixes (v1, v2, graphql, webhooks, sdk, tokens, cli,
health, ws, metrics, ping) as defense-in-depth against collision with
API aliases and ops endpoints.
- Clarify in both source files that the dotted/underscored entries in the
"Next.js / web standards" section are currently unreachable under the
slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` and are kept as defense-in-depth
in case the regex is ever relaxed.
- Add audit migration 056 following the 047/049 pattern to fail loud if
any production workspace slug collides with the newly reserved set.
* fix(slugs): rename prod conflicts in migration 056 (home → home-1, dashboard → dashboard-1)
Per db-boy's prod audit in the MUL-961 thread, two §3 slugs had live prod
workspaces at reservation time. Decision on MUL-961: force-rename both in
the audit migration (scheme 1), same playbook as MUL-972 for admin/multica/
new/www.
- `home` → `home-1` (68a982da, zzlye, 2026-04-14)
- `dashboard` → `dashboard-1` (ea5a332f, 王争, 2026-04-22)
Targeted UPDATEs land first, followed by a generic `<slug>-N` fallback that
handles any row that slips in between the audit snapshot and deploy. A
post-condition block re-queries the reserved set and fails loud if anything
slipped through.
Down migration reverts the two targeted renames deterministically (they're
keyed by workspace_id, so rollback is safe).
Owner outreach (email zzlye@ + 王争@ about the URL change) is tracked as a
follow-up outside this PR.
* feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189)
Subscribing to a parent issue used to surface every event from every
sub-issue in the inbox — comments, priority/due-date tweaks, assignee
shuffles, the lot — which drowned out the signal that actually matters
to a parent watcher: "did the sub-task move forward?".
notifySubscribers now consults a small allowlist (parentBubbleNotifTypes)
before walking up to the parent's subscriber list. Only status_changed
bubbles today; sub-issue subscribers themselves still get every event.
Direct notifications (issue_assigned, mentioned, task_failed targeted at
specific recipients) are unaffected — they go through notifyDirect, not
the parent-bubble path.
Tests cover the three behaviors that matter:
- status_changed on a sub-issue reaches the parent's subscriber, with
the inbox item still pointing at the sub-issue (so the user lands on
the actual change).
- new_comment on a sub-issue does NOT bubble.
- priority_changed on a sub-issue does NOT bubble.
* fix(test): pick next per-workspace issue number in test helpers
Both createTestIssue and createTestSubIssue inserted with the default
number=0, which collides with the uq_issue_workspace_number unique
constraint as soon as a single test creates two issues in the same
workspace (e.g. parent + sub-issue). The first failure also leaked the
parent row because t.Cleanup hadn't been registered yet, breaking every
subsequent test in the package.
Both helpers now compute number as MAX(number)+1 for the workspace, and
the parent-bubble tests register cleanup right after each insert so a
mid-test failure can't leave orphans.
Follow-ups on the onboarding flow shipped in #1411.
Pin state synchronization:
- ImportStarterContent now publishes pin:created after commit so the
sidebar refreshes without a hard reload (previously the pins landed
in the DB but no event was fired).
- ReorderPins publishes pin:reordered, keeping order in sync across
web + desktop sessions.
- StarterContentPrompt.onImport invalidates queries locally, mirroring
the useCreatePin / useDeletePin / useReorderPins onSettled pattern,
so the originating session's refresh doesn't depend on the WS
round-trip (WS is the signal for OTHER sessions).
- ImportStarterContent rejects malformed workspace_id up front with
400 instead of falling through to a misleading 403.
Welcome step layout:
- Switch the two-column hero from CSS Grid to a flex row. Both
columns share the container's full height via items-stretch +
justify-center, so the bg-muted/40 backdrop fills edge-to-edge on
tall viewports and left/right content stays vertically centred.
Desktop runtime bootstrap state:
- New DesktopRuntimesPage wrapper subscribes to window.daemonAPI and
forwards a `bootstrapping` prop to RuntimeList. While the bundled
daemon is booting, the empty state renders "Starting local
runtime…" instead of the misleading "Run multica daemon start"
hint. Web leaves the prop undefined — behaviour unchanged.
Small polish:
- CLI install dialog caps at 85vh with an internal scroll so the
Connect button stays reachable when multiple runtimes are
registered.
- Drop the env-aware CLI setup command; onboarding always targets
cloud, so `multica setup` is enough — no need to thread apiUrl /
appUrl through the dialog.
Developer tooling:
- pnpm dev:desktop:staging — parallel dev command that loads
.env.staging (copilothub backend) via `electron-vite --mode
staging`, so switching between local and staging no longer
requires hand-editing env files.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(transcript): add multi-select tool filter to agent execution dialog
Adds a Filter dropdown to AgentTranscriptDialog that lets users
multi-select tool types (e.g. tool:Bash, tool:Edit) to narrow down
the event list, timeline bar, and copy output. Non-tool items (text,
thinking, error) are also filterable. Clear filters is placed at the
bottom of the dropdown with a separator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): address review comments on tool filter
- Replace index-based selection with seq-based (selectedSeq) to fix
highlight/scroll jumping when toggling filters
- Use full tool count for the "X tool calls" chip (task-level stat)
instead of filtered count
- Title-case filter labels: Thinking, Error (was lowercase)
- "Copy all" → "Copy filtered" when filter is active
- Replace raw <button> with DropdownMenuItem for Clear filters
so it participates in keyboard navigation
- Drop redundant idx from row key (seq is already unique/stable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): use proportional width in timeline bar
After removing segment grouping, the timeline bar lost proportional
widths. Restore proportional width per item (1/items.length * 100%)
so each event's width reflects its share of the timeline.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): show individual event dividers in timeline bar
When filtering by a single type (e.g. Agent), all events share the
same color and blend into one solid bar. Split each event into its own
clickable button so users can see and click individual events, while
keeping proportional widths based on item count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): simplify timeline bar to segment-level buttons
Remove per-item nested buttons in timeline segments; each segment is now
a single clickable area. Reduces DOM nodes and aligns with the original
design where segments are coarse color blocks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(transcript): reuse getEventLabel for filter option display
Replace the manual displayMap with getEventLabel() so filter option
labels stay in sync with row labels automatically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transcript): address round-2 review comments
- Remove dead `onClick` prop from TranscriptEventRow (caused
TS6133 under noUnusedParameters; row never wired a click handler)
- Align `itemFilterKey` guard with `filterOptions` derivation
(tool_use/tool_result type check)
- Fix TimelineBar `isSelected` from seq range to actual membership
via `.some()` — avoids false highlight when a filtered-out seq
falls within a segment's range
Note: `DropdownMenuItem` uses `onClick` not `onSelect` because this
codebase uses Base UI, not Radix. Base UI's Item.Props has no
`onSelect`; the inbox/members-tab code uses `onClick` as the pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Ark <lifangzhou@shizhuang-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(skills): unify Add Skill UX + surface every local skill with real file count
Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.
UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
"Import From Runtime" buttons. Adding a skill is now a single entry
point: the "+" header button (or empty-state button) opens one dialog
with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
it can mount inline as a tab. The standalone Dialog wrapper stays for
the per-runtime "Import this skill" flow on the agent skills tab,
which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
From-Runtime tab (runtime selector + skill list + name/description
form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
runtimes were listed but the import endpoint rejects them anyway,
matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
(`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
shadcn SelectValue needs explicit children when items don't render
the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
description inputs in the detail header — it was decorative noise.
Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
every symlinked skill via the os.ModeSymlink early return. Skill
installers like lark-cli ship every skill at ~/.agents/skills/<name>
and symlink each one into ~/.claude/skills/, so users with dozens of
skills only saw the few they had cloned in place. Switch to ReadDir
+ os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
filepath.WalkDir does not descend into a symlinked root, so every
such skill reported 0 files. Resolve the skill dir via EvalSymlinks
before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
bundle's `Content` field to avoid duplication on import). The summary
now adds 1 back so the user-facing count matches the real file total
— every skill has SKILL.md, we just required it to be parseable.
Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
shared installer dir, symlinks one skill into the runtime root, and
asserts both regular and symlinked skills come back with the right
source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
(one supporting file + SKILL.md) and a comment explains the +1 split.
* test(skills): drive new Add Skill dialog flow in skills-page test
Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.
Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).
* fix(daemon): list nested skills, not just depth-1 entries
Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.
Replace the flat ReadDir with a recursive enumerator that:
- Follows symlinks at every level (so installer-style symlinked skill
trees still work — that was the original reason for moving off
WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
skill is registered, and its children are NOT scanned for further
skills. Stale nested SKILL.md files inside a parent skill's bundle
stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
with headroom) and tracks visited resolved paths so a cyclic symlink
can't loop forever.
New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
Follow-up to #1434. The merge-in of db-reset from main happened during
#1434's conflict resolution and didn't get a `##` description, so it
doesn't appear in `make help`. Add the one-line description so the
target surfaces under the Database grouping alongside the other `db-*`
commands.
* feat: add awk style make help
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
* Address review nits for make help
- Add ##@ Help section so help / makehelp targets are no longer orphaned
between the intro blurb and ##@ Self-hosting.
- Explicitly set .DEFAULT_GOAL := help and document that bare `make` now
prints help instead of launching selfhost. This is a safer default for
onboarding, but it is a behavior change from the previous first-target
default (selfhost).
---------
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
* feat: identify clients via X-Client-Platform/Version/OS
Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.
- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}
Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).
Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.
CORS allowlist extended for the new headers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: address client-identity PR nits
- Memoize the CoreProvider identity object on Web and Desktop, and key
WSProvider's effect on identity primitives instead of the object
reference, so unrelated parent re-renders no longer tear down and
reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
and asserts the "websocket connected" log entry surfaces them as
structured attributes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)
When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.
This change implements the A+B plan from the issue thread:
A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
daemon calls it on every register so the server fails any
dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
agent's session_id + work_dir mid-flight so a crash doesn't
lose the resume pointer (claude+codex emit MessageStatus with
SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
now set failure_reason ('agent_error' / 'timeout' /
'runtime_offline')
B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
carrying parent's session_id/work_dir when the failure reason
is infrastructure-shaped (timeout, runtime_offline,
runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
so the user transparently sees a new in_progress run instead of
a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
CLI for the manual escape hatch
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): address PR review for orphan-task recovery (MUL-1128)
Three review-must-fix items on top of the A+B implementation:
1. recover-orphans now funnels through TaskService.HandleFailedTasks,
the same shared post-failure pipeline used by the runtime sweeper.
This guarantees task:failed events are emitted, agent status is
reconciled, and issues stuck in_progress with no remaining active
task are reset to todo even when no auto-retry is created
(max_attempts exhausted, autopilot, non-retryable reason).
2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
issue's current assignee. The previous implementation called
CancelAgentTasksByIssue, which would collateral-cancel parallel
@-mention agents on the same issue.
3. GetLastTaskSession now considers both completed and failed tasks
(mirroring GetLastChatTaskSession), ordering by the most recent
timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
mid-flight, an auto-retry or manual rerun of a daemon-crash failure
now actually resumes the prior conversation context instead of
starting fresh — matching the stated B-branch behaviour.
go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agents): tasks tab crashes when agent has autopilot run_only tasks
Autopilot `run_only` tasks have no linked issue; the server serializes
that as `issue_id: ""` (not null) via `uuidToString` on an invalid
pgtype.UUID. The agent detail Tasks tab assumed every task had a real
issue id and fed `""` into `api.getIssue(id)` → `/api/issues/` and into
`paths.issueDetail("")`, crashing the whole tab as soon as one such
task existed on the agent.
Handle the empty-issue case explicitly:
- Filter empty ids out of `issueIds` so `useQueries` doesn't fire
`/api/issues/` for a nonexistent issue.
- Render run_only rows as non-link `<div>`s labeled "Autopilot run"
instead of clickable issue links.
No server-side change — the `""` serialization stays as-is; callers
just need to treat it as "no issue".
* fix(agents): neutral label for issue-less tasks + regression test
Review feedback on #1453: not every task without a linked issue is an
autopilot run. `ListAgentTasks` returns the agent's full queue; both
autopilot `run_only` runs and chat-spawned tasks persist with NULL
issue_id, which arrives here as "". Labeling both as "Autopilot run"
mislabels chat tasks.
Swap the label to the neutral "Task without linked issue" and update
surrounding comments. A follow-up will surface the real task source
once the server populates it on AgentTaskResponse.
Adds a regression test that empty issue_id rows render the neutral
label, aren't wrapped in an anchor, and don't trigger a detail fetch.
* docs(onboarding): add redesign proposal
Captures motivation (two activation funnels), research-backed principles,
final 5-step flow (welcome+questionnaire → workspace → runtime → agent →
first-issue), Q1/Q2/Q3 personalization matrix, backend user_onboarding
schema, API design, resume policy, and development ordering
(frontend-first with Zustand stub, backend-last, server swap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): scaffold redesigned flow and state foundation
Work-in-progress scaffold toward the redesign documented in
docs/onboarding-redesign-proposal.md. This commit is intentionally
broad — subsequent commits will replace step content and wire real
personalization. Not ready for merge.
Included:
- packages/views/onboarding/: flow orchestrator + 5 step components
(welcome/workspace/runtime/agent/complete) and the CLI install card.
Step content is the placeholder version; Step 1 (questionnaire) and
Step 5 (first issue) are the next changes.
- packages/core/onboarding/: dev-phase Zustand store + types. Not
persisted — every page refresh starts at Step 1 so each step can be
iterated in isolation. Will swap to TanStack Query + PATCH
/api/me/onboarding once the backend user_onboarding table ships
(keeps the exported hook surface stable).
- packages/core/paths/resolve.ts + .test.ts: centralized
resolvePostAuthDestination. Priority is flipped so !hasOnboarded
wins over workspace presence — during frontend development every
login re-enters /onboarding. useHasOnboarded() reads from the store
so the real onboarded_at semantic lands automatically once the
backend ships.
- Post-auth wiring: callback page, login page, landing redirect,
dashboard guard, realtime workspace-loss handler, settings leave/
delete, invite acceptance, and desktop app shell all delegate to
the shared resolver instead of inline logic.
- Desktop overlay: 'onboarding' added as a WindowOverlay type
alongside new-workspace / invite, with a navigation-adapter
interception so push('/onboarding') opens the overlay.
- packages/core/package.json / packages/views/package.json: add new
subpath exports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): revise questionnaire to role-driven 3-question form
Aligns the proposal with the corrected product positioning: Multica is an
AI agent orchestration platform for diverse users (developers, product
leads, writers, founders), not a coding-focused tool.
Key changes:
- Drop Q1 "which agents do you already use?" — daemon auto-detects
installed CLIs on PATH; asking is both redundant and less accurate
- Add Q2 "what best describes you?" (role) to drive Step 4 template
default and Onboarding Project sub-issue filtering
- Keep Q1 team_size, refine Q3 use_case (recover writing/research
option); all three now have "Other" with an 80-char text field
- Q3 use_case_other is embedded into Step 5 first issue prompt so
Other users get maximally personalized aha moments, not generic ones
- Agent templates: 3 → 4 (Coding / Planning / Writing / Assistant),
matrix driven by Q2 × Q3
- Onboarding Project sub-issues: surface Autopilot and Workspace
Context (product differentiators), replace "orchestration" wording
- Schema JSONB example and §5/§9 execution plan updated to match
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): align questionnaire shape with role-driven redesign
Prepares the core state layer for the Step 1 questionnaire rewrite.
Type-only and initial-value changes; no behavior changes (nothing was
reading the removed `existing_agents` field, since no questionnaire UI
exists yet).
- Add `Role` type (Q2: developer / product_lead / writer / founder / other)
- Add `*_other` sibling fields for team_size / role / use_case so each
question's "Other" selection can carry 80-char free text
- Drop `existing_agents` — daemon auto-detects CLIs on PATH at Step 3,
so the signal no longer belongs in the questionnaire
- Extend `TeamSize` / `UseCase` unions with `"other"` member
- Refine `UseCase` option label (`writing` → `writing_research`) so
it matches the widened Q3 scope in the proposal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): implement Step 1 questionnaire
Replaces the placeholder welcome step with the 3-question questionnaire
defined in docs/onboarding-redesign-proposal.md §3.4. Answers land in
the core onboarding store for later use by Steps 4 and 5.
Added:
- packages/views/onboarding/components/option-card.tsx — OptionCard +
OtherOptionCard. Radio-group ARIA semantics; Enter/Space select;
Other variant reveals an 80-char input that auto-focuses on mount.
- packages/views/onboarding/steps/step-questionnaire.tsx — merges
welcome + Q1/Q2/Q3 into one screen. Local draft state for
responsiveness; writes to the core store only on submit. Skip/
Continue CTA swap driven by "any answered?"; the only disabled
case is "picked Other but the text box is blank".
- Test coverage for the CTA rules, Other-clear-on-switch behavior,
initial-answers pre-fill, and full payload shape.
Modified:
- packages/views/onboarding/onboarding-flow.tsx — render
questionnaire as the first step; persist answers and advance the
stored current_step on submit. Other steps still run off local
useState for now; full store-driven orchestration follows when
Step 5 lands.
Removed:
- packages/views/onboarding/steps/step-welcome.tsx — superseded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): split welcome + questionnaire, unblock scroll, drop Q1 evaluating
Three fixes prompted by first real browser testing of the Step 1
questionnaire. All three are about making the flow usable before
pursuing visual polish.
1. Split Welcome and Questionnaire into two screens
The previous merge-welcome-into-questionnaire decision dropped
Multica's product introduction entirely. For a product with no
established mental model (AI agents as first-class teammates in a
task platform), first-time users need 5 seconds of framing before
the questionnaire makes sense. StepWelcome carries that framing;
it's UI-only (not a persisted step), shown only on first entry
(pristine store), and skipped automatically on resume.
2. Remove `my-auto` vertical centering from both platform shells
Long questionnaire content pushed the centered block's top above
the scroll origin, making Continue/Skip unreachable. Top-alignment
+ natural body/overlay scroll is the boring-but-correct baseline
for content of variable height.
3. Drop Q1 "Just exploring for now" option
Q1 asks about team structure, not attitude. "Evaluating" was a
category error. Low-commitment users already have a zero-friction
path (skip all questions). Removing the option simplifies the
question and the downstream mapping table.
Types, store initial value, proposal doc (§3.1 flow diagram, §3.4
options, §3.5 sub-issue sorting, §3.6 conditionals, §4.1 JSONB
schema, §5.2 file list, §7 decisions row, §9.2 execution order)
all synced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): center short steps, scroll long ones — correctly this time
Previous attempt removed `my-auto` thinking it was responsible for
blocked scrolling. That diagnosis was wrong: the real blocker was
the root layout's \`body { overflow: hidden }\` (an app-shell
convention so sidebar/topbar stay put while the inner content
region scrolls). Removing `my-auto` broke vertical centering of
short steps (Welcome) without fixing the scroll issue.
Correct fix:
- Web: page now owns its own scroll container — `h-full
overflow-y-auto` on the outermost div decouples from the body's
overflow-hidden.
- Desktop: the overlay's existing `flex-1 overflow-auto` container
already provided scroll; just restoring `my-auto` was sufficient.
- Both platforms: inner `flex min-h-full flex-col items-center` +
content `my-auto` gives the "short centers, long top-aligns and
overflows down" behavior. Per the flex spec, auto margins are
ignored on overflowing boxes (they overflow in the end direction),
so Continue/Skip remain reachable via scroll even on long steps
like the questionnaire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add progress indicator + stable header anchor
Adds a consistent visual anchor at the top of every step (except
Welcome), so transitioning between steps of different content heights
no longer shifts the vertical baseline.
- packages/core/onboarding/step-order.ts — single source of truth for
step order; indicator math reads from here so adding/reordering a
step touches only one line
- packages/views/onboarding/components/step-header.tsx — dot row +
"Step N of M" counter; three dot states (done/current/pending);
accessible progressbar semantics
- onboarding-flow.tsx — non-welcome steps now render under a shared
`<div flex flex-col gap-8>` wrapper with StepHeader on top. Maps
the local `complete` render step to the store's `first_issue`
until Step 5 lands (one-line function, self-deleting).
- step-welcome.tsx — keeps its own min-h-[60vh] + justify-center so
the short intro still feels centered once the shell drops my-auto
- apps/web + apps/desktop shells — removed `my-auto`. Every
non-welcome step now anchors to the same top position, so only the
content below the header changes during transitions. Welcome's own
internal centering handles its "short content, no header" case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add web Step 3 platform fork (Desktop / CLI / waitlist)
Web users now see a three-way choice at the runtime step instead of
being dropped directly into CLI install instructions:
- Primary CTA: Download Multica Desktop (bundled runtime)
- Alternate: install the CLI (reveals existing StepRuntimeConnect)
- Alternate: join the cloud waitlist (captures email, completes
onboarding early with cloud_waitlist_email set)
Desktop unchanged — its platform shell doesn't pass cliInstructions,
so OnboardingFlow routes it straight to StepRuntimeConnect for the
bundled-daemon auto-connect path.
Rename step-runtime.tsx → step-runtime-connect.tsx to reflect its
new single responsibility (connect UI only; platform choice lives
in StepPlatformFork).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): capture optional use-case on cloud waitlist
Adds a textarea to the waitlist form asking what the user wants to
use Multica for. Optional (submit still works with email alone) but
surfaces a clear prompt + placeholder example so most users will fill
it in. Stored as cloud_waitlist_description alongside the email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): make !hasOnboarded a first-class gate on both platforms
Triggering condition was wrong on both sides. Web's dashboard-guard
only checked hasOnboarded when the URL slug failed to resolve; desktop's
App.tsx effect returned early when wsCount > 0 before even looking at
hasOnboarded. Users with existing workspaces never got routed into
onboarding regardless of their flag state.
Also wire store.complete() into the happy-path finish — previously only
the waitlist branch wrote onboarded_at, so every normal completion
left the flag false and (now that triggers work) would loop users back
into onboarding on refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 5 auto-bootstrap — welcome issue + Getting Started project
After agent creation, the flow transitions to a loader screen that
runs the bootstrap in the background:
- Creates a welcome issue with a Q3-driven prompt, assigned to the
new agent (so it starts working immediately)
- Creates a "Getting Started" project with tutorial sub-issues
filtered by Q1/Q2/Q3
- Stores first_issue_id + onboarding_project_id via store.complete()
- Navigates the user straight into the welcome issue detail page,
where they see the agent already responding
Degraded path: if welcome issue fails, shows error with Retry /
Continue anyway. If project or sub-issues fail, logs and proceeds
with just the welcome issue — the aha moment still happens.
No-agent paths (runtime skip, agent skip) short-circuit to onComplete
without bootstrap.
Local flow step union now aligns with the store enum; removed the
mapLocalToStoreStep bridge and deleted the old step-complete.tsx
placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): converge all no-agent paths to a single bootstrap step
Before: skip-runtime, skip-agent, and waitlist each finished onboarding
independently, bypassing Step 5 entirely. Users without an agent landed
in an empty workspace with no tutorial project — the "self-serve" case
had no bootstrap at all.
Now: all three paths converge on the first_issue step with agent=null.
Bootstrap branches on agent presence:
- agent ✓ → welcome issue (assigned to agent) + project + agent-guided
sub-issues ("watch your agent do X"). Lands on the welcome issue.
- agent ✗ → project only + self-serve sub-issues ("try X yourself" —
configure runtime, create agent, write first issue, etc.). Lands on
the workspace issues list with the Getting Started project in the
sidebar.
Both web and desktop shells already handle firstIssueId=undefined →
fall back to /<slug>/issues, so no shell-side change was needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): pin starter project + assign sub-issues to the user
Bootstrap now also:
- Pins the Getting Started project so users see it in the sidebar
immediately (both paths)
- Pins the welcome issue too (path A only) so the first conversation
with the agent stays one click away
- Assigns every sub-issue to the current user (via their workspace
member record). Only the welcome issue stays assigned to the agent —
that's the aha-moment hand-off; everything else is for the user to
work through
Pin calls are fire-and-forget (failure logged but non-blocking).
Member lookup is defensive — if listMembers fails or the user isn't
found, sub-issues gracefully fall back to unassigned rather than
breaking the bootstrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): remove cloud waitlist option
Cloud runtime is not on the immediate roadmap and there's no backend
table to persist emails. Keeping the UI around would silently drop
user submissions — small trust leak. Revisit once cloud product lands
alongside a proper waitlist table + notification pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist onboarded_at end-to-end
Phase 1 of bringing onboarding from dev stub to production. A single
persisted column drives every trigger — no separate user_onboarding
table yet (that's a later phase for questionnaire persistence, cloud
waitlist, analytics).
Backend
- Migration 050: ALTER TABLE "user" ADD COLUMN onboarded_at TIMESTAMPTZ
(no backfill — existing users see onboarding next login, Skip
affordance lands later)
- sqlc: MarkUserOnboarded with COALESCE for idempotency
- UserResponse DTO + userToResponse now emit onboarded_at via
existing util.TimestampToPtr helper — single edit covers GetMe,
VerifyCode, GoogleLogin, LoginWithToken
- New handler POST /api/me/onboarding/complete
- Route registered in the authenticated user-scoped group
Frontend
- User type gets onboarded_at: string | null
- api.markOnboardingComplete()
- Auth store adds refreshMe() — lightweight getMe + setUser,
complements existing initialize()
- useHasOnboarded switches source from onboarding-store (dev stub)
to auth-store (user.onboarded_at). Every call site — dashboard
guard, desktop App.tsx, invite page fallback, realtime
workspace-loss handler, settings leave/delete — picks up the
real signal without any direct change
- onboarding-store.complete() now hits the server: POST + refreshMe
before local state update, so the next router effect sees the
non-null timestamp and won't bounce the user back
Triggers + route guards
- StepWorkspace drops the Skip button — every onboarding user
must create their own workspace even if invited into one
- /onboarding page redirects already-onboarded users away (guards
against manual URL access)
- login page + auth callback: onboarding wins over ?next= for
unonboarded users; invite links are revisitable after onboarding
Tests
- apps/web callback tests updated: mocks now return User objects
so onboarded_at is readable; new "onboarded user honors next"
scenario added, "unonboarded ignores next" scenario kept
- test/helpers mockUser gets onboarded_at field
- questionnaire already-existing strict-required tests bundled in
from a prior uncommitted change
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): review findings — dead state, error recovery, cache races
From independent review of the prior onboarded_at commit.
- Remove the dead OnboardingState.onboarded_at field, its INITIAL_STATE
entry, and its write in store.complete(). useHasOnboarded now reads
auth-store exclusively; leaving a parallel field here violates the
"don't duplicate server data in Zustand" rule and risks drifting into
a second source of truth.
- Wrap handleBootstrapDone/handleBootstrapSkip in try/catch with toast
recovery. complete() is idempotent server-side (COALESCE), so a
retry after a failed POST/refreshMe is free — letting the error
bubble into the React error boundary trapped the user with no way
forward.
- RedirectIfAuthenticated: swap `!list` for `isFetched`-gated check,
matching the pattern added on the /onboarding page. Same one-tick
race where a stale cache [] could fire a premature replace before
the fresh list settles.
- (Self-review fixups picked up along the way) /onboarding page now
waits for workspacesFetched before redirecting already-onboarded
users, and login handleSuccess reads useAuthStore.getState() so the
hasOnboarded value is fresh after setUser (the closure captured a
stale pre-login value otherwise).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): shrink store surface + firm up flow invariants
Post-review cleanup. End-to-end flow is already complete (user.onboarded_at
is the single source of truth); these are quality-of-life fixes on top.
Store surface
- Drop six dead fields from OnboardingState (workspace_id, runtime_id,
agent_id, first_issue_id, onboarding_project_id, platform_preference)
and the PlatformPreference type. None had readers — they were stub
placeholders for a future user_onboarding table that isn't coming
this phase. CLAUDE.md "don't design for hypothetical future".
- store.complete() signature simplifies to () — no more patch arg,
since the only patch fields were the ones just deleted.
Welcome as a first-class step
- Add "welcome" to OnboardingStep enum and make it INITIAL_STATE's
current_step. Removes the pristine-heuristic "did user see welcome?"
check, which could misfire on remount.
- pickInitialStep() collapses to `state.current_step ?? "welcome"`.
- ONBOARDING_STEP_ORDER stays unchanged (welcome isn't a progress point).
advance() chain
- Every transition handler now persists the new current_step to the
store (handleWorkspaceCreated, handleRuntimeNext, handleAgentCreated,
handleAgentSkip). Refresh lands on the right step instead of
jumping back to Step 2.
Invariants
- OnboardingFlow throws on null user instead of spreading defensive
`?? ""` and `if (userId)` that silently degraded to unassigned
sub-issues. Shell guards already ensure user is present.
- Desktop WindowOverlay's onComplete gains a paths.root() fallback
when workspace is undefined — matches web's symmetry.
docs/product-overview.md: committed from untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist questionnaire + current_step; resume + Back
End-to-end questionnaire persistence + resume capability. User answers
are now server-side (analytics-ready); refreshing or revisiting lands
on the furthest reached step with previous answers pre-filled; a Back
button on each step lets users edit earlier answers without losing
progress.
Backend
- Migration 051: ALTER TABLE "user" ADD onboarding_current_step TEXT,
onboarding_questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb
- sqlc: new PatchUserOnboarding with sqlc.narg for optional fields
(COALESCE preserves unspecified columns). MarkUserOnboarded also
clears current_step — once complete, the step pointer has no meaning
- Handler PATCH /api/me/onboarding accepting partial {current_step,
questionnaire}. Questionnaire passthrough via json.RawMessage, no
server-side validation of inner shape (keeps schema evolution free)
- UserResponse DTO emits both new fields; userToResponse coalesces
JSONB to '{}' defensively
Frontend
- User type gains onboarding_current_step + onboarding_questionnaire
- api.patchOnboarding(payload)
- Delete Zustand onboarding store — replaced with plain async
advanceOnboarding() / completeOnboarding() that call the API and
sync auth store. Source of truth is the user object, no client-side
shadow state that could drift
- pickInitialStep reads user.onboarding_current_step; StepQuestionnaire
initial pre-fills from user.onboarding_questionnaire
- Monotonic furthestStepRef: Back edits don't regress server-side
progress, and re-submit returns the user to where they were
- Back buttons on Steps 2/3/4. Back is local-only — just changes the
rendered step, no PATCH
- Loading indicator on Welcome + Questionnaire submit buttons while
PATCH is in flight
- CreateWorkspaceForm.onSuccess accepts Promise<void> so the flow can
await advance() from its onCreated handler
Test mocks (helpers + callback test) updated with new User fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resume to Step 3+ needs workspace/runtime fallback
Self-review caught: resume lands the user on their saved step, but
React state (workspace, runtime, agent) is empty on fresh mount. The
render conditions gate on those — without fallbacks the page stays
blank.
- workspaceListOptions() query fills runtimeWorkspace from cache when
stepping past Step 2. Only one workspace exists during onboarding
(StepWorkspace always creates one), so [0] is unambiguous.
- StepWorkspace accepts an `existing` prop. On resume / Back to Step 2
with a pre-existing workspace, render a "Continue with <name>"
confirmation instead of the create form, which would otherwise hit a
slug conflict the moment the user clicks Create.
- runtimeListOptions(wsId, "me") similarly seeds Step 4's runtime —
prefer first online, fall back to first.
Step 5 resume path unchanged: if `agent` React state is null on
re-entry, bootstrap runs the self-serve branch. Not ideal (user may
have actually created an agent), but bootstrap's list-check approach
(future work) will handle orphan detection symmetrically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): delete all skip/resume jump logic
Flow always starts from Welcome. Questionnaire answers still pre-fill
from user.onboarding_questionnaire. current_step is still PATCHed for
future analytics but no UI code reads it for navigation.
Removed from onboarding-flow.tsx:
- pickInitialStep + isOnboardingStep (no server-driven entry point)
- furthestStepRef + resolveNextStep (no edit-vs-first-pass branching)
- runtimes useQuery + stepRuntime fallback (user walks through Step 3
linearly, so runtime React state is always populated by Step 4)
- workspace resume fallback in runtimeWorkspace (same reasoning)
Kept:
- advanceOnboarding({ current_step, questionnaire? }) — server
persistence, analytics-ready
- StepQuestionnaire's initial prop from stored answers
- workspaces useQuery (gated to step === "workspace" only) for
existing-workspace detection on Step 2 to prevent slug conflicts
when a previous onboarding was abandoned
- Back buttons + handleBack (local-only navigation)
- Error recovery on completeOnboarding via try/catch + toast
Every transition handler is now a straight advance + setStep line.
Users who close mid-flow and return walk the full flow from Welcome
again — slight extra clicks, but each step shows meaningful confirm
UI (existing workspace, connected runtimes, etc.) so it doesn't feel
like repeated work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): grandfather existing users in the onboarded_at migration
Folded the backfill into 050 itself (branch has not shipped to prod,
so editing the migration in place is clean). Without this, once this
branch deploys, every pre-existing user would be walled off into
onboarding on their next login — a real production incident.
Uses created_at rather than NOW() so analytics like "signup →
onboarded interval" read correctly for pre-launch users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 1 questionnaire — two-column editorial layout
Matches the onboarding(3) design spec: full-bleed two-column on lg+
(main + "Why we ask" side rail), collapses to single column below.
- StepQuestionnaire rewritten with:
- Mono 01/02/03 markers per question
- Serif question headings (22px)
- Editorial serif title ("Three answers. We'll handle the rest.")
- Right-side rationale panel explaining what each answer unlocks
- Sticky footer with hint + Continue CTA
- Embeds StepHeader on the left column so it escapes the flow's
narrow max-w-xl wrapper, same pattern Welcome uses
- OptionCard redesigned: radio-dot marker + inset ring on select,
matches design's .opt pattern
- OtherOptionCard: text input appears below the row (not inside the
card) with bottom-border-only styling, aligned under the label
- onboarding-flow: questionnaire now early-returns full-bleed,
joining Welcome as a hero-layout step
Placeholder copy updated to match design examples; tests adjusted.
* fix(onboarding): questionnaire uses 3-region app-shell layout
Previous version had everything in a single scroll container with a
sticky footer. As the user scrolled into the questions, the Back
button and StepHeader progress indicator scrolled out of view, and
sticky-bottom had edge cases with width-constrained flex nesting.
Classic 3-region shell now:
- Fixed header row: Back button (left) + StepHeader progress
indicator — persistently visible regardless of scroll position
- Scrollable middle: eyebrow / serif title / lede / 3 question
blocks. Uses `flex-1 overflow-y-auto min-h-0` — the min-h-0 is
the critical bit that lets a flex-1 child shrink below content
height inside a flex column
- Fixed footer row: hint (hidden < sm) + Continue CTA — always
reachable, never scrolled off
Right "Why we ask" panel is now an independent grid column with its
own overflow, so the two columns scroll independently instead of the
whole page having one shared scrollbar.
Side panel width reduced 520 → 480 to give the question column more
room on 1280/1366 screens where 1fr_520 left ~760px for content;
1fr_480 gives ~800-900px which comfortably fits the 620px max-w
content column plus breathing room.
* fix(onboarding): questionnaire needs DragStrip like every full-window view
Traffic lights were overlapping the StepHeader progress dots because
Step 1 escaped onboarding-flow's non-welcome wrapper (which renders
<DragStrip />) without rendering its own. The codebase convention per
packages/views/platform/drag-strip.tsx is: every full-window view
places a DragStrip as the first flex child of each visible column.
Adds DragStrip at the top of both the left (shell) and right
("Why we ask") columns, matching step-welcome.tsx which already did
this. Traffic lights now land in the 48px transparent strip with no
content collision; dragging from any top edge moves the window on
Electron; border-l between columns runs edge-to-edge.
Also made the right column's scroll container use
`min-h-0 flex-1 overflow-y-auto` so its internal scroll activates
independently of the left column.
(Separately investigated: useImmersiveMode is no longer called
anywhere in production code — the codebase has fully committed to
the DragStrip pattern. No action needed on the hook itself.)
* style(onboarding): drop top/bottom borders on questionnaire shell
* style(onboarding): use chat-style scroll fade mask instead of border
The questionnaire's scroll area now fades softly at top/bottom edges
via `useScrollFade` (already used by chat-message-list.tsx) — the
same mask-image linear-gradient pattern that fades content under the
header/footer based on scroll position:
- At top: only bottom fades (hint: more content below)
- At bottom: only top fades (hint: content above)
- In middle: both fade
- Fits entirely: no mask
This replaces the removed border-b/border-t on the header/footer with
a softer, more editorial visual separation while giving an actual
scroll-position affordance the border can't.
* feat(onboarding): show "n of 3 answered" progress next to Continue
Gives the user a glance-able progress signal as they fill the
questionnaire. Static text, no extra UI primitives, no dynamic
state variants — just `{n} of 3 answered` updating in place,
left of the Continue button.
Replaces the static "Your answers shape the next screens..." hint,
which was always there regardless of progress and added noise.
Same canContinue gate as before (all 3 answered), just derived
from the new per-question check so we don't compute validity twice.
* style(onboarding): drop redundant lede under questionnaire title
The title already conveys the "we'll handle the rest for you"
promise — the lede just rephrased it at length. Removed; bumped the
question-list top margin (mt-8 → mt-10) to keep breathing room.
* feat(onboarding): land redesigned flow + post-landing starter content opt-in
This commit bundles the final onboarding-redesign work that sat in the
working tree with today's architectural reshape of how starter content
is handled. Splitting across sqlc-regenerated files would be fragile,
so it ships as one logical unit — "onboarding is ready for production".
Flow redesign (Steps 1–5)
-------------------------
- Editorial two-column shells on Steps 1/2/3/4 (DragStrip + hero column
+ aside panel) — Welcome, Questionnaire, Workspace, Runtime, Agent
- Web-only Step 3 fork (Download desktop / Install CLI / Cloud waitlist)
lives alongside desktop's direct runtime picker; cloud path is
interest-capture only, doesn't advance the flow
- DragStrip extracted to packages/views/platform as a cross-platform
component — 48px transparent drag row, no-op on web
- recommend-template.ts + test: Q1–Q3 → AgentTemplate mapping
Cloud waitlist
--------------
- Migration 052: cloud_waitlist_email VARCHAR(254) + cloud_waitlist_reason TEXT
- Handler: net/mail.ParseAddress + length bounds + reason trim
- Frontend: CloudWaitlistExpand component + api.joinCloudWaitlist
Drop persisted onboarding_current_step
--------------------------------------
- The interim implementation persisted the user's furthest-reached step;
the final design starts every entry at Welcome, so the column is dead
- Migration 051 no longer adds it; migration 053 drops it IF EXISTS on
any environment that ran the interim 051 — schema converges cleanly
- UserResponse / User type / patchOnboarding signature all drop the field
Post-landing starter content (new architecture)
-----------------------------------------------
Why: the old design ran bootstrap inside Step 5 (welcome issue + Getting
Started project + sub-issues, all in one try block). That had three
defects — (1) non-idempotent: Retry after partial failure created
duplicates; (2) sub-issue assignee raced listMembers → showed as
"Unknown"; (3) skipped users (paths A/C/D) never got any starter
content. All three are structural, not patchable.
New design: onboarding ends at completeOnboarding() as before (gate is
unchanged for useDashboardGuard). The 4 completion paths (Welcome skip
/ full flow / Runtime skip / Error recover) all just call
completeOnboarding() and navigate to workspace. On landing, a
StarterContentPrompt dialog renders exactly once per user
(starter_content_state == null) with Import / No thanks. The dialog is
mandatory — no X, no ESC, no outside-click — so state always ends in a
terminal value.
- Migration 054: starter_content_state TEXT, backfill 'skipped_legacy'
for pre-feature onboarded users so they're never prompted
- Server POST /api/me/starter-content/import: transactional claim
(NULL → 'imported') + bulk create project + optional welcome issue +
sub-issues + pins, all in one tx. 409 Conflict on second call
- Server POST /api/me/starter-content/dismiss: transactional NULL → 'dismissed'
- Import decides agent-guided vs self-serve by inspecting the workspace's
agent list at dialog time — fixes path A (Welcome skip + existing
agent) which was previously excluded from starter content
- starter-content-templates.ts replaces bootstrap.ts: pure template
builders, no API calls. Copy is reviewed as UI; server owns atomicity
- StepFirstIssue is now just completeOnboarding() + navigate; error
surface collapses to a Retry button (no more "Continue anyway" branch)
- OnboardingCelebration + just-completed.ts removed (replaced by
StarterContentPrompt which reads server state, not sessionStorage)
Handler hardening
-----------------
- PatchOnboarding: MaxBytesReader 16KB so the JSONB column can't be
weaponized as bulk storage (every /api/me read returns the payload)
- JoinCloudWaitlist: net/mail format check + explicit 254-char cap
- ImportStarterContent: MaxBytesReader 64KB (templates are markdown-heavy
but still bounded); welcome issue's agent_id verified in-workspace
Tests
-----
- Existing onboarding_test.go (waitlist) passes
- step-platform-fork.test.tsx + recommend-template.test.ts (new)
- apps/web test helpers updated for User.starter_content_state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resolve Unknown assignee/creator + tighten prompt copy
Two surface issues on the post-landing starter content dialog:
1. Unknown assignee & Created by
-------------------------------
ImportStarterContent stored `member.id` (the membership row UUID) in
`assignee_id` and `creator_id` for sub-issues. That mismatched the rest
of the codebase — AssigneePicker and resolveActor in issue.go both
store `user_id` for type="member", and `useActorName.getMemberName`
looks members up by `user_id`. The mismatch meant the lookup never
matched any member and fell through to the "Unknown" fallback.
Fix: use `parseUUID(userID)` for both fields. The existing membership
check stays for the 403 signal; we just no longer need the returned
`member.ID`.
2. Dialog copy too long, button labels unclear
----------------------------------------------
Old copy was 3–4 paragraphs of instruction; users need to read less
than that to make a binary choice. Buttons "Import starter tasks" and
"No thanks" also didn't make it clear what "No thanks" actually does —
it starts a blank workspace, so say so.
New:
- Title: "Welcome — add starter tasks?"
- Body: one sentence describing the seeded content
- Left button: "Start blank workspace"
- Right button: "Add starter tasks"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): server decides starter content branch
Problem: the old ImportStarterContent gated the agent-guided vs
self-serve branch on a client-supplied `welcome_issue.agent_id` or
null `welcome_issue`. The client made that decision by reading its
React Query cache of the workspace's agent list — any timing quirk
(cache not populated, stale, race with WS event) could lie to the
server, and there was no way for the server to disagree. Users with
an agent in the DB could still end up on the self-serve branch.
Fix: the server is now authoritative. The client always sends both
template arrays (agent_guided_sub_issues, self_serve_sub_issues) and
a welcome_issue_template (title + description + priority, NO agent_id).
Inside the import transaction the server runs ListAgents on the
workspace — if there's at least one agent, it picks agents[0] (same
ordering the client used: created_at ASC), uses agent_guided_sub_issues,
and creates the welcome issue assigned to that agent. Otherwise it
uses self_serve_sub_issues and skips the welcome issue.
Side effect: the Unknown assignee/creator bug is structurally gone —
no client-supplied id flows into assignee_id/creator_id for type=
"member". The server uses actorID = parseUUID(userID) everywhere,
matching resolveActor in issue.go.
Client surface also simplifies: StarterContentPrompt drops
useQuery(agentListOptions), the hasAgent check, the agentsFetched
button gate, and the branch-specific copy. Dialog description is a
single generic line ("If you already have an agent, we'll also seed
a welcome issue it replies to right away"). buildImportPayload no
longer takes an agentId parameter — one unconditional return shape.
Payload grows ~15 KB (both sub-issue arrays always present); still
well under the 64 KB MaxBytesReader cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): clarify runtime prerequisite, revert dialog agent list
Step 3 runtime (desktop step-runtime-connect.tsx) — scanning and empty
subtitles now name the local AI coding tools Multica drives (Claude
Code, Codex, Cursor, and others), so users understand a runtime alone
isn't enough: they also need one of those tools installed on the
machine. Uses "and others" rather than a closed list so we don't lock
the copy to exactly three integrations.
StarterContentPrompt dialog — reverted the short-lived "try Coding,
Planning, Writing agents and more" rewrite. That was a misread of
feedback meant for the Step 3 prerequisite, not the dialog. The
dialog's current single-sentence "how agents, issues, and context
work in Multica" is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add v0.2.11 changelog (2026-04-21)
Combines the v0.2.9 / v0.2.10 / v0.2.11 releases (plus post-v0.2.11
main commits) into a single landing-page entry, covering the PostHog
pipeline, desktop cross-platform packaging, board pagination and the
recent inbox / agent-task / markdown fixes.
* docs: trim v0.2.11 changelog to user-visible highlights
Drop minor fixes and CLI/daemon polish items — keep only the headline
features and the visible user-facing fixes.
* docs: reprioritize v0.2.11 changelog for external readers
Drop internal MUL-/#PR references, swap in the higher-impact fixes
(daemon workspace isolation, multica update + Windows daemon, board
card description, PostHog default off) that a self-hosted user
actually notices.
* docs: drop PostHog items from v0.2.11, promote multica update to feature
Analytics plumbing is not user-perceivable; replace the PostHog feature
and the PostHog default-off fix with multica update (CLI self-update)
as a feature and keep the Windows daemon persistence as a fix.
* docs: add OpenClaw model read fix to v0.2.11 changelog
* fix(inbox): don't archive after deleting an issue
Deleting an issue from the Inbox page was calling the archive API on the
inbox item right after deleteIssue succeeded. Because the inbox_item row
has ON DELETE CASCADE on issue_id, it was already gone by then and the
archive call 404'd with "inbox item not found", surfacing a "Failed to
archive" toast.
Drop the redundant archive call and invalidate the inbox cache through
the issue:deleted WS handler so every tab stays in sync without an extra
round trip.
* fix(inbox): keep stale selection on /inbox instead of the deleted issue
When another tab deletes the selected inbox issue, onInboxIssueDeleted
prunes the cache and `selected` becomes null. The existing fallback then
redirected to the issue detail page — which is also gone, so the user
landed on a "This issue does not exist..." screen instead of back in the
inbox list.
Track the last key that actually resolved against the inbox list. If it
used to be in the list and just disappeared, clear the selection and
stay on /inbox. Only shared links that were never in the user's inbox
continue to fall back to the issue detail page.
Also add ws-updaters tests covering onInboxIssueDeleted and
onInboxIssueStatusChanged.
* feat(issues): paginate every status column, not just done
Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.
Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.
Backend `ListIssues` already supports per-status pagination, so no
API changes are required.
* fix(issues): handle project / hidden-column / lookup regressions from paginated list cache
After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:
- `project-detail.tsx` filtered the workspace list by `project_id`
client-side, so projects whose issues sat past the first 50-per-status
page rendered empty. Switch to `myIssueListOptions(wsId,
'project:<id>', { project_id })` so the server returns only this
project's issues; add `project_id` to `ListIssuesParams` /
`MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
`issues` array — a paginated fragment. Pass `myIssuesOpts` through to
a per-row subcomponent that reads the real per-status total from the
cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
lookup for task titles / Recent items / current-issue chrome. Switch
both to per-id `issueDetailOptions` via `useQueries` so they're
independent of which page the issue lands on.
Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).
Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
Two unrelated bugs were preventing the GitHub-hosted runner desktop
release matrix from succeeding:
1. Windows job failed with `spawnSync electron-vite ENOENT`. On
Windows the package-local binaries are `.cmd` shims and Node's
`spawnSync` does not consult PATHEXT unless going through a shell.
Pass `shell: true` for both the electron-vite and electron-builder
spawns; on POSIX hosts these are real executables so the shell hop
is harmless.
2. Linux `.deb`/`.rpm` job failed with electron-builder errors:
`Please specify project homepage` and `Please specify author
'email'`. fpm requires a maintainer when generating .deb, and
electron-builder derives it from the app package.json metadata. Add
`description`, `homepage`, `repository`, `author` (with
email) and `license` to apps/desktop/package.json so the Linux
targets have the metadata they need.
Refs: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
Refs: https://www.electron.build/configuration.html#metadata
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agents can end a comment-triggered run without calling `multica issue comment
add` — the final reply stays in terminal / run-log text and never reaches
the user, even though the run panel shows "Completed". PR #1372 addressed
this via prompt wording, but compliance is inherently best-effort.
The server already had an exact fix for the assignment-triggered branch:
`HasAgentCommentedSince` + fallback synthesis from `payload.Output`. The
comment-triggered branch was explicitly exempted on the theory that the
agent "replies via CLI with --parent, so posting here would create a
duplicate" — but that is precisely the path that's failing.
Remove the `!task.TriggerCommentID.Valid` guard so the invariant "every
completed issue task has at least one agent comment on the issue" holds for
both branches. The existing `HasAgentCommentedSince` check still prevents
duplicates for compliant agents, and `createAgentComment` already threads
the synthesized comment under `task.TriggerCommentID` when present.
Regression tests cover both:
- comment-triggered + silent agent → synthesized comment threaded under trigger
- comment-triggered + agent already posted → no duplicate
The renderer's navigation adapter fell back to https://multica.ai when
VITE_APP_URL was unset (i.e. desktop dev builds), so "Copy link" in a dev
build produced a production URL instead of one pointing at the running
dev web frontend. Match the fallback used by pages/login.tsx
(http://localhost:3000) so dev links stay on the dev host.
The inbox detail panel keyed `<IssueDetail>` by `selected.id` (inbox-item
id). `deduplicateInboxItems` picks the most recent inbox notification per
issue, so every new `comment:created` / `reaction:added` event for the
currently open issue produced a fresh inbox item with a new id — flipping
the React key and forcing a full unmount/remount of `IssueDetail`. That
wiped the comment composer draft, dropped focus, and reset scroll.
Key on `selected.issue_id` instead: stable for the life of an open issue
(so input + scroll survive incoming events) and still changes when the
user picks a different issue (so state resets between issues, as before).
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: devv-eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
posthog-js ships with autocapture, heatmaps, dead-click detection,
session recording, exception capture, and surveys all on by default.
Staging verification showed the Activity view flooded with "clicked
button" / "clicked span with text \"…\"" events — they leak
user-typed content into PostHog, burn the billed event budget, and
dilute the explicit funnel. Our product analytics surface is narrow
and intentional (see docs/analytics.md): only the events we emit
server-side plus one manual $pageview belong. Opt all the auto
surfaces off at init time so the Activity view reflects the funnel.
* feat(analytics): add PostHog client with async batch shipping
Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.
Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.
* feat(server): emit signup and workspace_created analytics events
Wires analytics.Client through handler.New and main, then emits the first
two funnel events:
- signup fires from findOrCreateUser (which now reports isNew), covering
both the verification-code and Google OAuth entry points — a single
emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
with is_first_workspace computed from a post-commit ListWorkspaces count
so we can distinguish fresh-user activation from returning-user
expansion.
Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.
* refactor(analytics): drop is_first_workspace from workspace_created
Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.
PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.
* docs(analytics): document self-host safety defaults
Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.
* feat(analytics): emit runtime_registered, issue_executed, team_invite_*
Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:
- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
first_executed_at IS NULL flips the column for the first time. Retries,
re-assignments, and comment-triggered follow-up tasks hit the WHERE
clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
from AcceptInvitation, closing the expansion funnel.
Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.
* feat(config): surface PostHog key via /api/config
Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.
* feat(analytics): wire posthog-js identify + UTM capture on the client
Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.
captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.
Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
cookie and token paths. A getConfig/getMe race is handled by buffering
a pending identify inside the analytics module and flushing it once
initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
loginWithToken and resetAnalytics() on logout so the next login merges
cleanly without bleeding events.
* docs(analytics): describe runtime_registered, issue_executed, invite events
Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.
* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed
Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.
Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.
* fix(analytics): wire $pageview and harden signup_source cookie payload
Two frontend fixes from the PR review:
- PageviewTracker, mounted under WebProviders, fires capturePageview on
every Next.js App Router path / query-string change. Without this the
capturePageview helper in @multica/core/analytics was never called and
the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
*before* JSON.stringify, and drops the whole cookie when the serialised
payload still exceeds 512 chars. Previously the overall slice(0, 256)
could leave a half-JSON string on the wire that neither the backend nor
PostHog could parse.
Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.
* fix(analytics): URL-decode signup_source cookie on read
Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.
* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding
Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.
---------
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
* feat(desktop): support macOS cross-platform packaging
* fix(desktop): use releaseType instead of publishingType in electron-builder publish config
publishingType is not a valid electron-builder key; the correct GitHub
provider option is releaseType. The previous value was silently ignored,
causing uploads to be skipped and breaking auto-update.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): standardize artifact naming across desktop and CLI
Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a
filename alone reveals kind, version, platform, and CPU arch.
Desktop (apps/desktop/electron-builder.yml):
mac → multica-desktop-<v>-mac-<arch>.{dmg,zip}
linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage}
(fixes `\${name}` expanding the scoped `@multica/desktop` into a
broken `@multica/desktop-*` filename path)
windows → multica-desktop-<v>-windows-<arch>.exe
CLI (.goreleaser.yml):
multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz
(adds `-cli` marker + version; switches `_` to `-` for consistency)
Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`:
- drop mac x64 (Intel not a target yet)
- add linux arm64
Final: mac arm64, win x64/arm64, linux x64/arm64.
Downstream updates so install paths match the new CLI names:
- scripts/install.sh
- scripts/install.ps1 (URL + checksum regex)
- CLI_INSTALL.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): use multica_{os}_{arch} CLI archive naming
Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}'
asset names. Install scripts and the desktop CLI bootstrap now resolve
assets via checksums.txt so they work without hardcoding versions.
The Go self-update path queries the GitHub release API and accepts
either the new or legacy 'multica-cli-<version>-...' names so existing
releases keep updating cleanly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): ship both legacy and versioned CLI archive names
GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and
'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every
release. The legacy name keeps already-released CLIs self-updating; the
versioned name is what new clients should use going forward.
Self-update / install paths flipped to prefer the versioned name and
fall back to legacy:
- server/internal/cli/update.go (multica update)
- apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap)
- scripts/install.sh, scripts/install.ps1 (fresh install)
Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(desktop): also build Linux .rpm packages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1
Address review feedback on PR #1262:
- .github/workflows/release.yml: add a 'desktop' job that runs after the
CLI 'release' job and packages the Desktop installers for Linux
(AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes
them to the same GitHub Release via electron-builder. macOS Desktop
continues to ship through the manual release-desktop skill so it can
be signed and notarized with Apple Developer credentials.
- scripts/install.ps1: detect Windows ARM64 hosts via
RuntimeInformation::OSArchitecture so the new windows-arm64 CLI
archive is downloaded on ARM64 machines instead of always falling
back to amd64.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision
electron-builder's update metadata file is hardcoded to `latest.yml` for
Windows regardless of arch (only Linux gets an arch-suffixed name; see
app-builder-lib's getArchPrefixForUpdateFile). With two separate
electron-builder invocations for Windows x64 and arm64, both publish
`latest.yml` to the same GitHub Release and the second upload silently
overwrites the first — leaving one of the two architectures with auto-
update metadata pointing at the other arch's installer.
Route Windows arm64 to its own `latest-arm64` channel:
* scripts/package.mjs appends `-c.publish.channel=latest-arm64` only
for the Windows arm64 invocation, so x64 keeps producing `latest.yml`
and arm64 produces `latest-arm64.yml` alongside it.
* updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows
arm64 clients so they fetch the matching metadata file.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OpenClaw's `--json` result blob carries the actual LLM identifier in
`meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`),
alongside `provider` and the usage breakdown. The backend was reading
the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping
the `model` field entirely, then attributing every run's tokens to
`opts.Model` — which for openclaw is the *agent name* passed via
`--agent`, not a real model identifier — falling all the way through to
"unknown" when no agent.model was configured.
Surface the runtime-reported model:
- `openclawEventResult` gains a `model` string.
- `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty
string when absent for forward-compat with older runtimes / partial
outputs).
- `processOutput` propagates it through the result-blob branch.
- `Execute`'s usage map prefers `scanResult.model`, falling back to
`opts.Model` then `"unknown"` — preserving the prior behavior path
for any runtime that doesn't surface its own model yet.
Two unit tests cover both the populated and missing cases.
Refs: #1395
Surface the actual exec path + argv for every agent backend at INFO
so operators can see the exact command without flipping to debug.
Also add the missing log line in pi.go for consistency with the
other nine backends.
* fix(cli): detach daemon from parent console on Windows
CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.
* fix(cli): make `multica update` work while the binary is running on Windows
On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.
Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.
Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).
* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events
With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.
Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
Extract the URL assembly at the end of S3Storage.Upload into a helper
(uploadedURL) so the four env-var combinations can be covered by a
table-driven test without mocking s3.PutObject. This locks in the fix
from #1300 — cdn > endpoint > bucket — so future refactors can't
silently regress the CDN-wins-over-custom-endpoint case.
No behavior change.
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027
/ #1235: an agent running for workspace A ended up commenting on (and
renaming) a two-day-old issue in workspace B.
#1249/#1259 fixed resolution for autopilot tasks and consolidated the
task-workspace resolver, and #1294 populated workspace_id in the claim
response for run_only autopilot tasks. Those closed the known fallthroughs
but the failure mode is still broader: whenever the daemon or server fails
to supply a workspace, the CLI silently falls back to
`~/.multica/config.json`, which is user-global, not workspace-scoped. On a
host running daemons for multiple workspaces, a single gap in workspace
propagation is enough to leak writes across workspaces.
This PR adds three coordinated guards so no single layer's bug can cause a
cross-workspace write:
1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the
agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env,
both daemon-only markers) and in that context refuses to fall back to
the user-global CLI config. Human / script usage (no agent env) is
unchanged: flag → env → config fallback chain still applies.
2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now
captures the runtime's workspace from `requireDaemonRuntimeAccess` and
enforces `resolved_task_workspace == runtime_workspace` after the
existing issue/chat/autopilot branches. On mismatch or empty, the
handler explicitly cancels the just-dispatched task (via
`TaskService.CancelTask`, which also reconciles agent status) and
returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had
already transitioned the task to 'dispatched' and the agent status to
'working', so a plain 500 would leave both stuck for the ~5 min
stale-task sweep window.
3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the
agent when `task.WorkspaceID` is empty (defense-in-depth against
server bugs and reused workdirs).
Tests:
- `cmd/multica/cmd_agent_test.go`:
`TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests
covering the full fallback matrix (outside agent context still reads
config; agent context uses env; agent context with empty env returns
empty; task-id-only marker also counts; requireWorkspaceID surfaces the
agent-context error message).
- `internal/handler/daemon_test.go`:
`TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` —
constructs a data-inconsistent task (runtime_id in workspace A,
issue_id in workspace B) and asserts the handler returns 500 AND
leaves the task in 'cancelled' state (not 'dispatched').
Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup
workspace filter, cross-workspace audit of agent-facing endpoints,
observability) are out of scope for this PR and tracked separately.
When both AWS_ENDPOINT_URL and CLOUDFRONT_DOMAIN are configured, the
uploaded file URL returned by S3Storage.Upload now uses the CDN domain
instead of the raw S3-compatible endpoint.
This enables S3-compatible backends (MinIO, R2, B2, Wasabi, etc.) to be
paired with a separate public-read domain — previously the CDN domain was
silently ignored whenever a custom endpoint was set, forcing clients to
hit the raw S3 API endpoint which typically requires signed requests.
No behavior change for deployments that set only one of the two vars:
pure AWS S3 with CloudFront, AWS S3 without a CDN, and MinIO/R2 without
a CDN all continue to return the same URLs as before.
The ListIssues and ListOpenIssues SQL queries omitted the description
column, so the API response never included description data. Board cards
checked issue.description (always null) and never rendered it, even when
the Description card property was enabled.
Add description to both SQL queries, the generated Go structs/scan calls,
and the response mapping functions.
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent): add Kimi CLI as agent runtime
Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.
Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.
Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
file (Kimi reads AGENTS.md natively via /init), and writes skills under
`.kimi/skills/` so they are auto-discovered by the project-level skill
loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent/kimi): support per-agent model selection via ACP set_model
Wire Kimi into the model dropdown introduced in #1399:
- ListModels gets a 'kimi' case that drives the same ACP
initialize + session/new handshake as Hermes; both share a new
discoverACPModels helper and parseACPSessionNewModels parser
so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
opts.Model is non-empty, mirroring the Hermes flow. Failures
fail the task instead of silently falling back to Kimi's
default model — silent fallback would hide that the dropdown
pick wasn't honoured.
Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(agent): address code review feedback on Kimi runtime
- Share ACP provider-error sniffer between hermes and kimi. Previously
only hermes promoted stderr-observed 4xx/5xx into a failed task;
kimi would report "completed + empty output" when the Moonshot
upstream rejected a request (expired token, rate limit, …). Rename
hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
Message struct has only one relevant field (Tool), so passing it
twice was a dead fallback. Document that the function normalises
residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
that returns a JSON-RPC error for session/set_model, asserts that
the task result surfaces status=failed with the model name + upstream
message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
(missing cursor, gemini, copilot).
All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.
* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval
Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.
The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.
While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.
Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
handler test packages.
* fix(agent/acp): auto-approve session/request_permission from agent
The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.
The real fix is on our side: respond to session/request_permission.
ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.
Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.
Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
(reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
kimiBlockedArgs gives the wrong impression that it does something.
Comment in kimi.go now points at handleAgentRequest as the real fix.
Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
session/request_permission, assert the reply echoes the id and
carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
with a negative assertion: no dead --yolo / --auto-approve / -y on
argv, since they'd pretend to do something they can't.
All: go build ./..., go vet ./..., go test ./pkg/agent/... green.
* fix(agent/acp): surface kimi tool input/output via content blocks
Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.
Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).
Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
(single/multiple text blocks, terminal-block skip, empty input).
* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI
kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.
Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.
Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
path is unchanged — rawInput is present at tool_call time, so
emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
tool_call_update in_progress frames update the buffered argsText
(cumulative, so overwrite); on completion we parse the accumulated
JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
restarted mid-task) synthesise ToolUse from the update's own
title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
blocks as a compact header ("--- path / +++ path / (edited: N → M
bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
the tool's display block is a DiffDisplayBlock.
Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
in_progress + completed. Asserts no emission until complete, then
[ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.
All agent / daemon / handler test packages green.
---------
Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
The session cookie's Secure flag was tied to APP_ENV, and the
docker-compose self-host stack defaults APP_ENV to "production". On
plain-HTTP self-host deployments (LAN IP, private network) the browser
silently drops Secure cookies, leaving every subsequent /api/* call
anonymous and surfacing as 401 "auth: no token found" right after a
successful login.
Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get
Secure cookies and plain-HTTP origins get non-secure cookies the
browser will actually store. Also harden cookieDomain() against the
other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and
browsers reject. Log a one-shot warning and fall back to host-only.
Docs: correct the COOKIE_DOMAIN description (it was labelled as
CloudFront-only but applies to session cookies too) and call out the
IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and
.env.example.
Refs #1321
AgentLiveCard kept its taskStates map across issueId prop changes, and
its merge logic only added newly-fetched tasks without removing stale
ones. Navigating from Issue A (with a running agent) to Issue B via
cmd+k left A's sticky agent status card pinned on B's page.
Key AgentLiveCard and TaskRunHistory by issue id so React remounts
them when the issue changes, guaranteeing fresh state per issue.
Closes MUL-1147
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.
Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.
- Hoist posting the result comment into its own explicit, numbered step in
both assignment-triggered and comment-triggered workflows, with the exact
`multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
text is never delivered to the user.
- Add regression test covering both workflow branches.
* feat(server): configurable pgxpool size with sane defaults
pgxpool.New(ctx, url) silently sets MaxConns = max(4, NumCPU). On the
prod pods that resolved to 4, which got fully saturated by daemon
claim/heartbeat traffic (~3800 acquires/s) and showed up as ~900ms
acquire waits on every query — the actual root cause of the 3s+
/tasks/claim tail latency. The db pool stats logging from #1378
confirmed this with empty_acquire_delta == acquire_count_delta.
Switch to pgxpool.ParseConfig + NewWithConfig and apply per-pod
defaults of MaxConns=25 / MinConns=5, both overridable via env vars
(DATABASE_MAX_CONNS / DATABASE_MIN_CONNS) so the size can be tuned
in prod without a redeploy.
The defaults follow the standard 'small pool, lots of waiters' guidance
for Postgres (PG community / HikariCP formula
`(core_count * 2) + effective_spindle_count`); 25 leaves headroom for
bursts and occasional long queries while staying safely under typical
managed-Postgres max_connections ceilings when multiplied across pods.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): respect DATABASE_URL pool_* params; add precedence tests
Address review feedback on #1381:
- Configuration precedence is now explicit: DATABASE_MAX_CONNS env >
pool_max_conns query param on DATABASE_URL > built-in default. Same
for min_conns. Previously the env-empty path unconditionally
overwrote whatever ParseConfig had read from the URL — a silent
regression for deployments that already tuned pool size via the
connection string.
- Add unit tests in dbstats_test.go covering each precedence branch
(defaults, URL-only, env-over-URL, partial URL, invalid env,
min>max clamp).
- Move pool tuning vars out of 'Required Variables' into a new
'Database Pool Tuning (Optional)' section in SELF_HOSTING_ADVANCED.md
so self-hosters don't think they need to set them.
- Add commented entries in .env.example.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): invalid pool env falls back to URL/code default, never pgx 4
Address second round of review on #1381:
Previous code passed cfg.MaxConns / cfg.MinConns as the envInt32 fallback,
which meant an invalid DATABASE_MAX_CONNS value silently fell back to
ParseConfig's value — i.e. pgx's built-in default of 4/0 when the URL had
no pool_* params. That's exactly the bad value this PR exists to remove,
and the previous test (TestPoolSizing_InvalidEnvFallsBack) accidentally
locked it in.
Compute the non-env fallback first (URL pool_* if present, else code
default 25/5) and pass that to envInt32. Misconfigured env now lands on
the same value as if the env were unset — never on the pgx default.
Replace the loose 'max > 0' assertion with two precise tests:
- invalid env + no URL param → code default (25/5)
- invalid env + URL param → URL value
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After merging the per-phase claim slow-logs (#1376), the prod data showed
the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces,
ping) all completed at the *same wall-clock instant* with durations
clustered at ~1.4s and ~2.88s — and within the claim breakdown,
list_pending_ms was 713ms even when list_pending_count=0.
A 0-row indexed scan can't take 713ms, and unrelated endpoints don't
synchronize their completion by accident. The only explanation that fits
is requests blocking on a shared resource and being released together.
The most likely culprit is pgxpool connection-acquire wait: pgxpool.New
is called with no config, so MaxConns defaults to max(4, NumCPU) — under
the daemon poll fan-in this is trivially exhausted.
This change adds the observability needed to confirm/refute that without
changing pool sizing yet (pool sizing is a follow-up once we have data):
- logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime /
MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the
effective limit is critical because the default is surprisingly small
and easy to mistake for 'database is slow'.
- runDBStatsLogger: samples pool.Stat() every 15s (matches daemon
heartbeat cadence for easy correlation). Emits INFO with TotalConns /
AcquiredConns / IdleConns and per-window deltas (acquire_count,
empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to
WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0
— those are the direct symptom of a request having to wait because
no idle connection was available.
If on prod we see 'db pool pressure' WARN lines coincident with the
claim_endpoint slow lines, the hypothesis is confirmed and the fix
becomes a one-liner (pool config tuning + the existing N+1 reduction
ideas to lower demand).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): add slow-path timing logs for /tasks/claim
We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.
Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:
- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
build_ms so we can tell whether the slowness is in the actual claim
query or the post-claim response assembly (GetAgent, LoadAgentSkills,
GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
branch's 4 queries).
- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
list_pending_count, agents_tried, claim_loop_ms — directly validates
the suspected N+1 amplification (one ListPendingTasksByRuntime + one
ClaimTask per unique agent).
- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
LOCKED cost from the surrounding metadata reads.
Pure observability change. No behavior change in the request path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): widen claim slow-log to cover post-claim DB work and error paths
Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.
Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
and dispatch_ms phase fields. Error paths now also emit a slow log
with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
error paths now also carry phase timings (outcome=unauth / error_claim).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard
Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.
Two layers of defense:
1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
and execenv.InjectRuntimeConfig emit the same "use this exact
--parent, do not reuse values from previous turns" block. The
per-turn prompt now carries the current TriggerCommentID, which it
previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
mid-session).
2. Handler-side guard in CreateComment: when an agent posts from inside
a comment-triggered task (X-Agent-ID + X-Task-ID, task has
TriggerCommentID), require parent_id == task.TriggerCommentID or
return 409. Assignment-triggered tasks are untouched.
* fix(agent/comments): scope parent_id guard to the task's own issue
Two issues from CI + GPT-Boy's review:
1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
agent legitimately commenting on a different issue while its current
task was comment-triggered would get 409'd with the wrong issue's
trigger comment id. Narrow the guard to fire only when the request's
issue matches the task's own issue — cross-issue agent activity
stays unblocked.
2. The integration test tried to insert a second queued task for the
same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
unique index. Replace the assignment-triggered-task sub-case with a
cross-issue regression test (the scenario we now need to cover anyway):
post on issue B while X-Task-ID points at a comment-triggered task on
issue A, expect 201.
Replace the single day picker in the "Weekly" frequency with a multi-select
so users can schedule on any combination of weekdays (e.g. Mon/Wed/Fri)
in addition to the existing "Weekdays" Mon-Fri preset.
The backend already accepts any day-of-week list in the cron expression,
so this is a frontend-only change. Relabels the tab to "Days" to reflect
the new behavior.
* fix(agent/codex): surface stderr tail in initialize / turn startup errors
When codex app-server exits before the JSON-RPC handshake completes —
e.g. because the user put a flag in custom_args that the subcommand
rejects — the Result.Error users see is `codex initialize failed:
codex process exited`, while codex's actual complaint (typically
something like `error: unexpected argument '-m' found`) only lives in
daemon logs.
Wrap the stderr writer with a bounded stderrTail that still forwards
to the slog logWriter but also retains the last 2 KiB of bytes
written. Include that tail on the three startup failure paths
(initialize, startOrResumeThread, turn/start). Runtime cancellation
paths are left untouched — they're our own abort and the stderr
context isn't a clear signal there.
Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail
loudly" actually be workable by giving the failure a real message.
* fix(agent/codex): join cmd.Wait() before sampling stderr tail
Addressing review of #1314: reading stderrBuf.Tail() right after
c.request returns "codex process exited" was racy. Nothing in that
path synchronizes with os/exec's internal stderr copy goroutine —
cmd.Wait() is the only documented join point. The original defer ran
cmd.Wait() later, but by then we had already built Result.Error from
a potentially-empty Tail().
Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a
sync.Once-wrapped drainAndWait closure. Call it explicitly on the
three startup failure paths before sampling the tail; keep it as the
cleanup defer so the success path behaves identically.
Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a
real subprocess that prints to stderr and exits before responding to
initialize, runs it through Execute, and asserts Result.Error
contains the stderr hint. This covers the full timing path the
reviewer flagged, which the helper-level tests in this PR did not.
* feat(desktop): hourly update poll + manual check button in settings
The previous updater only ran one check 5s after launch, so a missed
or failed initial check meant the user had to fully restart the app to
see a new release. Add a 1h background poll for long sessions and a
"Check now" button under a new Updates tab in Settings so the user can
trigger a check on demand without waiting.
The button reuses the existing autoUpdater pipeline — when an update is
available the existing corner notification still drives the download
flow; the settings tab only surfaces the immediate check result
(up-to-date / available / error).
* fix(desktop): trust electron-updater's isUpdateAvailable for the manual check
Per review: deriving `available` from a version-string compare is wrong —
`updateInfo.version` can differ from `app.getVersion()` while
electron-updater still suppresses `update-available` (pre-release channels,
staged rollouts, downgrade scenarios, min-system-version gates). In those
cases the settings tab would say "vX is available" but no corner download
prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is
electron-updater's own answer.
Follow-up to PR #1188 / migration 047, which intentionally omitted the
five historical conflict slugs (admin / multica / new / setup / www) from
the reserved-slug audit because each had one production workspace using
it at the time and we did not want to block deploy on owner outreach.
MUL-972 closed that loop on prd for four of the five:
* admin (99cd10e4-…) → renamed to legacy-admin-99cd10e4
* multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa
* new (e391e3ed-…) → renamed to legacy-new-e391e3ed
* www (5e8d38b2-…) → workspace deleted (was empty: 0 issues /
projects / agents, owner-only member; 18
workspace-FK relations all CASCADE)
This PR:
1. Adds migration 049_audit_legacy_reserved_slugs which audits those
four slugs against workspace.slug at startup. If any future workspace
slips in with one of them, startup fails loudly via RAISE EXCEPTION
instead of being silently shadowed by a global route. Mirrors the
structure of 047.
2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list
in both the Go handler and the shared TS list (admin was already in
both). Keeps the two lists in lockstep per the convention enforced
in workspace_reserved_slugs.go header.
setup is STILL exempt from the audit and is intentionally NOT added to
the reserved list. The setup workspace (b43f0bc2-…) is a real production
user (owner: Roberto Betancourth, building a chants/Alabanzas app) and
is being handled out-of-band via owner outreach. A separate follow-up
migration will fold setup into the audit once that workspace's slug has
been migrated.
Migration is intentionally shipped AFTER the prd data fix (not before):
049 will RAISE EXCEPTION on any remaining conflict, so we want the data
state clean first. Rollout order:
prd data fix (done by db-boy on 2026-04-20) → this PR.
Tested:
- go test ./server/internal/handler/ -run TestReserved → pass
- pnpm --filter @multica/core test consistency → pass (4/4 in
consistency.test.ts; global-prefix↔reserved invariant holds)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When Desktop opens /login?platform=desktop in the browser and the user
already has a valid web session, the page previously bounced them to
their workspace and Desktop never received a token. Now we mint a bearer
token via issueCliToken and redirect through the multica:// deep link so
Desktop completes sign-in without a second Google round-trip.
Refs: MUL-1080
Fixes#1332.
Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:
1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
any agent configured with a Model field crashed in ~700ms with
`exit status 1`. Remove both forwards, and add them to
openclawBlockedArgs so custom_args can't reintroduce the crash.
Model is bound at registration time via `openclaw agents
add/update --model`.
2. AgentInstructions were written to `{workDir}/AGENTS.md` by
execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
from its own workspace dir — the file was never read, so every
agent's Instructions field was silently discarded. Populate
opts.SystemPrompt for the openclaw provider in runTask and
prepend it to the `--message` payload in the backend so the
model actually receives the instructions.
Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.
Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
* fix(chat): preserve chat session resume pointer across failures
The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:
- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
so any task that completed without a session_id (early agent crash,
missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
follow-up chat message claimed in between resumed against a stale (or
NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
fell back to the existing GetLastChatTaskSession query, so a single
bad turn could permanently drop the conversation memory.
This change:
- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
service) to forward session_id/work_dir, so failed/blocked tasks that
built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
chat_session pointer is missing, and include failed tasks in that
lookup so a single failure can't lose the conversation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): forward session_id/work_dir on blocked + timeout paths
runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:
- timeout returned a naked error, so handleTask called FailTask with
empty session info and the chat resume pointer was either left stale
or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
SessionID / WorkDir, so even though FailTask now COALESCEs into
chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
even when a real session_id had been built.
All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(copilot): capture session id from session.start as fallback
The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.
Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(copilot): parse premiumRequests as float to preserve session id
Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.
This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.
Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
The v0.2.6 self-host security fix (#1307) defaults APP_ENV to "production"
in docker-compose.selfhost.yml, which disables the 888888 master verification
code. The follow-up docs PR (#1313) covered SELF_HOSTING.md and the
installers, but `.env.example` — the file users actually copy and edit —
still makes no mention of APP_ENV, so operators who don't read the prose
docs hit the exact same "888888 stopped working after upgrade" confusion
reported in #1331.
- Add APP_ENV= to .env.example with a comment block that spells out the three
cases (Docker default, local dev, evaluation) and warns against enabling
the bypass on public instances. Keeping the value empty preserves the
current `make dev` UX (Go server reads empty → treats as non-production →
888888 works locally) while `${APP_ENV:-production}` in the compose file
still ensures public Docker deployments are safe by default.
- Update the existing 888888 mention under # Email so it no longer
contradicts the new gating rule.
- Update the `make selfhost` post-start banner, which still told operators
to "Log in with any email + verification code: 888888" even after #1307
disabled that path by default.
Adds a "Create sub-issue from selection" button to the editor bubble
menu. When an issue context is present (description editor, comment
input, reply input, comment edit), selecting text and clicking the
button creates a new issue parented under the current issue and
replaces the selection with a mention link to the new issue.
Closes#930
- Added environment variables to control signups
- Updated frontend to hide signup text when disabled
- Added backend check to block new user creation via magic link
- Updated .env.example
ESC did nothing inside the issue description editor because browsers
don't blur contenteditable elements by default, leaving users stuck in
the editor with no keyboard escape hatch.
Add a blur-shortcut extension mirroring TitleEditor's behavior and wire
it into ContentEditor's edit-mode extension set.
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
* fix(sidebar): stabilize useQuery default arrays to prevent render loop
Inline `= []` defaults on `useQuery` return a new array reference on
every render when `data` is undefined (query disabled or mid-load).
Downstream effects/memos that depend on the value then fire every
render; the pinned-items `useEffect` compounds this by calling
`setLocalPinned` each time, so under sustained `data === undefined`
(e.g. backend unreachable, WebSocket in reconnect loop) React trips
its "Maximum update depth exceeded" guard and the sidebar becomes
unusable.
Use module-level empty-array constants so the default identity stays
stable across renders.
* fix(chat): short-circuit ResizeObserver update when bounds unchanged
The resize observer always called `setRevision(r => r + 1)` from its
callback, even when `clientWidth`/`clientHeight` were identical to the
previous reading. Any spurious notification — sub-pixel layout jitter
during mount, or an ancestor reflow triggered by an unrelated state
update — then fed back into the same render cycle and could exceed
React's update-depth limit.
Guard the state bump by comparing against the previous bounds, and
leave `setBoundsReady(true)` outside the guard since it's idempotent.
Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.
Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
* fix(agent/codex): route custom_args -m/--model to thread/start payload
Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.
Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.
Fixes#1308.
* fix(ui/agents): drop Codex-incompatible --model example from custom args tab
The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.
Refs #1308.
* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"
This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.
The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.
Refs #1308.
* feat(agent): add LaunchHeader per agent type
Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.
Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.
Refs #1308.
* feat(handler/runtime): surface launch_header on runtime response
runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.
Refs #1308.
* feat(ui/agents): show launch mode preview in custom args tab
Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.
Refs #1308.
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth
Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().
Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.
* fix(auth): validate next= redirect target to prevent open redirect
Closes#1116
Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.
Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.
---------
Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
* fix(comment): assignee on_comment path should use reply id, not thread root
Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.
Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.
Fixes#1301
Made-with: Cursor
* test(comment): assert assignee on_comment records reply id as trigger_comment_id
Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.
Made-with: Cursor
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.
Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.
Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
When --resume targets a dead session, claude prints
"No conversation found with session ID: ..." to stderr, emits a stream-json
system init with a fresh session_id, then exits with code 1. The backend
was treating that fresh id as the authoritative session, so
daemon.go's retry-with-fresh-session fallback (SessionID == "" guard)
never triggered. Every subsequent task for the same (issue, agent) pair
stayed permanently broken until the server-side session_id was cleared by
hand.
Fix: when --resume was requested but the emitted session_id differs AND
the run failed, drop the fresh id from Result so the daemon's existing
fallback can do its job. Factored into a pure helper and unit-tested.
Fixes#1284
Co-authored-by: fuxiao <fuxiao@zyql.com>
* fix(agent): add per-agent mcp_config field to restore MCP access
Closes#1111
The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.
This PR completes the original design by:
- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
via custom_args
Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).
* fix: disambiguate JSON null vs absent for mcp_config
The release workflow previously triggered on 'v*', which matched a
stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran
again and overwrote the Homebrew formula with a 0.2.5-dirty version
whose tarball URLs 404.
Tighten the trigger to semver-shaped tags and add an explicit guard
that fails the job if the tag name contains '-dirty' (which can come
from 'git describe --tags --dirty').
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Docs site is no longer auto-deployed via Vercel (disabled in
dashboard), so building it on every PR adds friction without
catching anything actionable. Use turbo's negative filter to
skip @multica/docs across all three tasks.
Self-hosted services (postgres, backend, frontend) should restart
automatically on failure or host reboot. This is standard practice
for production docker-compose deployments.
Co-authored-by: Zhazha <zhazha@openclaw.internal>
- Mark AppLink draggable={false} and add pointer-events-none while
dragging, so the browser's native <a> drag (which otherwise navigates
to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
refetch) cannot reorder the DOM under dnd-kit's drop animation.
Mirrors the pattern already used by board-view.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.
- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
ensuring .agent_context/skills/ is not created for copilot
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
electron-builder 26.8.1 rejects publishingType under the GitHub publisher;
the correct option for selecting draft/prerelease/release is releaseType.
Using publishingType caused schema validation to fail during packaging.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agents): make issue tasks easier to open from agent details
Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.
Closes#1129
* fix(agents): use workspace issue paths in tasks tab
* test(agents): cover tasks tab issue links
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
* fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it
The /docs rewrite was running in the default afterFiles slot, which is
evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/
matched /docs first as a workspace named "docs" (which doesn't exist) and
returned 404 before the rewrite to the docs Vercel project ever fired.
Splitting rewrites into beforeFiles/afterFiles puts /docs and
/docs/:path* ahead of route resolution so they always proxy to the docs
zone.
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.
Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.
Changes:
- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
of the `profile` argument. The argument is retained for migration-only
use (see promotion below).
- Migration path: when the canonical file is missing and the requested
profile has a pre-change per-profile daemon.id, promote that UUID in
place so a user who only ever ran under a named profile keeps the same
identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
returns every UUID that survives parsing. `config.go` now appends those
to the daemon's `legacy_daemon_ids` payload, so any runtime rows
previously registered under a per-profile UUID (on any backend) get
merged into the canonical machine UUID at register time.
Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
Adds two new toggleable card properties that surface issue context at a glance:
- Project: shows the parent project icon + title when the issue belongs to one.
- Sub-issue progress: gates the existing progress ring behind a card property
so users can hide it when not useful.
Both default to on; toggled via the existing "Display" popover.
* feat(daemon): persistent UUID identity + legacy-id merge at register-time
daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on
first start, replacing the hostname-derived id that drifted whenever
`.local` appeared/disappeared, a system was renamed, or a profile
switched — each of which used to mint a fresh `agent_runtime` row and
strand agents on the old one.
To migrate existing installs without operator intervention, the daemon
reports every legacy id it may have registered under previously
(`host`, `host` with `.local` stripped, and `host[-profile]` variants
for both). At register-time the server looks up each candidate row
scoped to (workspace, provider), re-points its agents and tasks onto
the new UUID-keyed row, records which legacy id was subsumed in the
new `legacy_daemon_id` column for audit, and deletes the stale row.
Result: users running `xxx.local`-keyed runtimes today transparently
land on the new UUID row on next daemon restart.
The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'`
compatibility shim is no longer needed and has been removed along with
the handler call that invoked it.
* fix(daemon): handle bidirectional .local drift and case drift in legacy merge
Review on #1220 flagged two gaps in the legacy-id migration candidate set:
1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the
current hostname ended in `.local`. The opposite direction — DB has
`foo.local`, current host is `foo` — was missed, so runtimes registered
under the `.local` variant stayed orphaned after upgrade. Now both
variants (`foo` and `foo.local`) are always emitted, regardless of what
`os.Hostname()` currently returns, plus their `-<profile>` suffix forms.
2. Case drift: os.Hostname() has been observed returning different casings
on the same machine across mDNS/reboot state. A case-sensitive `=`
comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the
daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID
now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing
differences merge rather than orphan. The (workspace_id, provider) prefix
still bounds the scan to a tiny set of rows so the non-indexed LOWER()
comparison has negligible cost.
Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases;
daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal
and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift.
* fix(daemon): consolidate every case-duplicate legacy runtime, not just the first
Follow-up review on #1220: after switching to `LOWER(daemon_id) =
LOWER(@daemon_id)`, the single-row lookup still only merged one legacy
row per candidate. If a machine already had two rows in the DB that
differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND
`jiayuans-macbook-pro.local` coexisting because earlier hostname drift
already minted a duplicate), only one of them got consolidated and the
other stayed orphaned — violating the "no duplicate runtime per machine
after backfill" acceptance.
- FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many)
- mergeLegacyRuntimes iterates every returned row and dedupes across
overlapping legacy candidates so `foo` and `foo.local` both resolving
to the same stored row don't double-process
Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two
case-duplicate rows with one agent each and confirms both rows are
deleted and both agents end up on the new UUID-keyed row.
These trigger kinds exist in the DB schema but nothing on the server
fires them:
- autopilot_scheduler.ClaimDueScheduleTriggers filters kind='schedule'
(pkg/db/queries/autopilot.sql:150)
- DispatchAutopilot is reached only from the scheduler (source:schedule)
or POST /api/autopilots/{id}/trigger (source:manual); no inbound
webhook or api endpoint exists
- The UI only surfaces schedule creation
Exposing them in the CLI lets users create triggers that sit in the DB
doing nothing. Drop --kind from trigger-add, require --cron, always
send kind=schedule. Re-add the flag when the server grows a dispatch
path for the other kinds.
Follow-up to #1249. Two small follow-ups requested in review:
1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and
`service/task.go`. #1249 fixed the handler copy but left both in place,
meaning any future branch (e.g. a fourth task link type) still needs
to be added in two files. Promote the service method to the exported
`TaskService.ResolveTaskWorkspaceID` and delete the handler copy.
Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now
call through `h.TaskService`.
2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace`
covering the exact scenario from #1224: a task linked only via
`AutopilotRunID` must resolve to the autopilot's workspace. The test
asserts 404 for a cross-workspace daemon token and 200 (with status
transitioning to `running`) for the correct-workspace token.
Follow-up to #1192. Document the v2 protocol contract that the
dispatch-level threadId guard relies on, and lock down the two leakage
paths the guard closes:
- turn/completed from a subagent thread must not call onTurnDone
- item/completed (agentMessage, final_answer) from a subagent thread
must neither leak text into the output builder nor terminate the turn
Without these tests a future refactor that drops or relocates the guard
would not be caught by CI, since existing notification tests omit the
top-level threadId field and pass through unfiltered.
* feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).
Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.
- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
* fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:
1. run_only mode is broken end-to-end. The daemon-side
resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
workspace from issue/chat, so run_only tasks (which have neither)
return 404 from /start. BuildPrompt() would also emit an empty issue
ID. The service-level resolver in internal/service/task.go already
handles AutopilotRunID, but the daemon endpoint uses the handler
copy. Fixing that path is out of scope for the CLI PR; drop
run_only from the CLI and docs so we don't recommend a mode that
cannot complete. Server continues to accept it for the existing UI.
2. UpdateAutopilot did not verify that a new assignee_id belongs to
the workspace, unlike CreateAutopilot. This let a PATCH swap in an
agent from a different workspace. Mirror the same
GetAgentInWorkspace check.
The route-level loading.tsx creates a Suspense boundary that shows a
generic skeleton on every page navigation within the dashboard. Since
every page already handles its own data-loading skeleton via TanStack
Query isLoading, this causes two sequential skeleton flashes:
loading.tsx skeleton → page skeleton → content.
Removing it makes the old page stay visible during route transitions
(typically <100ms), then the new page renders directly with its own
skeleton — a single, smooth transition.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): filter thread/status/changed by threadId to prevent subagent interference
When Codex CLI has memories enabled, the app-server spawns a memory
consolidation subagent as a separate thread within the same stdio
connection. When that subagent thread finishes and transitions to idle,
the daemon's codex backend mistakenly interprets the idle signal as the
main turn completing, causing it to close stdin and cancel the context
before the real turn produces any output.
Add a threadId check to the thread/status/changed handler so only
status changes from the tracked thread trigger turn completion. Signals
from subagent threads (threadId != c.threadID) are now ignored.
Fixes#1181
* fix(codex): dispatch-level threadId filter for subagent notifications
Codex multiplexes subagent threads (e.g. memory consolidation) on
the same stdio pipe. Previously only thread/status/changed had a
threadId guard, but item/completed (agentMessage + final_answer),
turn/completed, and turn/started from subagent threads could still
trigger onTurnDone or contaminate output.
Move the threadId check to the top of handleRawNotification so all
notification handlers are protected. Remove the now-redundant
per-handler check on thread/status/changed.
Fixesmultica-ai/multica#1181
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
resolveTaskWorkspaceID only handled tasks linked via IssueID or
ChatSessionID. Tasks created by run_only autopilots (introduced in
#1028) have only AutopilotRunID set, so the resolver returned an empty
workspace ID, causing requireDaemonTaskAccess to respond with 404.
Add an AutopilotRunID branch that looks up the autopilot run, then
its parent autopilot, to obtain the workspace ID.
The project CRUD commands (list, get, create, update, delete, status)
and the `--project` flag on issue commands have been implemented in
the CLI but were not yet documented. Add them to both the docs site
reference and the repo-level CLI_AND_DAEMON.md so the feature is
discoverable.
Closes MUL-867
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the http/https scheme allowlist structurally enforced instead of a
convention. Move the allowlist check + shell.openExternal call into a
single openExternalSafely wrapper in external-url.ts, have both main-process
call sites (the IPC handler and setWindowOpenHandler) go through it, and
add an ESLint no-restricted-syntax rule that bans direct shell.openExternal
usage anywhere under apps/desktop/src/main/ except external-url.ts itself.
This is the follow-up to #1124: same safety guarantee, but a reviewer can
no longer accidentally reintroduce a bare shell.openExternal somewhere that
bypasses the check — the lint rule catches it at CI time. Also restores
the scheme info in the warn log (lost when the helper was extracted).
Test coverage extended to the cases the original PR review flagged but
didn't ship: casing (FILE:// / HTTPS://), javascript: / data:, ftp / smb,
vscode:// / ms-msdt:, mailto / tel, credentials-in-URL, empty / malformed.
Added two openExternalSafely tests (electron mocked) confirming allowed
URLs forward and rejected URLs do not.
Closes a follow-up bullet from the internal #1115 / #1124 review.
* fix(desktop): restrict shell.openExternal to http/https schemes
The Electron main-process IPC handler for shell:openExternal called
shell.openExternal with whatever string the renderer passed, with no
scheme validation. Under this app's intentional webSecurity: false and
sandbox: false configuration (#648), any unsafe content path in the
renderer reaching this IPC becomes a way to dispatch arbitrary OS
protocol handlers — file://, smb://, vscode://, Windows ms-msdt:,
and so on.
Parse the URL and reject anything outside http/https (the only schemes
any legitimate call site uses today). Matches the Electron security
checklist guidance for openExternal on non-isolated renderers.
Closes#1115
* Close the desktop external-open gap on target=_blank links
The original fix validated only the IPC path, but the renderer could still trigger shell.openExternal through setWindowOpenHandler for target="_blank" links and window.open(). This change reuses one allowlist helper for both sinks and adds a focused unit test for the helper contract.
Constraint: Desktop shell.openExternal must stay limited to http/https despite webSecurity=false and sandbox=false
Rejected: Duplicate URL validation logic in each sink | easy to drift and harder to test
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep all desktop external-open paths on the same validator so new sinks do not bypass the allowlist
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop test -- src/main/external-url.test.ts
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop typecheck
Not-tested: Full desktop app manual smoke run
Related: #1115
---------
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network
On macOS, Codex's Seatbelt sandbox in workspace-write mode silently
ignores '[sandbox_workspace_write] network_access = true' (see
openai/codex#10390). That blocks DNS inside the sandbox, so 'multica
issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such
host' — this is what caused MUL-963.
Changes:
- New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox
policy based on runtime.GOOS and the detected Codex CLI version.
Non-darwin or darwin with a known-fixed version keeps workspace-write
+ network_access=true; older darwin falls back to danger-full-access
and logs a warn with upgrade hint. The fix-version threshold is a
single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy
to bump once upstream ships.
- Per-task config.toml now gets a 'multica-managed' marker block
(BEGIN/END comments) rewritten idempotently; user-owned keys outside
the markers are preserved. Legacy inline sandbox directives from
earlier daemon versions are stripped on migration.
- execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a
codexVersion arg; daemon.go caches detected versions at registration
and threads them through to Prepare/Reuse.
- Replaces the old ensureCodexNetworkAccess tests with
platform-parameterised coverage (linux vs darwin, idempotency,
legacy-migration, policy matrix).
- docs/codex-sandbox-troubleshooting.md: symptom fingerprint table,
decision matrix, self-check commands, trade-offs.
Refs: MUL-963
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): hoist managed sandbox block above user tables (MUL-963)
Review on #1246 flagged that upsertMulticaManagedBlock appended the
managed block to EOF. If the user's config.toml ends inside a TOML table
(e.g. [permissions.multica] or [profiles.foo]), a trailing bare
sandbox_mode = "..." is parsed as a key of that preceding table, so
Codex silently ignores the policy the daemon meant to apply.
Two changes make the block position-independent:
- renderMulticaManagedBlock now emits only top-level key=value lines and
uses TOML dotted-key form (sandbox_workspace_write.network_access =
true) instead of opening a [sandbox_workspace_write] header. The block
therefore neither inherits from nor leaks into any surrounding table.
- upsertMulticaManagedBlock always hoists the block to the top of the
file (stripping any previously written managed block first), so the
sandbox_mode line is always at the TOML root regardless of what the
user put below it. This also migrates configs written by the original
PR #1246 logic where the block was trapped behind a user table.
Added tests for the regression scenario (pre-existing [permissions.*]
table) and the legacy-trailing-block migration; updated the existing
Linux default test and the troubleshooting runbook to reflect the
dotted-key form.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Autopilot was formatting the triggered-at timestamp with time.RFC3339
(e.g. "2026-04-16T14:54:32Z"), which is hard to read and confusing for
users in non-UTC timezones because the "Z" suffix looks like an error
instead of a timezone indicator.
Switch to a human-readable format ("2026-04-16 14:54 UTC") so only the
hour differs from local time; minutes match across timezones, making
the value easy to reconcile at a glance.
Fixesmultica-ai/multica#1197.
Shared inbox links (?issue=<id>) pointed to notifications that may no
longer exist in the current user's inbox (archived, or received by
someone else). The detail pane would fall back to an empty state and
leave the user stuck.
After inbox loads, if the selected key has no matching item, replace
the URL with /issues/<id> so the link still resolves to something
meaningful.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
crypto.randomUUID() is only defined in secure contexts, so self-hosted
HTTP deployments were throwing TypeError on mount and when clicking Add.
Route the id generation through the existing createSafeId() helper so
the tab works in non-secure contexts too.
Fixes#1214
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated)
- CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique
- Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying
- Down migration drops the constraint only; deleted rows and cascaded data are not restored
Co-authored-by: Anup Joy <joyanup@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Desktop-launched daemons have their CLI binary overwritten by the Desktop
app on every launch, so any in-app update is reset. The detail panel already
renders 'Managed by Desktop' and hides the Update button when
metadata.launched_by === 'desktop', but the sidebar red dot
(useMyRuntimesNeedUpdate) and the list arrow (useUpdatableRuntimeIds) still
flagged them because runtimeNeedsUpdate() only considered mode/owner/version.
Short-circuit runtimeNeedsUpdate() on launched_by === 'desktop' so all three
surfaces (sidebar dot, list arrow, detail panel) agree and defer CLI
upgrades to the Desktop auto-updater.
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Our CLI release flow pre-creates a *published* GitHub Release via
`gh release create`. electron-builder's default `publishingType: draft`
conflicts with `existingType=release` and causes the DMG/ZIP/blockmaps/
latest-mac.yml uploads to be silently skipped, which breaks
electron-updater auto-update on installed clients (observed on v0.2.4,
had to fall back to `gh release upload` manually).
Explicitly setting `publishingType: release` aligns electron-builder
with our release flow so desktop artifacts are uploaded to the existing
published release automatically.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(desktop): tabs are per-workspace, not cross-workspace
Tabs are now grouped by workspace in the store; the TabBar shows only the
active workspace's tabs, and switching workspace swaps the visible group.
Before this change tabs were a flat list that spanned workspaces, which
produced a confusing experience: working in acme with three tabs, then
switching to butter and back, still showed whatever tabs you happened to
open while you were in butter alongside your acme work.
The bug had the same shape as the pre-workspace-overlay bug we fixed in
#1237 — a concept ("workspace") was encoded in data (tab paths) but
ignored by the UI that displayed it (TabBar). The fix is structural:
make the data model match the concept.
Key changes:
- **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`.
The invariant "every tab belongs to a workspace group" is enforced at
sanitize time and at migration time; there is no longer a root `/`
sentinel.
- **NavigationAdapter** detects cross-workspace pushes and delegates to
`switchWorkspace(slug, path)` instead of navigating the active tab's
router. All existing call sites in shared code (sidebar dropdown,
settings post-delete redirect, invite-accept, cmd+k) keep calling
`push(paths.workspace(x).issues())` unchanged.
- **TabContent** renders only the active workspace's tabs under Activity.
Cross-workspace state preservation is an explicit non-goal — switching
workspaces should feel like switching.
- **WorkspaceRouteLayout** auto-heal no longer navigates the tab router
to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`)
that drops the whole stale group in one go.
- **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the
user has workspaces; the new-workspace overlay opens/closes based on
workspace count independently of any route.
- **Persistence migration** (v1 → v2) groups old flat tabs by extracted
slug, drops root / transition / reserved-slug tabs, and picks an
active workspace from the old active tab's owning group. No data
loss for existing users with workspace-scoped tabs.
Web is unchanged — tabs are a desktop-only concept. `packages/views`,
`packages/core`, `apps/web` are all untouched. `setCurrentWorkspace`
in core remains the single source of truth for the API client's
workspace header, driven by `WorkspaceRouteLayout` as before.
Tests: 19 tab-store tests (sanitize, migration, switchWorkspace,
validate, close-last-reseeds, reset). 38 desktop tests total pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: stable selectors + defensive guards on tab-store
Addresses self-review findings on #1239.
**C1 — perf cliff from unstable selector returns.** The previous
`useActiveTab()` selector used `.find()` inside, so every router tick
on the active tab (which replaces the Tab object via immutable spread
in updateTab / updateTabHistory) forced every subscriber to re-render.
Replaced with finer-grained selectors:
- `useActiveTabIdentity()` — { slug, tabId } primitives (stable across
unrelated updates).
- `useActiveTabRouter()` — stable object reference for a tab's lifetime.
- `useActiveTabHistory()` — { historyIndex, historyLength } numbers.
`useTabHistory` and `DesktopNavigationProvider` now consume the
primitive selectors, so back/forward buttons don't churn on every
path change. A non-hook `getActiveTab(state)` helper covers the
event-handler case.
**I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in
case a malformed path ever reaches the adapter's detector.
**I2 — merge warns on path/slug mismatch.** Previously silent drop;
now `console.warn` makes the condition visible during debugging.
**Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab
object eliminates a redundant store read per rendered tab.
Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts`
still uses `window.location.assign` for workspace-deleted eviction —
that's a full renderer reload on desktop, which post-refactor wastes
the careful in-memory tab state we just set up. Fixing cleanly requires
a navigation-callback injection pattern through CoreProvider, which is
cross-cutting and deserves its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError
Symptom: deleting the current workspace logged "current workspace
deleted, switching" from the realtime handler and surfaced an
"Uncaught (in promise) CancelledError" from TanStack Query's
refetchQueries batch.
Root cause: a three-way race between the mutation's own
invalidateQueries(workspaceKeys.list()), the settings page's
navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime
workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery.
All three refetched the same query concurrently; TanStack Query
cancelled the in-flight loser(s), and the rejection bubbled out of
invalidateQueries as an unhandled promise rejection.
Fix: invert the order. Compute the destination from the current
cached workspace list, navigate immediately, *then* fire the
mutation. By the time the backend fires workspace:deleted, the
active workspace is already something else — the realtime handler's
"current === deleted" check fails and its relocate branch no-ops.
Only one refetch happens (the mutation's onSettled), no race, no
cancellation.
navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery
since it reads from cache and returns before the mutation fires.
Applies to both Leave and Delete flows. Both web and desktop benefit
since the code is in packages/views.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): clear workspace singleton + flex drag strip + defer seeding
Three issues that the last round of delete-workspace fixes missed.
**1. `setCurrentWorkspace` singleton leaks after delete.** Navigating
before the mutation (prior fix) changed the URL but nothing cleared
the core platform's currentSlug/currentWsId singleton. Three
downstream consumers still believed the deleted workspace was active:
- `useRealtimeSync`'s `workspace:deleted` handler: its
`getCurrentWsId() === deleted` check fired, triggering a parallel
relocate that raced the mutation's invalidate and the settings
page's navigate — CancelledError + `window.location.assign`
(white screen reload).
- Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the
sidebar mounted, and `useWorkspaceId` inside it threw because the
workspace was gone from the list cache.
- API client's `X-Workspace-Slug` header: stale on the next call.
Fix: `navigateAwayFromCurrentWorkspace` now calls
`setCurrentWorkspace(null, null)` before pushing. The next
workspace's `WorkspaceRouteLayout` re-sets the singleton when it
mounts; for the last-workspace case, null is the correct state
(overlay has no workspace context).
Same family as the previous logout bug: persist only writes to
storage, reset on logout must also wipe in-memory state. Here the
singleton is another in-memory bit that survives a URL change if
we don't explicitly clear it.
**2. "Cannot update a component while rendering" warning.** The
per-workspace-tabs refactor kept the validate+seed call in render
phase (matching the pre-refactor pattern). It worked before because
`validateWorkspaceSlugs` is idempotent; the new `switchWorkspace`
seed is not, and triggers a TabBar re-render during AppContent's
render. Moved to `useLayoutEffect` — synchronously after render,
before paint, no flicker.
**3. Welcome-screen drag region didn't work on desktop.** The
absolute-positioned `h-10 z-10` drag strip relied on z-index stacking
to beat the content wrapper's no-drag for hit-testing, which wasn't
reliable for `-webkit-app-region` on the overlay. Replaced with a
flex child (`h-12 shrink-0` at top of the overlay's flex-col), so
the drag region owns its own layout space — any pixel in the top 48
is unambiguously drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split
Codifies the lessons from the recent desktop refactor series
(#1237, #1238, #1239) so future work doesn't re-derive them from
bugs. Covers:
- **Route categories** (session / transition / error) — explains why
`/workspaces/new` and `/invite/:id` are overlay state, not routes,
on desktop; stale slugs auto-heal instead of rendering error pages.
- **`setCurrentWorkspace` singleton hygiene** — unmount doesn't
clear it; any code leaving workspace context must call
`setCurrentWorkspace(null, null)` explicitly.
- **Workspace destructive operations ordering** — navigate first,
mutate after, to avoid the three-way refetch race that surfaces
as CancelledError + full-page reload.
- **Tab isolation** — tabs are grouped per workspace; cross-workspace
push is intercepted by the navigation adapter and translated into
switchWorkspace.
- **Drag region pattern** — flex child at top, not absolute overlay;
`-webkit-app-region` hit-testing is unreliable with z-index stacking.
- **UX vs platform chrome split** — UX affordances (Back, Log out,
welcome copy) in packages/views/; platform chrome (drag, immersive
mode, tab system) in desktop-only code.
Also patches the Cross-Platform Development Rules' rule #2 which
previously said "add a route in both apps" unconditionally — added
the exception for pre-workspace transition flows pointing at the
new Desktop-specific Rules section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): prevent duplicate image attachments showing as file cards
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(editor): rewrite bubble menu with @floating-ui/dom for reliable scroll hiding
Replace Tiptap's native <BubbleMenu> with custom @floating-ui/dom positioning.
The native plugin's virtual element lacked contextElement, so the hide middleware
could only detect viewport clipping — not nested scroll container clipping
(e.g. comment/reply inputs inside a scrollable page).
Key changes:
- contextElement on virtual reference enables hide middleware to detect ALL
overflow ancestor clipping, not just viewport bounds
- visibility:hidden (not display:none) keeps element measurable for
computePosition, fixing the comma-selection positioning bug
- autoUpdate monitors all scroll ancestors automatically via contextElement
- Remove: getScrollParent, scrollHiddenRef, manual scroll listener, scrollTarget
- Remove @tiptap/extension-bubble-menu dependency (no longer used)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(workspace): typed delete confirm + sole-owner leave preflight
Harden the Danger Zone on the Workspace settings tab.
**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.
**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
- sole member: "Delete the workspace to leave."
- sole owner with other members: "Promote another member to owner
first, or delete the workspace."
Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: gate Danger Zone on members fetched + reset typed input on rename
Addresses self-review findings on #1238:
- Previously the Danger Zone rendered immediately with `members = []`, so
the Delete workspace block (gated on `isOwner`, which is derived from
an empty members list) would flash in once the query settled. Gate the
whole section on `membersFetched` so it appears once with correct
controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
the workspace while the dialog is open, the already-typed string stops
matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
mid-dialog clears the input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.
Fix by recognising the underlying category mistake: on desktop, these
flows are application state, not routes. Move them to a window-level
overlay driven by a small Zustand store; the navigation adapter
intercepts pushes to the corresponding paths and routes them to the
overlay instead. Web keeps the routes (users need shareable URLs and
back-button semantics), so shared view components are reused as-is.
UX affordances (Back button when dismissable, Log out escape) live in
the shared NewWorkspacePage/InvitePage so both platforms render
identical content; the desktop overlay is now a thin platform shell
(drag strip + useImmersiveMode) that wraps the shared UX. Web wires
onBack based on whether the user has any workspaces.
Also addresses several related issues uncovered along the way:
- Logout now resets the in-memory tab + overlay stores (previously only
localStorage was cleared, so the next login inherited the prior
user's tabs).
- WorkspaceRouteLayout auto-heals a stale workspace slug by navigating
to "/" instead of rendering NoAccessPage — on desktop without a URL
bar, "no access" is always stale state, not a legitimate destination.
- IndexRedirect overlay lifecycle is bidirectional: opens when wsList
is empty, closes when it becomes non-empty (realtime workspace:added
would otherwise leave the overlay stuck open).
- tryRouteToOverlay resets the current tab to "/" when opening the
new-workspace overlay; otherwise workspace-scoped components under
the overlay continue to render and throw when the workspace they
reference disappears from the cache (reproducible by deleting the
last workspace from Settings).
- handleDeepLink now accepts multica://invite/<id>, IPC'd through to
the renderer and opened as an invite overlay. Email template still
links to https:// (unchanged), but the desktop dispatch path is now
wired for a future "open in desktop app" bridge.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.
Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority
- Drop GitHub button from hero CTAs (already in header) so the primary
Start / Download Desktop pair is the clear path.
- Split InstallCommand: outer is no longer a <button>, so text selection
no longer fights with copy. Mobile gets full-width with break-all;
desktop keeps the compact pill. Copy button has aria-label.
- Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so
the install pill's hover background actually renders.
- Add `flex-wrap` and gap-y to the "Works with" row so the label + 5
logos can stack on small screens instead of overflowing horizontally.
- Move `priority` from the decorative backdrop image onto the product
hero image (the actual LCP candidate) to stop background bytes from
starving the foreground.
* refactor(landing): remove install command from hero
Per design feedback, the install command pill is removed from the hero.
The download path now flows through the Download Desktop CTA only;
install instructions remain available in the docs and README.
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
* feat(search): add light/dark/system theme toggle actions to cmd+k
The command palette now surfaces an "Actions" section with theme toggle
items (Light / Dark / System), searchable via keywords like "theme",
"light", "dark", "appearance", or "mode". The active theme is marked
with a check icon.
* feat(search): add quick-win commands to cmd+k palette
Extends the command palette with a "Commands" group that consolidates
theme toggles plus four new actions:
- New Issue / New Project — trigger the global create modals
- Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current
route is an issue detail page; mirrors the copy-link dropdown logic
from issue-detail
Adds a "Switch Workspace" group that lists the user's other workspaces
(filtered by name/slug, or by typing "workspace"/"switch") and
navigates to the selected workspace's issues page.
To make "New Project" work from anywhere, the inline CreateProjectDialog
on ProjectsPage is extracted into a global CreateProjectModal mounted
via the existing ModalRegistry + modal store (same pattern as
create-issue / create-workspace). The modal store type gains a
"create-project" variant.
* feat(search): show Commands by default so they're discoverable
Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID /
theme toggles) only appeared when the user typed a matching keyword,
leaving them invisible unless the user already knew they existed.
Now the Commands group renders as soon as the palette opens (no query),
with the whole command list shown; typing narrows it down as before.
Also trims the redundant "⌘K to open this anytime" hint from the empty
state — the palette is already open.
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.
DESKTOP_APP_SUFFIX appends to the app name + userData path so each
worktree can claim its own lock:
DESKTOP_APP_SUFFIX=foo → "Multica Canary foo"
Default (no env var) keeps behavior unchanged.
Complements the existing DESKTOP_RENDERER_PORT env from #1210 so a full
"run a second dev Electron" setup looks like:
DESKTOP_RENDERER_PORT=15173 DESKTOP_APP_SUFFIX=foo pnpm dev:desktop
Hooks recordVisit into useCreateIssue onSuccess so issues the user just
created appear in cmd+k's Recent section without requiring them to open
the issue first.
2026-04-17 01:45:19 +08:00
1344 changed files with 178974 additions and 24879 deletions
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
### CI Requirements
@@ -130,10 +146,38 @@ make start-worktree # Start using .env.worktree
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims**for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
### API Response Compatibility
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
When writing code that consumes an API response, follow these rules:
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
### Backend Handler UUID Parsing Convention
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### Package Boundary Rules
@@ -162,7 +206,7 @@ When the two apps need different behavior for the same concept (e.g., different
When adding a new page or feature:
1.**New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2.**Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
2.**Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.**Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3.**Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4.**Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5.**Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
@@ -176,6 +220,43 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.**`WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2.`setCurrentWorkspace(null, null)`.
3.`navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status
@@ -140,12 +140,15 @@ The daemon auto-detects these AI CLIs on your PATH:
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
Agent-specific overrides:
@@ -178,8 +198,12 @@ Agent-specific overrides:
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -192,6 +216,12 @@ Agent-specific overrides:
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
### Self-Hosted Server
@@ -275,10 +305,12 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --full-id
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -291,9 +323,10 @@ multica issue get <id> --output json
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
@@ -385,6 +508,66 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --full-id
multica autopilot list --status active --output json
```
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage"\
--description "Scan todo issues and prioritize."\
--agent "Lambda"\
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
@@ -165,12 +166,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`,`copilot`,`opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`,`copilot`,`opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
---
@@ -184,12 +185,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`,`copilot`,`opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL:=help
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
##@ Help
help:## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
@@ -30,17 +30,32 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and**Cursor Agent**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**,**GitHub Copilot CLI**,**OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
@@ -85,7 +100,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
---
@@ -97,7 +113,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`,`copilot`,`openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
### 2. Verify your runtime
@@ -107,7 +123,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -115,21 +131,6 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
@@ -169,7 +170,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development
@@ -184,13 +185,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -54,6 +54,10 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -63,9 +67,15 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
@@ -14,6 +14,15 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
@@ -23,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
### Google OAuth (Optional)
@@ -33,24 +42,46 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
For file uploads and attachments, configure S3 and CloudFront:
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and (optionally) CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -153,16 +186,47 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
**Separate-domain layout** — frontend and backend on different hostnames:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx
@@ -218,7 +282,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
### WebSocket for LAN / Non-localhost Access
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1.**Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2.**Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.