Commit Graph

621 Commits

Author SHA1 Message Date
Bohan Jiang
58547faf31 fix(server): validate assignee_id existence on issue create/update (#1694)
* 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.
2026-04-26 10:35:47 +08:00
Magnus Handeland
9b55b2a9ce feat(cli): add --custom-env flag to agent create/update (#1518)
* 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>
2026-04-26 10:32:55 +08:00
Bohan Jiang
95912243bb test(daemon): cover cancelled classification in executeAndDrain (#1692)
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.
2026-04-26 09:27:13 +08:00
Kagura
24e135541b fix(server): use resolved issue ID in DeleteIssue handler (#1680)
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
2026-04-26 09:24:19 +08:00
Alex Fishlock
2df969cffc fix(daemon): report cancelled tasks as "cancelled", not "timeout" (#1686)
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.
2026-04-26 09:23:32 +08:00
lmorgan-yozu
5eab1dbbe1 fix: handle relative attachment download URLs
Resolve server-relative attachment download URLs against the CLI server base URL while preserving signed absolute URL behavior.
2026-04-25 02:13:18 +08:00
Bohan Jiang
a89064d693 docs: clean up leftover .pi/agent/skills references (#1645)
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.
2026-04-25 02:08:33 +08:00
etern
68a312c297 fix(runtimes): fix pi skills dir to: .pi/skills (#1632)
change .pi/agent/skills to .pi/skills

Pi loads skills from:

Global:
  ~/.pi/agent/skills/
  ~/.agents/skills/
Project:
  .pi/skills/
  .agents/skills/

- ref: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md#locations
2026-04-25 02:06:25 +08:00
Bohan Jiang
683ff132ca fix(server/heartbeat): probe/claim split + slow-log + model-list running timeout (#1644)
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.
2026-04-25 02:06:00 +08:00
Truffle
93fe324bb9 fix(skills): fast-path root-level SKILL.md with frontmatter guard (#1625)
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.
2026-04-25 01:40:23 +08:00
Bohan Jiang
74593fdb88 fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521) (#1643)
* 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.
2026-04-25 01:40:15 +08:00
Bohan Jiang
60fdc82824 fix(cli): resolve assignee by exact name or ShortID to avoid substring collisions (#1642)
`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
2026-04-25 01:05:29 +08:00
Joey
d17b2bfb8c feat(cli): 添加更新下载超时配置选项 (#1622)
- 在 update 命令中添加 --download-timeout 标志用于设置下载超时时间
- 实现 UpdateViaDownloadWithTimeout 函数支持自定义下载超时
- 添加 updateDownloadTimeoutOrDefault 辅助函数处理超时值验证
- 设置默认下载超时时间为 120 秒
- 添加 updateDownloadTimeoutOrDefault 函数的单元测试
- 验证超时参数必须大于零的错误处理逻辑
2026-04-24 17:05:23 +08:00
devv-eve
13d9d7df1b fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync.
2026-04-24 16:36:04 +08:00
LinYushen
9e1e3981fb fix(workspace): defense-in-depth owner check in DeleteWorkspace handler
Adds an owner check inside DeleteWorkspace as defense-in-depth and covers both router-level and direct handler paths.
2026-04-24 14:29:39 +08:00
LinYushen
99154d97b9 Restrict /health/realtime metrics exposure (MUL-1342) (#1608)
* 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>
2026-04-24 14:04:10 +08:00
devv-eve
9ed1fa95fc feat(server): add readiness health endpoints (#1605)
* feat(server): add readiness health endpoints

* fix(server): cache readiness checks

* fix(server): raise readiness cache ttl

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-24 13:50:24 +08:00
L.Amar
9c177562e2 fix(daemon/repocache): make bare repo cache keys collision-resistant 2026-04-24 13:04:08 +08:00
Naiyuan Qing
40cea8454d feat(autopilot): redesign modal — simpler schema, consistent schedule UI (#1595)
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>
2026-04-24 11:05:33 +08:00
Bohan Jiang
e0e91fc792 feat(daemon): harden agent mention-loop instructions (#1581)
* 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.
2026-04-24 01:39:12 +08:00
Bohan Jiang
8f10741a4d feat(daemon/gc): tighten GC defaults + flex duration suffix (#1559)
* 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.
2026-04-23 17:40:09 +08:00
Bohan Jiang
cbe0cbef56 fix(daemon): retry local-skill reports on transient server errors (#1561)
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.
2026-04-23 17:39:20 +08:00
affe (Yufei Zhang)
5ef957ca1b fix(skills): resolve aliased skills.sh imports (#1432)
* fix(skills): resolve aliased skills.sh imports

* fix(skills): harden alias fallback scan
2026-04-23 17:33:30 +08:00
Kagura
6d9ca9de93 fix(daemon): suppress agent terminal windows on Windows (#1474)
* 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>
2026-04-23 17:23:00 +08:00
Bohan Jiang
ad803b86ec fix(skills): shared-state runtime local-skill stores (MUL-1288) (#1557)
* 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).
2026-04-23 17:07:34 +08:00
Bohan Jiang
b51d1c4dc3 fix(cli): make browser-login work from a machine that isn't the server (#1556)
* 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.
2026-04-23 16:41:29 +08:00
Bohan Jiang
6fd1255873 feat(runtimes): remove Test Connection / runtime ping feature (#1554)
* 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
2026-04-23 16:18:21 +08:00
LinYushen
91424752ac feat(realtime): phase 0 — extract Broadcaster interface + add metrics (MUL-1138) (#1429)
* 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>
2026-04-23 13:36:55 +08:00
LinYushen
d97aec83d7 fix: pass model to Hermes ACP and add hermes to InjectRuntimeConfig (#1203)
* 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>
2026-04-23 12:43:30 +08:00
Naiyuan Qing
d6e7824ff1 feat(feedback): in-app feedback flow + Help launcher (#1546)
* 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>
2026-04-23 10:35:55 +08:00
Bohan Jiang
7375bda9b5 fix(landing): scope landing route to always-light palette (MUL-1277) (#1537)
* 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.
2026-04-23 01:52:46 +08:00
Bohan Jiang
9dcc082920 docs(handler): note that GetConfig is public-only and what may be returned (#1538)
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.
2026-04-23 01:51:59 +08:00
Black
98edc6b9ff fix(auth): make /api/config publicly accessible (#1530) 2026-04-23 01:49:21 +08:00
Bohan Jiang
6717db1fad feat(agents): surface task source on AgentTaskResponse + use it in Tasks tab (#1455)
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.
2026-04-22 19:26:57 +08:00
Dhruv-89
2a248b8548 fix(openclaw): raise agent discovery timeout to 30s (#1495)
'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.
2026-04-22 19:24:57 +08:00
Bohan Jiang
dc8096fb6e fix(agent): expose Gemini 3 + CLI aliases in Gemini runtime model list (#1508)
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.

Fixes multica-ai/multica#1503.
2026-04-22 19:02:07 +08:00
devv-eve
fbf41bde73 feat(selfhost): ship public GHCR deployment flow
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.
2026-04-22 16:58:42 +08:00
Bohan Jiang
936df59fa1 feat(analytics): instrument onboarding funnel (MUL-1250) (#1489)
* 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
2026-04-22 16:28:08 +08:00
Naiyuan Qing
c787546ede refactor(pin): drop server-side enrichment, derive sidebar fields client-side (#1484)
`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>
2026-04-22 15:08:16 +08:00
Bohan Jiang
14a9b5293e feat(slugs): reserve homepage + expand reserved slug list (MUL-961) (#1483)
* 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.
2026-04-22 15:08:06 +08:00
Bohan Jiang
b8b38381bb feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189) (#1481)
* 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.
2026-04-22 14:47:42 +08:00
Naiyuan Qing
3036c6418e fix(onboarding): pin sync, welcome layout, runtime bootstrap state (#1482)
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>
2026-04-22 14:47:00 +08:00
Bohan Jiang
aa9932e4e1 fix(skills): unify Add Skill UX + surface every local skill with real file count (#1480)
* 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.
2026-04-22 14:38:27 +08:00
LinYushen
b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* 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>
2026-04-22 13:36:13 +08:00
gezilinll
f247a4f544 feat(skills): import runtime local skills into workspace (#1431)
* feat(skills): import runtime local skills into workspace

* fix(skills): address runtime local skill review feedback

* docs(skills): annotate local provider skill paths

---------

Co-authored-by: zhangliang <zhangliang@gaoding.com>
2026-04-22 13:16:51 +08:00
LinYushen
0b1333fb00 feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128) (#1476)
* 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>
2026-04-22 13:08:37 +08:00
Naiyuan Qing
3fd2fb2ae3 feat(onboarding): redesigned flow + post-landing starter content opt-in (#1411)
* 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>
2026-04-21 20:32:33 +08:00
Kagura
1a565a221a fix(server): handle race in CompleteTask and FailTask for parallel agents 2026-04-21 19:23:31 +08:00
Bohan Jiang
7ada72faa6 fix(server/task): synthesize result comment for comment-triggered tasks too (#1440)
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
2026-04-21 16:09:59 +08:00
Naiyuan Qing
ba003eee83 fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387) (#1436)
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"   -> "&gt; quote"     (blockquote lost, see #1303)
  - '"foo"'     -> '&#34;foo&#34;'    (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>
2026-04-21 15:40:30 +08:00