67 Commits
Author SHA1 Message Date
sol713 0f3993e417 fix(cli): classify interrupted upload responses as network errors (#9181) 2026-10-13 16:10:23 +08:00
53b1baba0a feat(cli): add opt-in daemon autostart (MUL-7731) (#8864)
* fix(test): isolate cmd/multica suite home resolution on Windows

os.UserHomeDir reads USERPROFILE, not HOME, so every test that redirected
only HOME kept resolving ~/.multica against the real home: mkProfiles
stopped isolating at all, and a full-suite run wrote SaveCLIConfig
fixtures over a real default-profile config.json.

Redirect HOME and USERPROFILE process-wide in TestMain, and make mkProfiles
redirect both so per-test isolation holds on Windows too. On unix nothing
changes: USERPROFILE is unused there.

Co-authored-by: multica-agent <[email protected]>

* feat(cli): register daemon boot autostart on every platform

multica daemon start now registers the profile's daemon to come back at
login/boot so a reboot no longer takes the runtime offline: an HKCU Run
key on Windows, a launchd LaunchAgent on macOS, and a systemd user unit
(or XDG autostart fallback without systemd) on Linux. New
'daemon autostart enable|disable|status' manages the registration;
--no-autostart opts a single start out, and daemons spawned by a manager
(MULTICA_LAUNCHED_BY, i.e. the Desktop app's own start-at-login pref) are
never registered.

The registered command runs 'daemon start --foreground' per profile and
snapshots the shell's PATH so login sessions keep finding agent CLIs.
EnsureHiddenConsole now hides a console that exists only for the daemon
itself — a Run-key launch would otherwise park a visible console window
for the daemon's whole lifetime.

Co-authored-by: multica-agent <[email protected]>

* refactor(cli): make boot autostart registration opt-in per review

Registration now happens only through 'multica daemon autostart enable':
daemon start (and setup / restart / --foreground) merely print the enable
hint when nothing is registered and silently refresh an entry Multica
owns — marked by a comment line in a unit/.desktop file or a ManagedBy
key in a plist (the Run value name doubles as the marker on Windows).
Refresh skips anything without the marker and skips entirely under an
external systemd unit (INVOCATION_ID with a cgroup that is not ours), so
a hand-written unit like the one in #7091 is never overwritten.
--no-autostart is gone (there is nothing to opt out of), loginctl
enable-linger is no longer run automatically (enable prints the command
instead), and 'autostart enable' now spells out that only PATH carries
into the login session while shell-exported variables do not.

Also fixes the binary-update restart handoff under our own systemd unit:
spawning a successor and exiting 0 lets systemd's cgroup cleanup kill it
while Restart=on-failure never fires, leaving the runtime offline after
the first auto-update. The daemon now exits with
daemonSystemdHandoffExitStatus (42) instead; the generated unit carries
RestartForceExitStatus for it, and linux tests cover the unit-ownership
detection behind both the handoff and the refresh gate.

Review items also addressed: gofmt applied to cmd_daemon_test.go and
proc_windows.go, the Desktop wording corrected to its app-start daemon
preference (it has no OS login item), and CLI_AND_DAEMON.md plus the
daemon start / autostart help text follow the opt-in flow.

Co-authored-by: multica-agent <[email protected]>

* fix(test): redirect USERPROFILE alongside HOME in cmd/multica tests

Every t.Setenv("HOME", ...) in this package now goes through
redirectTestHome, which sets both home environment variables. Production
resolves ~/.multica via os.UserHomeDir — USERPROFILE on Windows — so a
HOME-only redirect split the fixture's write path from the code's read
path: on Windows the fixture landed where the code never looked (or,
before TestMain's scratch home, in the real ~/.multica). CI on ubuntu is
unaffected; the Windows host suite stops depending on whatever happens
to be in the real profile directory.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): record autostart with the stable brew path, shared with the restart target

The exit-42 systemd handoff restarts whatever ExecStart recorded, and
autostartSpecFor recorded os.Executable() verbatim — on brew installs
(Homebrew on Linux too, reached through install.sh) that is the versioned
keg path, which `brew upgrade` deletes when it cleans the old keg:
systemd then fails ExecStart with 203/EXEC and gives up after
StartLimitBurst, leaving the runtime offline until someone starts it by
hand; a LaunchAgent would point at a missing file at the next login.

Fix per review: both consumers now resolve through one shared helper,
cli.StableExecutablePath, with identical detection order — the path's own
known-Cellar shape first, then `brew --prefix` — so the two cannot drift.
The daemon's restartTargetBinary keeps its seam-based per-process cache
but delegates the mapping; autostartSpecFor runs
cli.StableSelfExecutable before recording. cli.IsBrewInstall shares the
same detection core.

Tests pin the keg -> <prefix>/bin/multica mapping at the helper level for
all three known prefixes, and end to end through autostartSpecFor feeding
a brew keg path, as requested in the review.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): key autostart ownership on file presence, not enablement

Review round 3: on Linux Enabled only means "linked in
default.target.wants", so the enable/disable guard (Enabled && !Managed)
missed a hand-written unit at our path that was unlinked — or linked
under another target such as graphical-session.target — and disable
deleted it; separately, the XDG sweeper deleted an unmarked
multica-daemon.desktop outright from enable, disable, and the silent
daemon start refresh.

- autostartState gains Present ("a file/value exists at our path");
  refuseUnmanagedAutostart and the status note key off Present, while the
  enabled/disabled verdict still keys off Enabled.
- platformRemoveAutostart re-checks the marker on the unit BEFORE
  systemctl disable (refusing must not unlink the user's unit as a side
  effect) and refuses to delete an unmarked file; removeXdgAutostartFile
  removes only marked files, which covers all three of its call sites
  (enable's stale sweep, the daemon start refresh, disable).
- sync no longer hints enable at a foreign unlinked file, and never
  heals an owned-but-unlinked entry — refresh must not re-enable what the
  user manually disabled — it hints instead.
- linux tests cover unlinked / other-target-linked / default-linked
  unmarked units on the read side, the removal backstop, a managed unit
  coexisting with an unmarked .desktop, and the mark-checked XDG
  sweeper; shared guard/status/sync tests move to the presence contract.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): keep the running service's restart policy when disabling autostart

Review round 4 (P2): on Linux, `autostart disable` deleted the managed
unit file after unlinking it. The running daemon kept going, but systemd
lost the unit — LoadState=not-found, Restart=no — so the next
binary-update handoff (exit 42) exited into a service that could no
longer restart it and the runtime stayed offline until a manual start.

Disable now removes the enablement only: unlink every wants entry
(systemctl --user disable, with a glob fallback for a missing user bus)
and KEEP the marked unit file, so the supervised process retains its
restart policy while nothing starts at the next login. Re-enabling
re-links the same file. A second disable is a no-op again (change is
keyed off links actually present, in any target). The macOS equivalent
is fixed in the same spirit: the plist is deleted WITHOUT launchctl
bootout, which used to terminate the daemon launchd was supervising —
disable means "don't start at the next login", not "kill this session".

enable/disable now read the state once and share it with the ownership
guard; enable's wording keys off the pre-state, because re-linking a
kept unit often changes no file content and "already enabled" would
describe the state the user just left. The daemon-start hint now fires
only when nothing is registered at all — never at a disabled-or-foreign
file the user chose or owns — and the refresh still never re-links.

Regression coverage for running service → disable → handoff:
TestDisableKeepsRunningServiceRecoverable asserts the unit file survives
with RestartForceExitStatus intact, no wants link remains, the handoff
still detects our own unit (exit-42 path), a subsequent sync does not
re-enable, and a second disable reports no change. Docs note the
keep-the-file semantics on both Linux and macOS.

Co-authored-by: multica-agent <[email protected]>

* test(cli): update the coexistence test to the keep-the-unit disable contract

CI caught the round-4 test asserting the pre-round-5 behavior: it wrote a
marked unit WITHOUT a wants link and expected platformRemoveAutostart to
delete it. Disable now keeps the marked unit (unlinking its enablement)
so a running service retains its restart policy — so the test links the
unit (giving disable real work, changed=true), asserts the unit survives
unlinked, and keeps its original point: the hand-written .desktop beside
it is never touched.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: ZhangRui <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-10-12 00:17:42 -04:00
c8d6a1fad0 MUL-7923: fix(test): isolate test home on Windows so tests stop overwriting ~/.multica/config.json (#9128)
* fix(test): isolate test home on Windows across config-writing packages

Tests that only redirect HOME still resolve ~/.multica against the real
home on Windows: os.UserHomeDir reads USERPROFILE, not HOME. SaveCLIConfig
then writes test fixtures over a real default-profile config.json.

Reproduced on a Windows host by running `go test ./internal/cli ./cmd/multica`
on an unmodified tree: the real ~/.multica/config.json was replaced with the
test fixture {"token": "mul_owner_pat"}, dropping server_url, app_url and
workspace_id. The running daemon keeps its in-memory config, so the damage
only surfaces on the next restart as an auth failure.

Redirect both home variables process-wide in TestMain for the packages that
resolve ~/.multica (cmd/multica, internal/cli, internal/daemon), and route
per-test home redirects through a redirectTestHome helper that sets HOME and
USERPROFILE together. Per-test t.Setenv overrides still win.

Comparison on the same host, same packages:

  without this change: 6 internal/cli + 10 cmd/multica failures, real config overwritten
  with this change:    1 + 5 failures (all pre-existing Windows cases), config intact

Co-authored-by: multica-agent <[email protected]>

* fix(test): fail closed when isolating test homes

Co-authored-by: multica-agent <[email protected]>

* test(ghsnapshot): isolate cursor fixture from other packages

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: ZhangRui <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Yushen <[email protected]>
2026-10-10 17:03:32 +08:00
Bohan Jiangandmultica-agent 8355c7aeca fix(cli): name the TLS handshake timeout and stop masking it on login (MUL-7572, GH #8654) (#8744)
A Windows user whose network dropped the two-packet TLS ClientHello that
Go 1.24+ sends by default (post-quantum key share, ~1.5 KB) saw only
"Sign-in did not complete: the server could not issue an access token"
and, on the --token path, "make sure it is valid and not expired". Neither
mentioned the network, and the generic timeout copy pointed at
MULTICA_HTTP_TIMEOUT, which does not govern the handshake.

- classify net/http's "TLS handshake timeout" as its own kind, with copy
  that names the GODEBUG=tlsmlkem=0 check in both languages
- let `multica login` keep the transport copy for transport failures; its
  sign-in copy now only overrides HTTP refusals
- troubleshooting entry (en/zh/ja/ko)

Co-authored-by: multica-agent <[email protected]>
2026-09-23 16:45:36 +08:00
Bohan Jiangandmultica-agent 6ee01c33aa fix(cli): reference issue create attachments in the description (#8736)
`multica issue create --attachment` uploaded each file after the issue
existed, stamping it with the new issue_id and leaving the description
untouched. An issue renders only the files its description references, so
the file was stored and reachable through the API while appearing nowhere
on web, desktop or mobile.

Upload the files first, unbound, append each one's markdown to the
description (images inline, other files as a card), and bind every id via
attachment_ids on the create call. Files already referenced by the
description are not appended again, which keeps the quick-create path —
where the user's pasted markdown is already in the body — from rendering
the same file twice.

Uploading first also drops the old partial-success state: a failed upload
now means no issue was created, so the retry cannot duplicate one.

Co-authored-by: multica-agent <[email protected]>
2026-09-23 15:19:26 +08:00
2233adminandCurry 2bd4f17571 MUL-7058 feat(cli): support self-hosted Gitea release sources (#8027)
Co-authored-by: Curry <[email protected]>
2026-09-21 15:04:33 +08:00
Bohan JiangandCatCatUncle 3ac53a68a6 MUL-7108 fix(autopilot): judge every autopilot write as the human it acts for (#8107)
Autopilot write endpoints judged the request's authenticated user, which under
a mat_ task token is the RUNTIME OWNER. Any member could therefore @ an agent
running on someone else's machine and spend that machine owner's autopilot
rights — pause a rule, re-point a webhook, rotate a token, or grant themselves
a collaborator row.

Every write on this surface now resolves the human it acts FOR (a member acts
for themselves, an agent for its run's originator), through one seam, and
stamps that same human into created_by / published_by / granted_by and the
rule versions. The gate and the row can no longer name two different people.

Two bypasses of that gate are closed with it:

- The webhook credential was broadcast to the whole workspace on
  autopilot:updated, handing every member the token GetAutopilot had just
  redacted for them. All five trigger fan-outs now carry a redacted copy; the
  HTTP caller still gets the live value.
- Agent identity itself was forgeable: Auth stripped only X-Actor-Source, so a
  member could replay an observable (agent_id, task_id) pair — both returned by
  GET /api/issues/{id}/task-runs — and be resolved as that agent. Auth and
  DaemonAuth now strip both headers, leaving the mat_ branch as the only writer
  of agent identity.

That last part is MUL-3428, reported and fixed by @CatCatUncle in #4313; it is
carried here because this change cannot be correct on top of a forgeable actor.

Behavior tightening, no migration: the only path it breaks is a member changing
an autopilot they hold no grant on. A run with no originator can no longer
write here.

Co-authored-by: CatCatUncle <[email protected]>
2026-09-07 16:46:07 +08:00
60408ef252 MUL-7090 fix(autopilot): let an agent trigger a manual run for the human it acts for (#8099)
* fix(autopilot): let an agent trigger a manual run for the human it acts for

MUL-6951 replaced canCreatorInvokeAgent, which judged automatic dispatch by the
autopilot CREATOR and needed no trigger, with ResolveAutopilotTriggerPrincipal,
which resolves the FIRING TRIGGER's creator. The manual entry point passes no
trigger by construction, so the resolver returned nothing on its first line and
every manual trigger from a non-member actor was refused. Scheduled runs kept
working — they have a trigger row — which is what made this read as a
configuration problem rather than a regression. Reported as #8078.

The refusal was also self-contradicting: requireAutopilotWrite had already
resolved a real human from the request (for a task token, the runtime owner the
middleware binds) and confirmed they may trigger this autopilot. Three lines
later memberActorUserID discarded that human for not being a "member" actor, and
the empty identity fell into the automation branch.

TriggerAutopilot now resolves the human the caller acts FOR:

  - a member acts for themselves, judged exactly as before;
  - an agent acts for its run's ORIGINATOR, which is how every other invoke
    surface judges an agent actor (MUL-3963 / canInvokeAgent), including this
    file's own private-leader gate on save.

Reading the runtime owner instead would have been a smaller diff and the wrong
grant: it makes every agent a standing proxy for its machine's owner, so anyone
who can talk to the agent inherits that owner's autopilot rights. The originator
must ALSO hold write access, so an agent can only press a "Run now" that the
human ordering it could press themselves.

No resolvable human is refused outright (403). The alternative was the
workspace-broad exception invokeAgentDecision grants unattributed agent/system
principals on a public_to-workspace agent, which would have admitted an
originator-less chain here too. Rejected per Bohan: a manual trigger is by
definition somebody's decision, so a chain arriving with no human at its top
means something upstream dropped it, and that should surface.

Two things this does NOT change, both deliberate:

  - requireAutopilotWrite still judges the request's authenticated user. That it
    resolves the runtime owner for an agent is pre-existing and shared by all
    eight autopilot write endpoints; narrowing it is a separate change with its
    own blast radius, filed separately.
  - The run's attribution stays direct_human to the resolved human rather than
    delegation. attributionForIssueTask treats a valid actor as direct_human by
    contract, shared with assign/promote/rerun, so labelling this one path
    delegation means threading a new parameter through TaskService's enqueue
    chain. The human on the hook is correct either way; only the label is
    coarser. Filed as follow-up rather than half-applied to one execution mode.

Two independent defects the same report exposed:

  - The skip reason blamed "the trigger records no owner" on a path that has no
    trigger, sending #8078's reporter hunting a broken trigger owner for a day.
    It now branches on whether a trigger exists.
  - `multica autopilot trigger` printed "Autopilot triggered" and exited 0 for a
    skipped run, so the operator — and any agent running it for them — read
    success. It now mirrors the web client's runNowToastKind whitelist: only
    issue_created and running are success, everything else prints failure_reason
    and exits non-zero. --output json still writes the run to stdout first.
    This is the half that made the bug silent, and it covers every skip reason,
    not just this one.

The MUL-4525 admission test asserted that (no actor, no trigger) must be refused
and called it "automation dispatch" — but that is exactly the argument pair a
manual trigger produced, so the test pinned the regression and would have
blocked the fix. Rewritten to assert the no-principal phrasing and, explicitly,
that the reason does not blame a trigger row that does not exist.

Verification (real PostgreSQL 17): new handler suite covers agent-with-
originator (dispatches), agent-without-originator (403, no run row), originator
lacking write (403, no run row) and the untouched member path. The acceptance
test was confirmed to fail against the pre-fix handler with exactly the reported
symptom — status skipped, invocation_not_allowed. internal/service and
internal/handler pass; the one internal/handler failure (TestPluginRoutes-
RequireWorkspaceAdmin, a duplicate user email) and the cmd/multica failures
reproduce identically on a clean tree.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): judge a manual trigger by the ordering human alone

Addresses Elon's review of #8099. Two must-fixes, both confirmed against the
code before changing it.

Must-fix 1 — the endpoint still required TWO humans to hold the grant.
requireAutopilotWrite ran in front of the originator check, and under a task
token it judges the RUNTIME OWNER. So the effective rule was "runtime owner AND
originator", not "the human who ordered it" — the opposite failure from the one
#8078 reported, and it broke a case a member is plainly entitled to: U created
the autopilot and may invoke an agent running on O's machine, O holds no grant,
so U could press Run now themselves but could not ask the agent to. It also
contradicted the docs this PR shipped, which already promised originator-based
authorization.

requireAutopilotTriggerInvoker is now this endpoint's only write gate.
requireAutopilotWrite is not called alongside it. Workspace tenancy for the
authenticated caller is unaffected: /api/autopilots sits inside the router's
RequireWorkspaceMember group, so dropping the second check loses no isolation —
it only stops a third party's machine ownership from vetoing the ordering
human's own rights. The other seven autopilot write endpoints are untouched and
stay with MUL-7108.

Authorization also moved ahead of the status check, so an unauthorized caller
cannot tell an active autopilot from a paused one.

The positive test could not have caught this: it let testUserID be runtime
owner, autopilot creator and originator at once. TestTriggerAutopilot_
RuntimeOwnerNeedsNoGrant now separates all three — the ordering human creates
the autopilot, the runtime owner is a plain member with no grant — and was
verified to fail against the previous commit with exactly the 403 Elon
described.

Must-fix 2 — the actionable refusal never reached the person running the
command. The handler named the cause, but FormatError collapses every 403 into
generic "no access" copy, so `calling run records no originator` died in the
response body. That is the same silence #8078 was about, one layer up.

Both refusals now carry a stable code (autopilot_trigger_no_originator,
autopilot_trigger_forbidden) via writeErrorCode, and cli.ServerErrorCode reads
it. The trigger command opts those two out of the generic copy and prints
guidance. The branch is on the code, never on the English sentence: copy edits
and translation must not silently disable it. The opt-out is per-code, so every
403 this command has not named keeps the deliberately vague copy that stops a
refusal confirming a resource exists — pinned by a test.

The two 403s deliberately share one sentence and one code whether the ordering
human is a non-member or merely ungranted, so the endpoint cannot be used to
probe workspace membership.

Verification (real PostgreSQL 17):
- TestTriggerAutopilot_RuntimeOwnerNeedsNoGrant fails against the previous
  commit's handler and passes now; the other four trigger tests still pass.
- Both 403 tests assert the code, not just the status — the CLI's output
  depends on it.
- New cmd/multica test runs the real cli.FormatError over both refusals, since
  the defect being guarded lives in FormatError's collapse, and asserts an
  un-opted-in 403 still gets the generic copy.
- New internal/cli TestServerErrorCode covers missing code, non-JSON, malformed
  JSON, prose in `code`, and a non-HTTP error.
- internal/service passes. Pre-existing unrelated failures reproduce identically
  on a clean tree: TestPluginRoutesRequireWorkspaceAdmin and
  TestCommentSourceContextLifecycle in internal/handler, four TestCLIConfig_*
  in internal/cli.
- gofmt, go vet, git diff --check clean.

Docs corrected at both sites to state the contract explicitly: the machine
owner's grants are not consulted, only workspace membership.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 14:57:37 +08:00
cc09c3c12a MUL-7008: Relax healthy WebSocket claim polling to 3 minutes (#7983)
* MUL-7008: relax websocket claim fallback polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden websocket claim polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden deferred claim scheduling

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 13:01:07 +08:00
54027ba763 MUL-6639: fix(skills): metadata-only skill listings and stall-based timeouts (#7574)
* MUL-6639: feat(skills): list skill files as metadata, not content

`multica skill get` and `multica skill files list` both downloaded every file
body to render output that shows none of them — `skill get`'s table view prints
four columns, and the file listing prints paths and timestamps. Past a few
hundred KB that stopped being waste and became a hard failure: a 599KB skill
could not be fetched at all, and the listing that would have shown which file
was oversized required downloading all of them first, so the diagnostic died
with the thing it was meant to diagnose (GH #7498).

Both endpoints now take `?include=content|metadata`. The metadata shape carries
path, byte size and content hash per file, plus the size and hash of the
SKILL.md body — computed in Postgres, so the bodies never leave the database.
Response size now tracks file count, not content size.

`GET /api/skills/{id}/files` defaults to metadata: it is a list endpoint, the
CLI is its only caller, and `GET /api/skills` already dropped content for this
exact reason (GH #2174). `GET /api/skills/{id}` keeps returning content by
default — installed web and desktop builds call it for the skill editor and
cannot be retrofitted with a query parameter. The CLI asks for metadata on both
and takes `--with-content` when the bodies are actually wanted.

The file listing gains a SIZE column, which is what makes an oversized skill
diagnosable without downloading it.

Co-authored-by: multica-agent <[email protected]>

* MUL-6639: fix(cli): fail skill transfers on stall, not elapsed time

`http.Client.Timeout` is a wall clock on the whole request, body included, so
it punishes exactly the transfer that is working: a 599KB skill arriving
steadily over a slow link was cut off at 30s mid-body, while a genuinely dead
connection was held open for the same 30s (GH #7498). Raising the constant
moves the cliff without removing it — the honest test is not "has this taken
too long?" but "is it still making progress?".

internal/cli/stall.go adds a client that asks the second question: bounded
connect, TLS and response-header phases, and a response body that fails after
15s with no new bytes. The wall clock stays as a loose 10-minute backstop —
demoted, not deleted, because dropping it to 0 would have left a stalled
request upload with no deadline at all.

Failures now say which thing went wrong. A new KindNetworkStalled reports "no
data received for 15s after N bytes" rather than a timeout, because only one of
those is fixed by waiting longer. Body-read errors are classified at all now:
wrapTransport only ever saw errors from http.Client.Do, which returns once the
headers arrive, so the failure #7498 actually reports reached users as a raw
"context deadline exceeded ... while reading body" out of the JSON decoder.

MULTICA_HTTP_STALL_TIMEOUT sets the no-progress budget. An explicitly set
MULTICA_HTTP_TIMEOUT still applies on this path, keeping its plain meaning —
the longest you will wait for a server — instead of silently reverting to a
total-elapsed limit.

Adoption starts with the skill commands, where the failure was reported; the
mechanism itself is not skill-specific. Graduation is one edit: point
NewAPIClient at NewStallAwareHTTPClient, fold StallAwareContext into
APIContext, drop newSkillAPIClient. A permanent second client would leave the
CLI with two timeout personalities, which is worse than either alone.

Co-authored-by: multica-agent <[email protected]>

* MUL-6639: fix(skills): hash raw UTF-8, keep the no-include default at content

Review found two problems with the previous two commits.

**The file hash was not over the file's bytes.** `sha256(content::bytea)` runs
the bytea *input* parser over the text rather than taking its bytes, so it
reads backslash escapes: a file containing `\x41` hashed as the single byte
`A`, disagreeing silently with the Go-side hash of the SKILL.md body.

Worse than the mismatch, and not caught earlier because the first check used
accented text with no backslash in it: `content::bytea` *errors* on a bare
backslash. `C:\Users\...`, a regex `\d+`, a LaTeX snippet — ordinary contents
of a skill's supporting files — fail with "invalid input syntax for type
bytea", making the listing 500 on the skills most likely to need it.
`convert_to(content, 'UTF8')` takes the bytes, and matches Go exactly.

**`/api/skills/{id}/files` must not change what a bare request returns.** The
previous commit flipped its default to metadata on the grounds that the CLI is
its only caller. That was the wrong reading of "only caller": the CLI is
installed software too, and an older `skill files list --output json` reads
`content`. It is the same argument that kept the detail endpoint's default —
applied inconsistently to a client that upgrades separately from the server.

Both endpoints now return content when `?include=` is absent. The shrink
travels with the caller instead: the CLI sends `include=metadata` itself, so
GH #7498 is fixed with no synchronized release, and the default can flip once
clients that send `include=content` have aged in. The default is now uniform,
so resolveSkillInclude no longer takes a per-endpoint parameter.

Tests: TestSkillFileHashCoversRawUTF8Bytes covers `\x41`, invalid hex, bare
backslashes, Unicode and empty bodies against the Go hash — verified to fail on
the previous SQL. TestSkillEndpointsWithoutIncludeStillReturnContent pins the
compatibility guarantee on both endpoints.

Not addressed, and noted as follow-up: the metadata path still loads the full
SKILL.md into Go, because loadSkillForUser needs the row for its tenant check.
The HTTP response no longer grows with content; the database read still does.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 15:45:06 +08:00
617d416029 MUL-6658 fix(cli): tell a rejected task token to stop, not to sign in again (#7522) (#7532)
* fix(cli): tell a revoked task token the task is over, not to sign in again

The CLI's generic 401 copy reads "Your session has expired or you are
not signed in. Run `multica login` to sign in again. On a self-hosted or
non-OAuth setup, ask your administrator for valid credentials."

That is correct advice for a person and actively harmful for an agent. A
task's mat_ token is revoked the moment the task reaches a terminal
state, so every call an orphaned agent makes after cancellation gets that
message — and it reads as an instruction to go find a working credential.
In GH #7522 one did exactly that: it read the daemon owner's profile PAT
out of ~/.multica and kept working under the member's identity.

A 401 on a task-scoped token now says the token was revoked because the
task ended, and not to retry with another credential. The status code
alone cannot distinguish the two cases, so HTTPError records which kind
of credential the request carried; nothing else about the client is read,
and exit classification is unchanged (still ExitAuth).

This is defence in depth, not the fix for #7522 — cancellation not
terminating the process tree is (PR #7531). It removes the motive.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): base the task-token claim on the request, not the client

Review found three problems with the first pass.

TaskScoped read the client's own Token field, which answers "what does
this client hold", not "what did the failing request send". DownloadFile
deliberately sends no Authorization header for an absolute signed URL,
so a 401 from object storage would have been reported to an agent as a
finished task purely because the client happened to hold a mat_ token.
It now reads resp.Request, which is the request that actually went out —
also correct across a redirect that strips the header, which the
caller's own request object would not be. newHTTPError goes back to a
plain function. A test drives a real client through both paths instead
of hand-building the error, and fails if the claim moves back to client
state.

The copy asserted a cause the CLI cannot verify: 401 also covers a
malformed token, one sent to the wrong server, and one dropped by an
unrelated cleanup. It now says the token was rejected and to stop,
without naming a reason. "Stop, and do not fall back to a profile or
member credential" is true in every one of those cases; "the task
finished" is not. The kind is renamed to KindTaskTokenRejected to match.

The Chinese copy used 任务 for an agent execution. Per the glossary,
任务 is the product entity a user files (an issue) and an agent execution
stays lowercase `task`; the old wording said the user's issue was
rejected. A test now fails if 任务 reappears in this message.

Co-authored-by: multica-agent <[email protected]>

* docs(cli): stop asserting the cause in the comments too

The runtime copy no longer claims why a task token was rejected, but
three comments still did: TaskTokenPrefix called a 401 "a normal
end-of-task signal", TaskScoped described the token as "finished", and
the wiring test quoted the old wording. A reader following the comments
would have reintroduced exactly the claim this PR removed.

They now say what the CLI can actually see — a task token was rejected,
there is no sign-in that brings it back, and the reason is not visible
from here.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 16:27:55 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00
iiwish 698013cedb MUL-5771 feat(daemon): allow configuring workspaces root (#6830)
* feat(daemon): allow configuring workspaces root

* fix(daemon): align disk usage workspace roots
2026-08-14 01:58:29 +08:00
31645cc510 [MUL-6125] Ship Private Skill Plugin developer loop (#6900)
* [MUL-6125] Ship private Skill Plugin developer loop

Co-authored-by: multica-agent <[email protected]>

* [MUL-6125] Address Private Plugin review findings

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:49:43 +08:00
rongchenzheng 586a624aa5 fix(cli): isolate task profile state (#6407) 2026-08-10 12:20:17 +08:00
beast 31cd51ae2f feat(daemon): converge on out-of-band multica and agent CLI version changes (MUL-3269)
A daemon whose `multica` binary or agent CLI was replaced out of band kept
running the old version until someone restarted it by hand, and most people
never knew they had to.

Two separate behaviors, deliberately not one:

- The `multica` binary being replaced (brew upgrade, a manual download, a
  downgrade) is followed by a restart, once the daemon is idle. A running task
  is never interrupted; a busy daemon defers to the next check and reports the
  reason through `daemon status` / `reload_pending_reason`. This is independent
  of the GitHub auto-update poller and has its own switch
  (`--no-auto-reload` / `MULTICA_DAEMON_AUTO_RELOAD` / `disable_auto_reload`),
  because "don't pull new versions" and "follow the binary I replaced myself"
  are different intents. Desktop-managed daemons stay excluded.
- An agent CLI upgrading in place is a hot refresh: re-probe, refresh the
  cached version and the server-side registration, and let subsequent tasks run
  under the new version's policy. Multica's availability does not track a third
  party's release cadence.

Failure semantics are explicit. An unreadable, blank, or unparseable version is
"no evidence", not a version change: the runtime and last trusted version are
kept and the next round retries. A version confirmed below the minimum takes
that provider's runtimes offline once the daemon is idle, and recovers
automatically on upgrade. A late register response, a newly synced workspace,
or an older cleanup request can neither revive a runtime already judged too old
nor knock out one that has legitimately recovered.

No migrations, no server endpoints, no frontend changes.
2026-08-05 19:54:22 +08:00
d38da27ed4 MUL-5619: fix(cli): surface the server's 409 message instead of the generic conflict template (#6267)
* fix(cli): show the server's conflict message instead of the generic 409 template

Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.

Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.

extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".

MUL-5619

Co-authored-by: multica-agent <[email protected]>

* fix(comments): stop telling a wrong --parent that it posted a top-level comment

The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.

Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.

MUL-5619

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): return 500, not a 409 echo, when the update store fails

InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".

Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.

Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.

MUL-5619

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 13:35:04 +08:00
2187b58292 feat(attachments): let callers opt out of pre-signed URLs in bulk responses (MUL-5372) (#6119)
* feat(attachments): let callers opt out of pre-signed URLs in bulk responses

A CloudFront-signed download_url is ~800 chars, ~630 of which are a
Policy+Signature pair re-minted on every request (the policy embeds
now+TTL at second granularity). Emitting one per attachment on every
list response costs three ways: raw payload, a fresh RSA sign per
attachment per request, and — because the bytes differ on every read —
it defeats any cache keyed on response content.

Agents pay all three and use none of it. `multica attachment download
<id>` fetches a fresh signature from the single-attachment endpoint, so
the id is the only part the CLI needs; measured on a 15-attachment
issue, the URL fields were 28.3% of the payload and ~10k chars of that
rotated on every read.

Callers now advertise `stable_attachment_urls` in X-Client-Capabilities
to receive the stable /api/attachments/{id}/download path instead. That
endpoint re-signs and 302s on every hit, so the value stays correct
forever at ~95 chars. The CLI advertises it unconditionally — it is a
protocol detail, not a user-visible flag.

The single-attachment endpoint keeps signing regardless of the
capability: it is the one source of fresh, natively-loadable URLs, and
stable-mode callers exchange a stable path for a signature there. That
carve-out is what makes the rest safe.

The server default never moves. A caller that advertises nothing — every
installed mobile build, every third-party script — gets byte-identical
responses, so this ships ahead of any client and each surface migrates
on its own release cadence. Web/desktop (Phase 2) and mobile (Phase 3)
are deliberately not touched here.

MUL-5372

Co-authored-by: multica-agent <[email protected]>

* test(attachments): pin the CLI capability header and the rotation premise

Two review nits from #6119.

The CLI's setHeaders call is the only place Phase 1 is switched on, and
nothing observed it: the server tests prove a request carrying
`X-Client-Capabilities: stable_attachment_urls` gets stable paths, but a
typo in the CLI token — or dropping the header — would have left every
test green while the CLI silently went back to ~800-char signed URLs.
Assert the header verbatim across GET, GET-with-headers, POST, and the
no-token client. Verified the assertion is load-bearing by mutating the
constant to `stable_attachment_url`: the test fails.

TestAttachmentToResponse_SignedModeRotatesButStableDoesNot only checked
the "stable does not" half, so its name overstated coverage. Add the
rotation half. It drives the signer with two explicit expiries rather
than calling attachmentToResponse twice: that function mints its expiry
from time.Now() at second granularity, so consecutive calls usually land
in the same second and asserting on them directly would be a flaky test
of a real property. Also pins that only the query rotates, and that a
fixed expiry re-signs deterministically — without which the rotation
assertion would prove nothing about the clock.

MUL-5372

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-29 18:25:14 +08:00
leroy-chen e13eb6c216 feat(cli): persist daemon flags in config.json (#3824)
Merge approved PR #5161.
2026-07-17 15:43:29 +08:00
a91a390d48 fix(cli): recover daemon executable path (MUL-4514)
* fix(cli): recover daemon executable path

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): reuse executable fallback for restart

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-14 14:50:22 +08:00
220fa58264 fix: guide SSH installs to token login (#5318)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-13 16:42:46 +08:00
a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-13 09:15:36 +08:00
Bohan Jiang b90816264e feat(skills): import skills from a .skill/.zip archive (#4735)
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard.

Closes #4730
MUL-3865
2026-06-30 14:46:46 +08:00
637b6ee433 feat: add CLI comment resolve commands (#4404)
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-22 18:16:38 +08:00
LinYushenandmultica-agent 1f5cb51d4e MUL-3284: Web UI + CLI (custom runtime PR3) (#4177)
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override

- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
  update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
  / unset-path for a per-machine command override. protocol-family validated
  client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
  validated; update only sends changed flags (protocol_family immutable);
  delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
  CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
  round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
  appendProfileRuntimes now prefers an override path when set AND executable,
  else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
  bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
  relative-path rejection, config preservation); cli/config round-trip;
  daemon prefers-override / falls-back-when-not-executable.

Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.

Co-authored-by: multica-agent <[email protected]>

* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page

Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).

- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
  whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
  RuntimeProtocolFamily / RuntimeProfile types; api client
  list/get/create/update/deleteRuntimeProfile against
  /api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
  mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
  rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
  protocol family → step 2 display_name/command_name/description; edit form
  for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
  409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
  create/edit/delete flow is keyboard + screen-reader operable.

Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.

Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.

Co-authored-by: multica-agent <[email protected]>

* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)

Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".

- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
  detail row, the create body field, the update patch field, and the form
  textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
  fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
  update and stopped sending `fixed_args`; updated the "no fields" message.
  Test now asserts the CLI never sends fixed_args.

Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.

Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.

Co-authored-by: multica-agent <[email protected]>

* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)

Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.

- Server (runtime_profile.go): drop `visibility` from the create + update
  request structs; CreateRuntimeProfile always stores 'workspace'
  (runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
  removed validRuntimeProfileVisibility. The column + response field stay
  (always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
  the VisibilityOption component, the detail row, the visibility state, and the
  create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
  (profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
  Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
  the VISIBILITY list column; removed validateVisibility; stopped sending the
  field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
  visibility:"private" -> response and DB row are 'workspace'); CLI create test
  now asserts visibility is never sent.

Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.

Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-06-17 11:38:17 +08:00
Chenyu-24601 8b94764c47 feat(daemon): configurable OpenClaw binary path / state dir via CLIConfig.Backends (MUL-3157)
Summary:
- Add CLI config schema for OpenClaw backend binary path and state dir overrides.
- Apply those overrides during daemon LoadConfig using the existing env-var based probe/spawn path.
- Cover backward compatibility, precedence, partial overrides, and fail-soft config loading.

Verification:
- go test ./internal/cli ./internal/daemon
- go vet ./internal/cli ./internal/daemon
- GitHub CI passed
2026-06-09 14:05:37 +08:00
LinYushenandmultica-agent 9ff801f926 docs(cli): error-message conventions + sign-in copy (PR3, MUL-3104) (#3900)
* docs(cli): add Error Messages conventions + refine sign-in copy (PR3)

Final pass of the CLI error-message work (MUL-3104).

- CLI_AND_DAEMON.md: new "Error Messages" section documenting the user-facing
  contract — friendly single-line messages, server validation passthrough,
  English default with automatic Chinese on a zh locale, the tiered exit codes
  (0/1/2/3/4/5), --debug / MULTICA_DEBUG for the full chain, and
  MULTICA_HTTP_TIMEOUT.
- cmd_auth.go: clarify three high-frequency sign-in errors so the message
  states what failed and the next step — local login-callback server start
  (hints at port/firewall), access-token creation, and token verification
  (suggests retrying `multica login` and checking the token is valid/not
  expired). All keep %w so exit-code tiering and --debug detail are preserved.

cmd_id_resolver.go is left as-is — its not-found / ambiguous-prefix messages
already point at `list --full-id` and need no change. The user-facing
FormatError layer is unchanged, so its existing PR1/PR2 test coverage still
applies; no test asserted the old verb strings.

Refs MUL-3104. PR3 of 3 (final).

Co-authored-by: multica-agent <[email protected]>

* fix(cli): make login failure guidance visible via typed user-message wrapper

Addresses 张大彪's PR3 review: the refined sign-in copy was wrapped with %w,
so FormatError returned the centralized *HTTPError/*NetworkError copy and the
new guidance only appeared under --debug.

- Add cli.UserMessageError + cli.WithUserMessage: a typed wrapper carrying a
  user-facing message that FormatError surfaces by default, recognized before
  the network/http branches. Unwrap() is preserved, so ExitCodeFor still
  classifies by the underlying typed error and --debug still prints the full
  original chain.
- cmd_auth.go: wrap the OAuth access-token-creation and PAT-verification
  failures with WithUserMessage (OAuth copy no longer mentions a passed token,
  since that flow has none), and move the token-specific 'valid / not expired'
  hint to the real Enter your personal access token:  verification site (was the generic
  'invalid token: %w').
- Focused tests: under a wrapped *HTTPError(401) the default FormatError shows
  the login hint, ExitCodeFor returns ExitAuth, and --debug retains the raw
  chain; a wrapped *NetworkError still classifies as ExitNetwork.
- CLI_AND_DAEMON.md: narrow 'every error' to command errors returned to the
  top-level handler, noting commands like setup's fast /health probe bypass it.

Refs MUL-3104, PR #3900.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-06-09 13:15:51 +08:00
LinYushenandmultica-agent b83b41ff44 feat(cli): per-status error copy with actionable hints (PR2, MUL-3104) (#3897)
* feat(cli): refine per-status error copy with actionable hints (PR2)

Builds on PR1's translation layer. Each HTTP-status message now carries an
actionable next step, in both English and Chinese:

- 401: run `multica login`; plus a self-hosted / non-OAuth fallback telling
  the user to ask their administrator for valid credentials
- 403: check the workspace / ask an admin to grant access
- 404: check the ID or run the matching `list` command
- 409: re-fetch the latest state and retry
- 422: check values / run with --help
- 429: wait and retry; reduce call frequency if it persists
- 5xx: retry, contact support, and re-run with --debug for the raw response

Also adds ErrorKind.String() (stable snake_case identifiers) and uses it in
--debug output instead of the raw int, and clears the pre-existing gofmt dirt
Eve flagged in cmd_config.go, cmd_version.go, and help.go.

Tests: TestErrorKindString (all kinds + uniqueness + out-of-range fallback)
and TestFormatErrorActionableHints (locks the per-status hints in EN and ZH).

Refs MUL-3104. PR2 of 3.

Co-authored-by: multica-agent <[email protected]>

* test(cli): cover validation (400/422) actionable hint

TestFormatErrorActionableHints omitted KindValidation, so deleting the 400/422
hint would have gone unnoticed. Add 400 and 422 cases (no server message, so
the generic validation copy is used) asserting EN contains --help / expected
format and ZH contains --help / 格式 / 参数.

Refs MUL-3104, PR #3897.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-06-08 16:02:09 +08:00
LinYushenandmultica-agent 28de8b8bde feat(cli): central error translation layer (PR1, MUL-3104) (#3892)
* feat(cli): add central error translation layer (PR1)

Introduce server/internal/cli/errors.go, a single user-facing error
translation layer that collapses raw transport errors, HTTP status
errors, and internal verb-wrapped chains into clear, localized messages.

- ErrorKind classification (network timeout/DNS/refused/TLS/offline,
  401/403/404/409/400+422/429/5xx, unknown)
- NetworkError wraps transport errors and strips the raw URL from the
  user-facing message; classifyNetworkError categorizes via errors.As/Is
  with string fallbacks
- HTTPError.Kind() maps status codes onto ErrorKind
- FormatError: bilingual output (English default, auto-switch to Chinese
  on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the
  server message; --debug / MULTICA_DEBUG appends the full raw chain
- ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5,
  other=1)
- client.go: default HTTP timeout 15s -> 30s, overridable via
  MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError
- main.go: route errors through FormatError + ExitCodeFor, add persistent
  --debug flag

Unit tests cover every ErrorKind, classification, language detection,
exit codes, server-message extraction, and timeout parsing.

Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and
per-command customization) follow separately.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): address review — unify command timeouts and classify all helper errors

Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT.
Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout
+ small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s
context.WithTimeout in every API command (14 files, 92 sites) with
cli.APIContext. The issue-create/comment path now uses APITimeout() with a
60s floor for attachment uploads.

Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a
shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders,
PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile,
UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue
update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/
comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all
get HTTPError.Kind() classification, friendly copy, and the tiered exit code
instead of the raw string + exit 1.

Tests: new errors_integration_test.go drives the real helpers against a fake
server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500
across all 10 helpers, plus a slow-server test proving the command context
does not cancel before the transport timeout. Updated the UploadFileWithURL
assertion to check for *HTTPError.

Refs MUL-3104, PR #3892.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT

Closes out the timeout work: the last API command paths still used a
hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them
to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up
while preserving each original lower bound:

- cmd_autopilot.go  autopilot trigger      30s -> AtLeastAPITimeout(30s)
- cmd_attachment.go attachment download    60s -> AtLeastAPITimeout(60s)
- cmd_agent.go      avatar upload           60s -> AtLeastAPITimeout(60s)
- cmd_skill.go      skill import / search    60s -> AtLeastAPITimeout(60s)
- cmd_runtime.go    runtime update         150s -> AtLeastAPITimeout(150s)
- cmd_login.go      workspace-creation poll 10s -> AtLeastAPITimeout(10s)

The login poll keeps a short 10s floor to stay responsive within its 5-minute
loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales
with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in
TestAPITimeoutRespectsEnv.

Refs MUL-3104, PR #3892.

Co-authored-by: multica-agent <[email protected]>

* style(cli): gofmt cmd_attachment.go to unblock backend CI

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-06-08 15:34:59 +08:00
LinYushenandmultica-agent 5e1a6c4853 fix(cli): degrade 'issue metadata list' to {} on /metadata 404 (#3757)
Self-hosted backends without the per-issue metadata route (older builds,
unapplied 105_issue_metadata migration, or proxy/ingress misroutes) reply
404 to GET /api/issues/:id/metadata. The agent runtime bootstrap calls
'multica issue metadata list <issue> --output json' best-effort, but a
non-zero exit was being escalated by Hermes into a failed agent run even
when the rest of the work succeeded.

This makes only the 'list' verb best-effort: a 404 from /metadata now
prints {} (or an empty table) and exits 0. Other status codes (401, 500,
etc.) keep real error semantics, and 'metadata get / set / delete' are
unaffected — those represent explicit caller intent.

To support the status-code check without changing the user-facing error
string, GetJSON now returns *cli.HTTPError on HTTP failures (the format
'GET <path> returned <code>: <body>' is preserved by HTTPError.Error()).

Refs GitHub issue #3711.

Co-authored-by: multica-agent <[email protected]>
2026-06-04 16:13:27 +08:00
Multica Eve ae27058b0a fix(attachments): unified download endpoint with mode + presign + proxy (MUL-2976) (#3747)
Fix attachment download for self-hosted deployments using private S3-compatible buckets without CloudFront. Closes #3721.

**Server**

- New unified `GET /api/attachments/{id}/download` endpoint that picks CloudFront / S3 presign / server proxy at request time.
- `ATTACHMENT_DOWNLOAD_MODE=auto|cloudfront|presign|proxy` and `ATTACHMENT_DOWNLOAD_URL_TTL` env knobs; `auto` routes Docker hostnames / localhost / private IPs through the proxy and public S3 endpoints through presign.
- `Storage.PresignGet` capability; S3 implementation generates presigned GET URLs.
- `attachmentToResponse` returns the unified relative endpoint instead of leaking raw unsigned S3 URLs when CloudFront is not configured. Proxy path streams via `io.Copy` with `Content-Disposition` / `Content-Length` / `Cache-Control: no-store` / `X-Content-Type-Options: nosniff`.

**Clients**

- CLI / Desktop / Mobile resolve relative `download_url` values against the configured API base. Desktop covers the Electron native download bridge and the media preview modal; Mobile covers `Linking.openURL`, the markdown image RN loader, and the composer's completed non-image file chip.
- Mobile gains a minimal Node-environment vitest lane wired into `mobile-verify.yml`.

**Docs**

- `.env.example`, `docker-compose.selfhost.yml`, `SELF_HOSTING_ADVANCED.md`, and the `environment-variables` doc set updated with the new env keys and the `ATTACHMENT_DOWNLOAD_MODE=proxy` recommendation for Docker / VPC-internal object stores.

**Tests**

- `internal/storage`, `internal/cli`, `internal/handler` (download endpoint, mode selection, proxy header, `/content` non-regression), `cmd/server` (trusted proxy parser).
- `packages/views/editor/use-download-attachment.test.tsx` and `attachment-preview-modal.test.tsx` exercise relative URL resolution + absolute pass-through.
- `apps/mobile/lib/attachment-url.test.ts` covers every helper branch plus the composer non-image chip case.
2026-06-04 14:52:57 +08:00
iYuan d8635ad580 fix(issues): prevent duplicate active issue creation (MUL-2225) (#2602)
* fix: prevent duplicate active issue creation

* fix(issues): address duplicate guard review

* fix(autopilot): skip duplicate issue admissions

* fix(issueguard): tighten duplicate lookup edge cases

* test(issues): cover duplicate guard autopilot skips

* feat(autopilots): group skipped runs in history
2026-05-15 18:27:56 +08:00
Bohan Jiangandmultica-agent fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

Co-authored-by: multica-agent <[email protected]>

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-15 18:10:22 +08:00
LinYushenandmultica-agent add3135a42 feat(cli): add squad create/update/delete and member add/remove (#2574)
* feat(cli): add squad create/update/delete and member add/remove commands

Implement missing squad management commands in the CLI:
- squad create --name --leader [--description]
- squad update <id> [--name] [--description] [--instructions] [--leader] [--avatar-url]
- squad delete <id>
- squad member add <squad-id> --member-id --type [--role]
- squad member remove <squad-id> --member-id --type

Also adds DeleteJSONWithBody to the API client for the member remove
endpoint which uses DELETE with a JSON body.

All commands support --output json for structured output.

Co-authored-by: multica-agent <[email protected]>

* fix(squad): add --output json to delete/member remove, return 404 on 0-row delete

- squad delete: add --output json flag, emit {id, deleted} on success
- squad member remove: add --output json flag, emit {squad_id, member_id, removed}
- Backend RemoveSquadMember: change query to :execrows, check RowsAffected
  and return 404 'squad member not found' when 0 rows deleted

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-14 12:51:44 +08:00
Matt Van HornandMatt Van Horn 6b7294aa5b fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes (#2076)
* fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes

After brew upgrade on Linux, os.Executable() resolves /proc/self/exe to
the Cellar path (e.g. .../Cellar/multica/0.2.9/bin/multica), which
brew cleanup deletes. The previous IsBrewInstall() short-circuit skipped
EvalSymlinks to 'preserve' the symlink, but on Linux there was nothing
to preserve - the path was already resolved.

Use cli.GetBrewPrefix() to resolve the stable symlink path
<brewPrefix>/bin/multica for brew installs. Fall back to
EvalSymlinks(os.Executable()) with a warning log when GetBrewPrefix()
returns empty (brew binary missing from PATH).

Introduce package-level function vars (isBrewInstall, getBrewPrefix) so
the daemon test can override them without modifying the cli package.

Closes #1624

* fix(daemon): harden brew-prefix fallback and document the WHY

When `brew --prefix` is unavailable but the binary is under a known Cellar
root, recover the prefix from cli.MatchKnownBrewPrefix and target
<prefix>/bin/multica instead of falling back to the resolved Cellar path
(which brew cleanup just deleted).

- Extract knownBrewPrefixes + MatchKnownBrewPrefix in cli/update.go and
  reuse from IsBrewInstall to keep one source of truth for the install-root
  list.
- Add a WHY comment above the brew branch in triggerRestart explaining the
  /proc/self/exe -> Cellar -> deleted-by-brew-cleanup chain.
- Cover both fallback paths (matched / unmatched) in daemon_test.go.

---------

Co-authored-by: Matt Van Horn <[email protected]>
2026-05-08 12:08:56 +08:00
Bright Zheng c2f199650a feat(cli): add agent avatar upload command (#1760)
* feat(cli): add UploadFileWithURL and AttachmentResponse to APIClient

* feat(cli): add agent avatar command and show avatar_url in agent get output

* fix(server): include id and url in no-workspace file upload response

* fix(cli): remove dead HTTPClient timeout swap, extend ctx to 60s for avatar upload

The 30s context deadline was tighter than the 60s HTTPClient timeout
swap, so the swap was dead code and did nothing for slow connections.
Both Neo and Omni Mentor flagged this in review.

Fix: extend the command context to 60s and remove the HTTPClient
mutation. This is simpler, thread-safe, and actually works for slow
uploads.

* fix: align fallback upload response shape and honor context deadline

- file.go: fallback returns {id, url, filename} instead of {filename, link},
  matching the no-workspace path response shape.
- client.go UploadFileWithURL: tolerate empty attachment ID (S3 succeeded
  but DB record failed — the file is still usable via its URL).
- client.go UploadFileWithURL: use a context-deadline-aware HTTP client so
  that the 60s upload timeout set by the avatar command actually takes
  effect instead of being shadowed by the default 15s client timeout.
- client_test.go: update 'missing id' test to verify empty-id success
  (fallback tolerance).

* fix(cli): shallow-copy HTTP client to preserve Transport on upload timeout

When the context deadline exceeds the default 15s HTTP client timeout,
UploadFileWithURL was creating a bare &http.Client{Timeout: remaining},
silently dropping any custom Transport, Jar, or CheckRedirect configured
on the original client. This causes obscure connection failures when the
CLI uses an authenticated proxy, custom TLS, or mock transport in tests.

Fix: perform a shallow copy of the original client struct and only
mutate the Timeout field on the copy.
2026-05-03 10:49:02 +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
Joey d17b2bfb8c feat(cli): 添加更新下载超时配置选项 (#1622)
- 在 update 命令中添加 --download-timeout 标志用于设置下载超时时间
- 实现 UpdateViaDownloadWithTimeout 函数支持自定义下载超时
- 添加 updateDownloadTimeoutOrDefault 辅助函数处理超时值验证
- 设置默认下载超时时间为 120 秒
- 添加 updateDownloadTimeoutOrDefault 函数的单元测试
- 验证超时参数必须大于零的错误处理逻辑
2026-04-24 17:05:23 +08:00
LinYushenandCopilot 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
2026-04-22 13:36:13 +08:00
6f63fae41a feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging

* fix(desktop): use releaseType instead of publishingType in electron-builder publish config

publishingType is not a valid electron-builder key; the correct GitHub
provider option is releaseType. The previous value was silently ignored,
causing uploads to be skipped and breaking auto-update.

Co-authored-by: Copilot <[email protected]>

* feat(release): standardize artifact naming across desktop and CLI

Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a
filename alone reveals kind, version, platform, and CPU arch.

Desktop (apps/desktop/electron-builder.yml):
  mac     → multica-desktop-<v>-mac-<arch>.{dmg,zip}
  linux   → multica-desktop-<v>-linux-<arch>.{deb,AppImage}
    (fixes `\${name}` expanding the scoped `@multica/desktop` into a
    broken `@multica/desktop-*` filename path)
  windows → multica-desktop-<v>-windows-<arch>.exe

CLI (.goreleaser.yml):
  multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz
  (adds `-cli` marker + version; switches `_` to `-` for consistency)

Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`:
  - drop mac x64 (Intel not a target yet)
  - add linux arm64
  Final: mac arm64, win x64/arm64, linux x64/arm64.

Downstream updates so install paths match the new CLI names:
  - scripts/install.sh
  - scripts/install.ps1 (URL + checksum regex)
  - CLI_INSTALL.md

Co-authored-by: Copilot <[email protected]>

* feat(release): use multica_{os}_{arch} CLI archive naming

Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}'
asset names. Install scripts and the desktop CLI bootstrap now resolve
assets via checksums.txt so they work without hardcoding versions.

The Go self-update path queries the GitHub release API and accepts
either the new or legacy 'multica-cli-<version>-...' names so existing
releases keep updating cleanly.

Co-authored-by: Copilot <[email protected]>

* feat(release): ship both legacy and versioned CLI archive names

GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and
'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every
release. The legacy name keeps already-released CLIs self-updating; the
versioned name is what new clients should use going forward.

Self-update / install paths flipped to prefer the versioned name and
fall back to legacy:
  - server/internal/cli/update.go (multica update)
  - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap)
  - scripts/install.sh, scripts/install.ps1 (fresh install)

Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'.

Co-authored-by: Copilot <[email protected]>

* feat(desktop): also build Linux .rpm packages

Co-authored-by: Copilot <[email protected]>

* feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1

Address review feedback on PR #1262:

- .github/workflows/release.yml: add a 'desktop' job that runs after the
  CLI 'release' job and packages the Desktop installers for Linux
  (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes
  them to the same GitHub Release via electron-builder. macOS Desktop
  continues to ship through the manual release-desktop skill so it can
  be signed and notarized with Apple Developer credentials.

- scripts/install.ps1: detect Windows ARM64 hosts via
  RuntimeInformation::OSArchitecture so the new windows-arm64 CLI
  archive is downloaded on ARM64 machines instead of always falling
  back to amd64.

Co-authored-by: Copilot <[email protected]>

* fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision

electron-builder's update metadata file is hardcoded to `latest.yml` for
Windows regardless of arch (only Linux gets an arch-suffixed name; see
app-builder-lib's getArchPrefixForUpdateFile). With two separate
electron-builder invocations for Windows x64 and arm64, both publish
`latest.yml` to the same GitHub Release and the second upload silently
overwrites the first — leaving one of the two architectures with auto-
update metadata pointing at the other arch's installer.

Route Windows arm64 to its own `latest-arm64` channel:

* scripts/package.mjs appends `-c.publish.channel=latest-arm64` only
  for the Windows arm64 invocation, so x64 keeps producing `latest.yml`
  and arm64 produces `latest-arm64.yml` alongside it.
* updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows
  arm64 clients so they fetch the matching metadata file.

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Devv <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-04-20 23:33:41 -07:00
Bohan Jiang 632fdde700 fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows

CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.

* fix(cli): make `multica update` work while the binary is running on Windows

On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.

Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.

Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).

* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events

With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.

Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
2026-04-21 13:03:48 +08:00
Jiayuan Zhang 9e15b17c92 feat(cli): add autopilot commands (#1234)
* feat(cli): add autopilot commands

Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md

* fix(autopilot): address code review — restrict run_only, validate workspace on update

Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 14:46:34 +08:00
Bohan Jiang 970b7fd1d3 fix(cli): use .zip archive for Windows in multica update (#1075)
GoReleaser produces .zip for Windows and .tar.gz for other platforms,
but the update command hardcoded .tar.gz for all platforms, causing a
404 error on Windows.

- Select .zip extension when runtime.GOOS is "windows"
- Add extractBinaryFromZip() for zip archive extraction
- Use "multica.exe" as the binary name on Windows

Closes #1072
2026-04-15 17:16:36 +08:00
2cbebfc568 refactor(daemon): remove watch/unwatch workspace logic, default to all workspaces (#1003)
The daemon now automatically watches all workspaces the user belongs to,
fetched directly from the API. This removes the manual watch/unwatch
workflow, the config-based watched/unwatched lists, the /watch HTTP
endpoints, the CLI watch/unwatch commands, and the desktop app's watched
workspace UI and reconciliation logic.

Co-authored-by: Devv <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-15 11:24:15 +08:00
40aa23a528 feat(desktop): daemon management panel with sidebar status bar (#952)
* feat(desktop): add daemon management panel with sidebar status bar

Integrate multica daemon lifecycle management into the desktop app so
users can start/stop/restart the daemon and view live logs without
leaving the UI. Session tokens are automatically synced to the CLI
config file, making daemon authentication transparent.

- daemon-manager.ts: Electron main process module for daemon lifecycle
  (health polling, start/stop via CLI, token sync, log tail)
- Preload bridge: new daemonAPI with IPC for all daemon operations
- Sidebar bottomSlot: persistent daemon status indicator in sidebar
  footer (desktop-only, injected via AppSidebar slot)
- Daemon panel Sheet: right-side drawer with status details, controls,
  and real-time log viewer with auto-scroll and level coloring
- Token sync: on login and app startup, JWT is written to
  ~/.multica/config.json so daemon can authenticate seamlessly

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings

P1: Runtimes page Local Daemon card
- Add topSlot prop to shared RuntimesPage for platform injection
- DaemonRuntimeCard shows status, agents, uptime with Start/Stop/
  Restart/Logs buttons (desktop-only, injected via slot)

P2: Auto-start and auto-stop
- Daemon auto-starts on app launch when user is authenticated
  (controlled by autoStart preference, default: true)
- Daemon auto-stops on app quit (controlled by autoStop preference,
  default: false — daemon keeps running in background by default)
- Preferences persisted to ~/.multica/desktop_prefs.json

P2: Daemon settings tab
- New "Daemon" tab in Settings > My Account section (desktop-only)
- Toggle auto-start and auto-stop behavior
- CLI installation status check with link to install guide
- SettingsPage gains extraAccountTabs prop for platform injection

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): address PR review feedback on daemon management

Must-fix:
- before-quit handler now calls event.preventDefault(), awaits
  stopDaemon(), then re-calls app.quit() so the daemon actually
  stops before the app exits
- Add concurrency guard (operationInProgress lock) in daemon-manager
  to reject overlapping start/stop/restart IPC calls
- Extract shared types (DaemonState, DaemonStatus, DaemonPrefs),
  constants (STATE_COLORS, STATE_LABELS), and formatUptime to
  apps/desktop/src/shared/daemon-types.ts — all renderer components
  now import from this single source

Should-fix:
- Log viewer uses monotonic counter (LogEntry.id) instead of array
  index as React key, preventing full re-renders on overflow
- All start/stop/restart handlers now show toast.error() with the
  error message when the operation fails
- startLogTail retries up to 5 times with 2s delay when the log
  file doesn't exist yet (handles first-run case)

Minor:
- Cache findCliBinary() result after first successful lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(logger): suppress ANSI color codes when stderr is not a TTY

Detect whether stderr is connected to a terminal and set tint's NoColor
option accordingly. Previously daemon.log files contained raw escape
sequences like \033[2m and \033[92m which made them unreadable in the
Desktop log viewer and any non-TTY sink (docker logs, systemd, etc).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(daemon): runtime watch/unwatch HTTP endpoints and denylist

Add GET/POST/DELETE /watch handlers on the daemon's health port so
clients (notably Desktop) can add or remove watched workspaces at
runtime without restarting the daemon or editing config.json. Each
handler updates in-memory state under d.mu and persists back to
~/.multica/profiles/<name>/config.json for survival across restarts.

- CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist.
  syncWorkspacesFromAPI skips entries in the denylist so a manual
  unwatch isn't silently revived 30s later by the periodic sync.
- loadWatchedWorkspaces tolerates an empty config and returns nil
  instead of erroring out, because Desktop starts daemons with a
  fresh profile and relies on the sync loop / watch endpoint to
  populate the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(desktop): bundled CLI, per-backend profile, and watch UI

Make the Desktop app self-sufficient: it bundles its own multica
binary, manages its own daemon profile keyed by the backend URL, and
authenticates that daemon with a long-lived PAT it mints on first
login. The daemon panel gains a checkbox list of watched workspaces
and surfaces the active profile + server URL.

CLI bootstrap
- scripts/bundle-cli.mjs copies server/bin/multica into
  apps/desktop/resources/bin/ before electron-vite dev and
  electron-builder package. asarUnpack: resources/** already covers
  this path, so the binary ships with the .app in prod.
- main/cli-bootstrap.ts adds an ensureManagedCli() fallback that
  downloads the latest release from GitHub when no bundled binary
  exists (first launch on a machine without developer tooling).
- daemon-manager.resolveCliBinary prefers bundled > managed > download
  > PATH, so local iteration uses the freshly built binary.

Daemon profile
- resolveActiveProfile now derives a desktop-<host> profile name from
  the target API URL and creates its config.json on demand. Never
  reads or writes the user's hand-configured CLI profiles, avoiding
  the "Desktop polluted my default profile" class of bug.
- syncToken detects a JWT input and exchanges it for a PAT via
  POST /api/tokens; caches the resulting mul_* token in the profile
  config so subsequent launches skip the round-trip.
- startDaemon / stopDaemon / log tail all operate on the resolved
  profile; renderer sets the target URL via a new
  daemon:set-target-api-url IPC.

Workspace watching
- daemon-manager exposes daemon:list-watched / daemon:watch-workspace /
  daemon:unwatch-workspace IPCs backed by the daemon's new /watch
  endpoints.
- App.tsx reconciles the user's workspace list against the daemon's
  watched set whenever TanStack Query updates it — new workspaces are
  registered instantly instead of waiting for the daemon's 30s sync,
  and removed workspaces are unwatched.
- daemon-panel gains a "Watched Workspaces" section with per-workspace
  checkboxes that call watch/unwatch directly. Opt-outs persist in the
  profile's unwatched_workspaces denylist.

Lifecycle states + UI
- DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli`
  state. Panel shows Profile / Server info rows and a "Setting up…"
  blurb during first-run CLI download; failure surfaces a Retry button.
- Status bar renders a spinner during installation and hides the Start
  button until setup finishes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): register /onboarding route

The create-workspace modal navigates to /onboarding on success, but
the Desktop router only had flat routes (issues, projects, runtimes,
etc.) — resulting in an "Unexpected Application Error! 404 Not Found"
page after creating a new workspace.

Mirror the web app's wiring: render OnboardingWizard with onComplete
pushing to /issues, via the shared navigation adapter.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(desktop): remove sidebar daemon status bar

Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard
at the top of the Runtimes page, which already shows the same info
plus full Start/Stop/Restart controls and the Logs entry point. A
single canonical place avoids fragmenting daemon status across the UI.

Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop
was the only consumer, Web never needed it, so keeping it would be
dead scaffolding.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): daemon panel layout and close button

- Logs section now fills the remaining vertical space down to the
  sheet bottom instead of being capped at h-64, which left a huge
  empty area below it. Top section (status, actions, watched list)
  keeps natural height as shrink-0; the watched list gets its own
  max-h-48 scroll so a long list can't push Logs off screen.
- Replace the Sheet's built-in close button with an explicit
  <button> wired directly to onOpenChange(false). The Base UI
  Dialog.Close wrapped in Button via the render prop wasn't firing
  on click in this panel; going straight through the controlled
  state guarantees it responds.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): make daemon panel clickable inside Electron drag region

The sheet opens at the top of the window, which visually overlaps the
TabBar's -webkit-app-region: drag zone. Even though the sheet portals
to document.body, Chromium computes drag regions over the final
composited pixels, so the sheet inherited "drag" and swallowed the
mouseup of every click (mousedown fired but click never resolved) —
including the X close button.

Mark the entire SheetContent popup with -webkit-app-region: no-drag
to subtract it from the drag region. This also fixes future buttons /
checkboxes inside the sheet that would have hit the same issue.

While here, move the close button into the SheetHeader as a flex
sibling of SheetTitle instead of an absolutely positioned overlay —
simpler layout and avoids any stacking-context weirdness.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(desktop): clickable daemon runtime card row

The whole Local Daemon row now opens the sheet panel — icon, title,
and status line are all part of one click target. This replaces the
standalone "Logs" button, which was redundant now that clicking
anywhere on the row does the same thing.

The right-side action cluster (Start / Stop / Restart) wraps its
onClick in stopPropagation so pressing those buttons doesn't bubble
up and open the panel.

Keyboard access: Enter / Space on the focused row opens the panel,
with a focus-visible background for feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(runtimes): mark Desktop-launched daemons as managed

When the Multica Desktop app spawns the CLI it ships with, the
resulting daemon shares its binary with the Electron bundle — Desktop
is responsible for updating that binary on every release. Letting the
daemon self-update would just get clobbered on the next Desktop launch
and could brick the embedded binary mid-update.

Propagate a "launched_by" signal end-to-end so the UI can hide the
CLI self-update affordance (and the daemon refuses updates as a second
line of defense):

- Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop.
- daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot.
- registerRuntimesForWorkspace includes launched_by in the request body.
- Server DaemonRegister folds launched_by into runtime.metadata (JSONB
  — no migration needed).
- handleUpdate returns a "failed" status with an explanatory message
  when LaunchedBy == "desktop", so even a bypass API call can't trigger
  the self-update path.
- RuntimeDetail extracts metadata.launched_by and passes it to
  UpdateSection, which swaps the Latest / → available / Update button
  cluster for a muted "Managed by Desktop" label.

CLI-only users (brew install, direct tarball) keep the exact same
behavior — the env var is empty, the UI shows the update button,
the daemon still self-updates on request.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): harden daemon manager from PR review

- syncToken now takes userId and mints a fresh PAT on user switch,
  restarting a running daemon so it picks up the new credentials.
  A .desktop-user-id sidecar in each profile records the owner so a
  previous user's cached PAT can't be reused on the next login.
- App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken()
  and daemonAPI.stop() so the cached PAT and live daemon don't
  outlive the session.
- startLogTail replaced with a cross-platform watchFile
  implementation (initial 32 KB window + poll for new bytes,
  handles truncation). spawn("tail") was broken on Windows.
- writeProfileConfig now serializes through a promise chain to
  prevent concurrent writes from corrupting config.json.
- startDaemon keeps the "starting" state until pollOnce confirms
  /health, avoiding a running → stopped flash when the Go daemon
  isn't yet listening after the supervisor returns.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(desktop): verify downloaded CLI against checksums.txt

Download goreleaser's checksums.txt alongside the release archive,
parse the sha256 lookup, stream the archive through createHash, and
refuse to install on mismatch or missing entry. Closes the supply-
chain gap where auto-install would execute an unverified binary on
first launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore(desktop): lint and style cleanups from PR review

- eslint.config.mjs: add scripts/**/*.{mjs,js} override with
  globals.node so bundle-cli.mjs lints clean (was erroring on
  undefined process/console).
- daemon-panel.tsx: log level classes now use semantic tokens
  (text-info, text-warning, text-destructive) instead of hardcoded
  Tailwind colors; escape the apostrophe in the retry copy.
- daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon-
  types instead of redefining it.
- runtimes-page.tsx: fix indentation inside the new topSlot wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Devv <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: yushen <[email protected]>
2026-04-14 19:12:39 +08:00
black-fe 3c0ed0f732 test(cli): cover API client context headers 2026-04-14 15:13:54 +08:00
Jiayuan c39470a53f fix(comments): address code review feedback on pagination
1. Update CLAUDE.md template to document --limit, --offset, --since
   params and guide agents to use pagination when comments are large
2. Add GetJSONWithHeaders to API client; CLI now prints "Showing X of Y
   comments" to stderr when paginating
3. Cap --since without --limit at 500 server-side to prevent unbounded
   result sets
2026-04-04 01:01:48 +08:00
8eb1caa72b fix(agent): instruct agents to use download_url for attachments (#356)
* fix(agent): instruct agents to use download_url for attachments

Agents were not aware of the signed vs unsigned URL distinction in
attachments, causing failures when trying to read images. Added an
Attachments section to the generated CLAUDE.md/AGENTS.md template that
tells agents to always use `download_url`. Also increased signed URL
expiry from 5 to 30 minutes to better accommodate agent processing time.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(cli): add `multica attachment download` command

Adds a dedicated CLI command for downloading attachments by ID. The
command fetches attachment metadata from the API (which returns a fresh
signed URL), downloads the file, and saves it locally. This eliminates
the need for agents to understand signed vs unsigned URLs.

Changes:
- New `multica attachment download <id>` CLI command
- New `GET /api/attachments/{id}` backend endpoint
- `DownloadFile` helper on APIClient
- Updated CLAUDE.md template to document the command

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(cli): sanitize filename and add download size limit

- Use filepath.Base on attachment filename to prevent path traversal
- Add 100MB size limit to DownloadFile (matches upload limit)
- Include response body in download error messages for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Devv <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-02 07:45:42 -07:00
LinYushenandClaude Opus 4.6 606930725a feat(daemon): support direct download update for non-Homebrew installs (#334)
* feat(daemon): support direct download update for non-Homebrew installs

Previously, CLI auto-update only worked for Homebrew installations. Non-brew
binaries would fail with "not installed via Homebrew". Now the daemon and
`multica update` fall back to downloading the release binary directly from
GitHub Releases when Homebrew is not detected.

Also fixes:
- Daemon restart now uses the current executable's absolute path instead of
  searching PATH, ensuring the updated binary is used
- Brew installs preserve the symlink path so the new Cellar version is picked up
- Daemon startup logs now include the CLI version
- Update UI auto-clears "restarting" status after 5s to show the new version

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(cli): remove dead DetectNewBinaryPath and guard against nil latest version

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-02 15:38:06 +08:00
LinYushenandClaude Opus 4.6 fdba410f11 feat(runtime): support CLI update from web runtime page (#331)
* feat(runtime): support CLI update from web runtime page

Add the ability to update the CLI daemon from the web Runtime detail page.
When a newer version is available on GitHub Releases, an update button
appears. Clicking it sends an update command through the server to the
daemon via the heartbeat mechanism (same pattern as ping). The daemon
executes `brew upgrade`, reports the result, and restarts itself with the
new binary.

Changes across all three layers:
- Frontend: version display, GitHub latest check, UpdateSection component
- Server: UpdateStore (in-memory), heartbeat extension, 3 new endpoints
- CLI: shared update logic, daemon handleUpdate + graceful restart

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(runtime): handle 'running' status in ReportUpdateResult

The daemon sends {"status":"running"} when it starts executing the
update, but ReportUpdateResult treated any non-"completed" status as
failure — immediately marking the update as failed before brew upgrade
even ran.

Fix: use a switch statement to handle "running" as a no-op (status is
already "running" from PopPending), and also timeout running updates
after 120 seconds in case brew upgrade hangs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-02 14:12:49 +08:00