316 Commits
Author SHA1 Message Date
104bf7be35 MUL-7732: regroup settings by scope and consolidate scattered pages (#8874)
* feat(mcp): report how many agents use each workspace MCP server

The workspace MCP library listing now carries agent_count: the number of
live (non-archived) agents each entry is assigned to. Settings shows it so
an admin can tell whether replacing or removing a server affects anyone.

The field is optional in the client schema, so an older server that omits
it simply shows no count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(settings): regroup settings by scope and consolidate scattered pages

Implements the MUL-7732 settings redesign:

- Navigation is grouped by who a setting affects: Personal, the current
  workspace (with Issues & workflow and Connections & extensions
  sub-groups), and This device on desktop. Pages and sections carry a
  scope badge (account, this device, workspace), and members see a lock on
  pages only owners and admins can change plus a shared read-only notice.
- Settings search in the nav indexes page and row titles and
  descriptions; opening a result scrolls to and highlights the row.
- Preferences is one page instead of three tabs, with a searchable time
  zone picker, a segmented theme control and a field grid for the
  create-issue dialogs.
- Code merges Repositories, GitHub and self-hosted Git. Repositories are
  read-only rows edited through dialogs; self-hosted Git connects in a
  dialog that ends on the one-time webhook secret.
- The PR merge rule moves to Statuses & transitions as an automatic
  transition; Code only shows its current value. Built-in statuses show
  edit and archive as unavailable instead of refusing after a click.
- Integrations becomes Messaging (IM channels only) with a breadcrumb on
  detail pages; Composio moves to Personal as Connected apps.
- Members gets tabs, search, invite and share-link dialogs and a seat
  summary. MCP servers show assignment counts and explain how MCP,
  connected apps and plugins differ. Workspace General shows members plain
  values and changes the issue prefix through an explicit dialog.
- Skill labels are managed from the Skills page; Settings > Labels keeps
  the issue catalog.
- Successful saves no longer toast; failures still do.

Old ?tab= values (github, repositories, integrations, issue, chat, labs,
and the Composio OAuth callback) resolve to the new pages, since server
redirects still use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs: point settings paths at the regrouped settings pages

GitHub, repositories and self-hosted Git now live under Settings > Code,
IM bots under Settings > Messaging, and the PR merge rule under
Settings > Statuses & transitions. The GitHub guide also drops the old
master switch step in favor of pausing from the GitHub row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): edit the PR merge rule on the Code page

The "After PRs merge, move the issue to" rule sits with the other pull
request rules on Code again instead of a separate Automatic transitions
section. It applies to every code host, so pausing GitHub features does
not lock it. The statuses page goes back to Issue Statuses, and its merge
badge links to the rule on Code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): manage skill labels under Settings → Labels again

The Skills page entry was buried at the bottom of the filter menu's Labels
submenu. Settings → Labels switches between the issue and skill catalogs
again, using the members page's segmented switch (now SettingsViewTabs),
and the Skills page keeps its shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): keep GitHub resume reachable after a disconnect

Pausing GitHub features is a workspace setting, but the pause and resume
item lived in a menu that only rendered with an installation. A workspace
paused before disconnecting, or with github_enabled=false and no
installation, lost the way back, and every PR switch stayed disabled. On
deployments without a GitHub App it could not even reconnect, though
Co-authored-by works without one.

The menu now shows whenever GitHub is connected or paused, and only
Disconnect requires an installation. The paused note shows in either state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): let the URL choose the open members list

The members page read `section` only to initialize its list tab, so a
search result for invitations or share links opened while already on the
page changed the URL but not the list, and back/forward did nothing. With a
router the open list is now derived from the URL, and switching lists
replaces `section`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): tell repositories on different ports apart

The manual repository dialog reused the GitHub import's identity, which
drops the port, so https://git.example.com:9443/acme/api.git read as a
duplicate of the same path on :8443 and could not be saved. Non-default
ports now stay in the identity; default ports, including 22 for ssh://,
still compare equal to the scp-like form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(comments): stop the recipient-menu tests flaking under CI load

The steering tests opened a recipient chip menu while the composer could
still re-render its chips, from the previous send settling or the preview
catching up. That closed the menu, and the menu steps also used the 1s
default wait inside the 5s default test timeout. Under parallel load
locally, about 1 in 20 runs failed the same way as CI.

Menu choices now go through a helper that reopens the menu until the item
shows. The remaining waits get the 5s the first steps already had, and each
test gets 15s. 140 of 140 loaded runs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 18:15:01 +08:00
815fe37b1f MUL-7685: fix(agents): paginate task history and aggregate duration (#8804)
* fix(agents): paginate task history and aggregate duration

* test(agents): cover pagination boundaries and query lifecycle

* fix(migrations): register agent history index retry cleanup

* fix(agents): show totals only for complete task history

* fix(agents): average duration over the full rolling window

The server bounds duration totals by completed_at > now() - 30 days,
which can span 31 calendar days. deriveAgentActivity summed duration only
after trimming buckets to the chart's 30 local-day slots, so runs on the
oldest partial day were dropped from the average. Sum duration for every
returned bucket before slotting.

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): renumber agent history index to 552

main now carries 551_pr_merge_status, and migration numbers from 129
onward must be unique (TestMigrationNumericPrefixesAreUnique).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bhjiang@outlook.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 15:58:00 +08:00
9c6622787d MUL-7735: docs(readme): fix stale facts, sync the Chinese README, retake the hero screenshot (#8869)
* MUL-7735: docs(readme): fix stale facts and bring the Chinese README in line

Facts that had drifted from the code:
- Runtimes table was missing ZeroClaw (25 rows against the 26 CLIs in
  scripts/agent-cli-command-names.txt).
- Architecture diagram routed Desktop and iOS through Next.js; only Web
  does. Desktop and iOS talk to the Go backend directly.
- A single execution is a Run, not a Task (conventions glossary; the
  docs page is already titled Runs / 运行).
- Mobile runs natively on iPad too; link the mobile-app docs page.
- New Feishu/Lark connections are mainland-China Feishu only.
- "Any Git host" overclaimed; it is GitHub, GitLab, Gitea, and Forgejo.

Chinese README: channel list now matches English (adds WeCom and
Telegram and the community-maintained note), docs links point at
/docs/zh/ with Chinese heading anchors, adds the missing CLI-skill row
and Star History badges, and uses the UI's terms (聊天, 自托管).

Structure: the CLI count now appears once, in the Runtimes section,
instead of four places per file; Get started and the five-minute
walkthrough are merged; the self-host block states the anonymous
telemetry default and the DO_NOT_TRACK opt-out; Stay in the loop gains
steering a running agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* MUL-7735: docs(assets): retake the workspace overview screenshot

The hero image predated the sidebar regrouping (Work / AI Team) and the
Usage -> Analytics rename. Retaken from a seeded local environment at the
same 1600x900 size and WebP q80 (85 KB -> 82 KB). The README and docs
index pages share this file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 04:27:06 +08:00
7dac88a6a8 MUL-7649: issue deliverables and dynamic blocks — sidebar section, overview grid, viewer info panel, html/mermaid block frame (#8779)
* feat(attachments): issue deliverables model and sequence block ids (MUL-7649)

collectDeliverableFiles groups every comment upload into deliverables,
merging same-name, same-type re-uploads into versions (newest version
first). Description attachments are inputs and never enter the model.

Sequence items now carry the id of the block they came from, so a viewer
can say which comment (or the description) a file was posted in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(viewer): host details, overview and info panel slots (MUL-7649)

PreviewSequenceProvider takes an optional describeItem (title accessory,
info panel, locate action) and onOpenOverview. The viewer renders only the
slots it is handed: a locate button, a grid button (G) and an info toggle
(I) whose panel sits beside the stage. Letter keys stand down in fields,
and any key pressed inside an open menu stays with the menu.

A zoom canvas still at fit now follows a viewport resize (the info panel
opening), while a zoom the reader chose is only clamped, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): deliverables section, overview grid and info panel (MUL-7649)

The sidebar's "Deliverables" section shows what the issue delivered as a
whole: its pull requests and the files its comments uploaded, re-uploads
merged into one entry marked v2. It lists the three newest images and four
newest files, plus "View all N deliverables".

The former Pull requests section becomes the code group, keeping its
MUL-7429 actions (link a PR by URL, the auto-complete menu) beside the
group label. While the workspace shows PRs, the section stays up even with
nothing delivered, since that is where a PR gets linked by hand.

The overview (from "view all" or G in the viewer) shows code first, then
files grouped by posting comment, filterable by kind, each group one click
from its comment. Its count always equals the sidebar's.

In the viewer, deliverables get a version switcher, and every file gets an
info panel (source excerpt, sibling files, uploader, time, size) and
"Show in comments", which unfolds a resolved thread if needed and reuses
the timeline's jump-and-flash highlight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(comments): standalone attachments as cards and an image row (MUL-7649)

The files under a comment (and a chat message) no longer stack as one
full-width row each. Other files become cards in a grid: type glyph, name,
"TYPE · size", download / remove on hover, and the whole card opens the
file. Several images sit in one row at a shared height; a lone image keeps
its full size. HTML keeps its embedded preview (MUL-2330).

On the issue page a re-uploaded file's card carries its version (v1, v2)
from the deliverables model, through a small context.

Standalone attachments are grouped images, then HTML, then files, and the
preview sequence walks them in that same order, so paging follows the
screen. The file-type glyph moves to editor/utils/file-icon so comment
cards and the deliverables surfaces share one mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): pull requests keep their own section (MUL-7649)

Since MUL-7429 the PR block is issue workflow — linking a PR by hand, the
auto-complete switch, "won't auto-complete: missing Closes" — rather than
output, so it goes back to main's own Pull requests section, unchanged,
directly above Deliverables.

Deliverables is now the delivered files only: hidden until a comment
delivers one, and the sidebar number, "View all N" and the overview's
"All N" all count files. The overview drops its code group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): lay several images out as justified rows (MUL-7649)

Fixed-height tiles wrapped as soon as the column narrowed — three
screenshots became two plus an orphan, with a ragged right edge. Rows are
now justified from each image's real aspect ratio: every row shares one
height and a full row runs edge to edge. A row takes as many images as fit
above a minimum height, never grows past a maximum, and a last row that
does not fill keeps the height of the row above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(rich-content): dynamic block frame for html and mermaid fences (MUL-7649)

A fenced ```html or ```mermaid block is part of the message body, so it now
renders in one shared frame instead of two unrelated widgets:

- Always-visible title bar: kind icon, the fence's title="..." (or the kind
  name), a kind chip, Preview | Source, fullscreen and copy. Previously the
  controls only appeared on hover and floated over the content.
- The body takes its content's height (at least 120px) and collapses past
  480px behind a fade and "Show all". An HTML block used to be a fixed 480px.
- Loading keeps the height this block had earlier in the session; a script
  error or a Mermaid parse error is explained inside the frame with
  "View source" and "Copy error".
- HTML gets the app's theme tokens (--foreground, --chart-1 ...); HTML that
  uses one also gets the app's color-scheme, so it follows dark mode. HTML
  that uses none keeps its own look.

The sandboxed document reports its height and first uncaught error through a
one-line postMessage bridge; the page checks the message source and clamps
every value, and stops a document whose height follows the frame. rehype-raw
drops the fence meta, so the title is read in the closed-fence parse.

The Mermaid sandbox now also declares the app's color-scheme and font: in
dark mode it was painted on an opaque white canvas, and its labels were drawn
in a serif font that did not match the layout Mermaid measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(attachments): HTML files show as cards, not inline previews (MUL-7649)

Reverses the MUL-2330 pin. An uploaded HTML file is a deliverable to open,
not part of the text: like every other non-image file it shows as a card
(a row inline in the body, a grid card under it) that opens in the viewer,
and its contents are no longer fetched to embed a 480px iframe. HTML meant
to be read in place is written as a ```html block, which renders as a
dynamic block.

- Attachment drops its html branch; HtmlAttachmentPreview is deleted and
  HtmlPreviewBody keeps only the inline source the viewer uses.
- orderStandaloneAttachments groups images, then files (HTML included), so
  AttachmentList and the viewer sequence still walk the same order.
- The MUL-2330 regression pins now assert the card and that no text fetch
  happens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs: charts go in html/mermaid blocks, files show as cards (MUL-7649)

Agents that uploaded an HTML chart expecting it to render in the comment now
get a file card instead. Tell them where each kind of content goes:

- multica-platform skill (issues reference, routed from the table): a fenced
  ```html / ```mermaid block renders in place with a title bar and content
  height; an attached file, HTML included, is a card that opens the viewer.
  Covers title="...", the sandbox, the theme variables and their opt-in
  color scheme, and sizing to content rather than the viewport.
- `multica issue comment add --attachment` help says the same in one line.
- Comments docs (all five languages) describe both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(daemon): runtime brief says where charts and diagrams go (MUL-7649)

The chart/file rule lived only in the multica-platform skill's issues
reference, which an agent opens on demand, and the skill's description did
not mention comment formatting, so an agent that just wanted a chart in its
result had no reason to read it. Agents that uploaded report.html expecting
an inline chart now get a card.

- The brief's Output section carries one line, beside the file-delivery
  line, on the surfaces the web renders (issue comments and web/mobile
  chat): put charts and diagrams in the text as a fenced html or mermaid
  block, name it with title="...", and an attached file, HTML included,
  shows as a card. Theming and sizing stay in the reference.
- Channel chats, autopilot run results and quick-create stdout do not render
  these blocks and do not get the line; the delivery tests pin both sides.
- The skill description names "charts and files in comments" so the
  reference is picked for that task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): locate a file in a collapsed thread (MUL-7649)

"Show in comments" for a file posted in a reply only unfolded resolved
threads and gave up after 30 frames, so a thread the reader had collapsed
stayed shut and the reply never mounted. A reply now goes through the
quick-jump rail's jumpToReply, which already undoes every kind of folding
and waits for the reply to land. A root comment gets the same treatment for
its own thread (the reader's collapse, or a resolved bar), then the jump.

Found in review by Emacs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(viewer): the info panel's "Show in comments" closes the viewer (MUL-7649)

The top bar's locate went through the viewer, which closes itself first;
the same button in the info panel called the host's callback directly, so
the page scrolled underneath a viewer that still covered it. Controls handed
to describeItem gain close(), and the issue page builds one close-then-locate
action for both entry points.

Found in review by Emacs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): the deliverables overview is a real modal dialog (MUL-7649)

The overview was a portaled layer with role="dialog" and nothing else: focus
stayed on the opener, Tab walked into the page underneath, and closing left
focus wherever it was. It now renders through the shared Dialog, restyled to
cover the window, so the primitive moves focus in, keeps it inside, returns
it to the opener, and handles Escape. `G` back to the viewer's file stays.
The dialog is named by its title ("MUL-123 deliverables").

Found in review by Emacs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 03:23:21 +08:00
fc4a615700 MUL-7726: let the workspace choose the status merged PRs move an issue to (#8862)
* feat(pr): move issues to a workspace-chosen status when their PRs merge (MUL-7726)

The merge automation completed an issue only when a PR said "Closes MUL-1",
so the workspace switch mostly did nothing: most PRs are linked by title or
branch and never completed anything. The workspace now picks what a merge
does, in settings.pr_merge_status: "none", or the key of a started or done
status (custom ones included, Blocked excluded). Absent means Done. When
every linked PR is merged, the issue moves to that status. A closing keyword
only links.

- Decision: terminal, triage, "none", the per-issue switch and a new
  at_target state (the issue already has the target) come before the PR
  states, so the issue page only speaks when a merge would move the issue.
  The response adds target_status.
- MoveIssueFromPullRequests replaces CompleteIssueFromPullRequests and
  writes the target status. The target is resolved through the delivery's
  shared catalog read (Resolver.WritableCategory). An archived or unknown
  choice, or a failed read, fails closed to no write.
- Close intent is no longer synced or read. The column stays.
- Migration 551 pins "none" on existing workspaces whose history shows a
  merge never moved every issue: auto-complete switched off, or a linked PR
  whose merges were not all keyword merges. Workspaces with no links, or with
  only keyword merges, keep the Done default. Replays are harmless.
- CLI: `multica issue pr-automation <id> on|off` lets an agent keep one issue
  where it is. The multica-platform skill describes the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(views): choose the status merged PRs move an issue to on the GitHub page (MUL-7726)

- Settings → Integrations → GitHub → Features: "After PRs merge, move the
  issue to" picks Don't change, or a started or done status, custom ones
  included and Blocked excluded. It replaces the read-only auto-complete row.
  The self-hosted Git page shows the same setting. The statuses page drops
  its switch, and a badge on the target status now links to the setting.
- Issue sidebar: the line under the PR list names the target status ("Moves
  to In Review when #19 merges"). It says nothing when the workspace leaves
  status alone, when the issue already has the target, or on an older
  backend's no_close_intent. The per-issue switch reads "Keep status when PRs
  merge" and is hidden when it would change nothing. The menu opens the
  GitHub setting. The link hint only shows while auto-link is on.
- core: derivePRMergeStatus replaces derivePRAutoCompleteEnabled; the
  auto_complete schema adds target_status and the at_target state.
- Copy in 5 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs: describe the workspace-chosen status for merged PRs (MUL-7726)

GitHub integration, self-hosted Git, issues and environment variable pages
(4-5 languages) now say that the workspace picks what a merge does: Done by
default, another started or done status, or no change. They also say that a
closing keyword only links, and how to keep one issue's status. The
troubleshooting entries explain what a missing line under the PR list means.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(pr): keep the merge automation out of agents' instructions (MUL-7726)

Agents don't act on what a merge does to an issue, so they don't need a
command for it or a rule to learn. Drop `multica issue pr-automation`, and
cut the multica-platform skill down to how PRs get linked: no merge rule, no
auto_complete field guide, no "the server moves it to done" note. People
still keep one issue's status from its Pull requests menu, and the docs now
point there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(pr): keep the retired auto-complete switch working for old desktop clients (MUL-7726)

Review of #8862: an admin on a desktop client from before this change can
still flip "Complete issues when their PRs merge". The client saved
pr_auto_complete_enabled = false, and the save looked successful, but the
server only read pr_merge_status, so merges kept moving issues to Done.

- Settings writes: a flip of the retired switch becomes a choice (off →
  none, on → Done). An old client that echoes a chosen target without
  flipping the switch leaves the target alone. Every write mirrors the switch
  from the effective choice (false for none, absent otherwise), so old
  clients show the right state.
- Reads: without pr_merge_status, the retired switch decides (off → none,
  else Done), on the server and in derivePRMergeStatus. This also covers
  settings written by a pod that predates the change during rollout.
- Migration 551 sets the switch off on the workspaces it pins to none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 02:26:24 +08:00
692dd019ac MUL-7713: docs(troubleshooting): explain macOS TCC re-prompt after CLI upgrade (#8843)
* docs(troubleshooting): explain macOS TCC re-prompt after CLI upgrade

The Homebrew CLI is ad-hoc signed and installed under a versioned Cellar
path, so macOS drops the daemon's privacy grants on every upgrade and the
next run that touches a protected folder blocks on an unanswered prompt.
Document the symptom, the cause, and how to re-grant access (en/zh/ja/ko/fr).

Part of #8720

* docs(troubleshooting): scope macOS TCC section to launchd daemons

- Say the hang applies when the dialog names multica (launchd/LaunchAgent
  daemons), instead of stating it for every daemon.
- List all upgrade paths: multica update and the Runtimes page Update button.
- Restart the daemon the way it was started (launchctl kickstart for a
  LaunchAgent), and note auto-update is only on by default for Multica Cloud.
- fr: use "le CLI" / "daemon" like the rest of the page; zh: link text
  matches the Desktop page title.

Part of #8720

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bhjiang@outlook.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 02:11:19 +08:00
pseudoyu 64b06b3de8 fix(telegram): attach the file a reply quotes (#8828)
A reply that quoted a photo, video or file reached the agent without the
file: in a group with an @-mention the quote rendered as
"[empty or non-text message]", and in a private chat, or when replying to
the bot's own message, the quoted message was not rendered at all. Only
the trigger message's own file went through the media resolver.

Telegram delivers the quoted message in reply_to_message with its file
ids, so the adapter now picks that file the way it picks the sender's own,
renders it in the quoted block as its placeholder above the caption, and
carries both files in the raw envelope in body order. The resolver ingests
every file, keys each object by its position as DingTalk and WeCom do, and
tells the sender once when one could not be fetched. A quoted file is
selected context in every chat and whoever sent it, as long as the reply
addresses the bot; quoted text keeps the group-mention rule.

Recent-context entries render a file the same way, so a captioned photo in
the window no longer hides that a photo was there.
2026-09-25 14:29:30 +08:00
Bohan Jiangandmultica-agent 51e95dca2d fix(issues): fit PR auto-complete copy on one line (MUL-7678) (#8797)
* fix(issues): shorten the no-close-keyword hint under the PR list (MUL-7678)

"No PR says “Closes MUL-123”, so merging won’t complete this issue" wrapped
to two lines in the default-width sidebar. Drop the trailing clause: the
line sits in the auto-complete slot under the PR list, and "Closes" is the
GitHub keyword developers already read as "completes on merge". The keyword
and identifier, the part a reader acts on, stay.

Same cut in all five locales. The docs already describe this line as "no
PR says `Closes MUL-123`", so they need no change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): lead the no-close-keyword hint with the outcome (MUL-7678)

Review of #8797: the line sits in the auto-complete status slot next to
"Completes when #19 merges", and when it shows the PR is usually one click
from merging. Dropping "merging won't complete" left the fix but not the
warning. Put the outcome first and keep the keyword:

  Won't complete: no "Closes MUL-123"

Measured with Inter at 12px in Chromium: 229px (235px for a five-digit
number), one line in the 260px text column of the default 320px sidebar.
"Won't complete on merge: ..." measured 285px and wrapped. zh, ja and ko
use the same terse shape and also fit; fr takes two lines.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): fit the PR auto-complete menu toggle on one line (MUL-7678)

"Don't auto-complete this issue" measures 204px at 14px Inter, over the
198px text column of the w-60 menu, so it wrapped. Use "Turn off
auto-complete" / "Turn on auto-complete" (154px / 152px): same verbs as
the "Auto-complete is off for this issue · Turn on" status line, and the
menu already belongs to this issue's Pull requests block. fr wrapped too
and gets the same shape; zh, ja and ko already fit and stay.

The docs path to the toggle is updated to the new label.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-24 16:19:57 +08:00
Bohan Jiangandmultica-agent 0da0b46bbf MUL-7672: only a closing keyword completes an issue on PR merge (#8794)
* feat(pr): only a closing keyword completes an issue on merge (MUL-7672)

#8758 made every linked PR count toward auto-complete, so a title-only
"MUL-123: ..." PR moved its issue to Done on merge. Restore the earlier
contract: a PR completes its issue only when its title or body puts a
closing keyword (Closes/Fixes/Resolves) right before the identifier.

- Body closing keywords link again; a bare body mention still links nothing.
- close_intent is recorded on the link and follows the PR text until the
  merge/close event, then freezes; a link first made after merge carries none.
- The decision requires every linked PR merged and at least one with close
  intent; a new no_close_intent state explains why a merge won't complete.
- Everything else from #8758 stays: manual link/remove, per-issue and
  workspace switches, edge-triggered evaluation, the result line.
- Docs (4 languages), UI copy (5 languages) and the multica-platform skill
  describe the keyword rule again.

Co-authored-by: multica-agent <github@multica.ai>

* fix(pr): sync close intent on every link of the PR (MUL-7672)

Review of #8794 found two ways a withdrawn closing keyword still completed
the issue:

- close_intent was refreshed only for identifiers the PR still claimed, so
  a manual link, or an automatic link whose merge event (carrying the final
  text) arrived before the edit event, kept a stale true.
- the refresh sat behind the auto-link toggle, so turning auto-link off
  froze whatever intent was recorded.

Replace the per-link update with one PR-wide sync: until the merge/close
event, every link of the PR (automatic or manual) gets close_intent exactly
when the current title/body closes its issue. It runs whenever GitHub
features are on, independent of auto-link, which now only decides which
links are created.

Co-authored-by: multica-agent <github@multica.ai>

* fix(pr): decide close intent apart from auto-link ownership (MUL-7672)

Re-review of #8794: the PR-wide close intent sync reused the auto-link
verdict, and resolvePRLinkPolicy skips workspaces with auto-link off. With
an installation bound to several workspaces, turning auto-link off in one
of them therefore cleared a closing keyword only that workspace resolves,
and the merge no longer completed the issue.

The policy now reads every bound workspace with GitHub on, and records
the identifier's sole resolver next to the auto-linking owner. Links still
follow the owner (auto-link off workspaces are not competing claimants);
close intent is permitted for the owner or the sole resolver, so an
identifier that resolves in more than one workspace stays withheld.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-24 14:56:59 +08:00
Bohan Jiangandmultica-agent 9c69661f7f feat(web): licensing FAQ, privacy policy, About team section, source-available wording (MUL-7558) (#8643)
* docs(license): name Index Labs (Hong Kong) Limited as the producer (MUL-7558)

The LICENSE and NOTICE credited "Multica, Inc.", which is not a registered
entity. Replace it with the company that holds the rights, define "the
producer" (used throughout Part I but never defined), and point to where
commercial licenses and branding waivers are requested.

Co-authored-by: multica-agent <github@multica.ai>

* feat(web): add licensing and privacy pages, describe Multica as source-available (MUL-7558)

- /licensing: plain-language licensing FAQ with the rule of thumb and common
  scenarios, in en/zh/ja/ko.
- /privacy: privacy policy based on what Multica Cloud actually collects;
  the Contact Sales privacy link now points here instead of /about.
- About: new "Who's behind Multica" section (team story + contact routes;
  team member cards left for later).
- Replace "open source" / "fully open source" with source-available
  wording across the landing copy, page metadata, JSON-LD, docs, READMEs,
  and the Helper agent prompt, since the license restricts hosted use.
- Contact Sales consent copy says "Multica" instead of "Multica, Inc.".
- Reserve the "licensing" workspace slug for the new top-level route.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): drop the Slack users section from the licensing page (MUL-7558)

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): add the commercial-use FAQ in ja/ko and sharpen the source block headline (MUL-7558)

- ja and ko override faq.items wholesale, so the new "Can I use Multica
  commercially?" entry (the FAQ's only link to /licensing) was missing
  there. Add it, plus a test that keeps every locale's FAQ in step.
- The source block headline now states the value ("Every line, on your
  terms.") instead of repeating the license category.
- Privacy policy: list apps connected through Composio among the
  integrations that receive data.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): align the privacy policy with what the product actually does (MUL-7558)

Privacy review found promises the code does not keep:

- Workspace deletion removes content from the service, but uploaded files
  are not yet erased from object storage and backups keep copies. Say so,
  and give an email route for erasing files.
- AI: coding agents send prompts, code, and tool results to their own model
  providers; only the Cloud assist features use a provider we pick. Scope
  the training promise to Multica.
- Crash reports: redaction filters recognizable emails and credentials in
  the error message only, not every field.
- Self-hosted: list the snapshot fields (including the random deployment
  ID), and note that AI, integrations, and analytics follow the operator's
  configuration.
- Sharing: cover workspace members, admins, and authorized agents; move
  legal and M&A disclosures out of the service provider list.
- Add legal bases, consent withdrawal, and the right to complain, plus
  retention for billing records and analytics.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): build trust-page test dictionaries through createLandingDict (MUL-7558)

main now passes a docs href to each landing dictionary factory, so the
test's single-argument calls failed typecheck. Build the dictionaries the
way the app does, mark the DOM-free test as node, and bring the new French
mobile-app doc in line with the source-available wording.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): stop overpromising on data handling and align trust copy with product terms (MUL-7558)

- The commercial-use FAQ now names both license triggers in every locale
  (offering Multica to outside users, and embedding it in a product you sell
  or distribute); ja and ko read as hosted-only before.
- Replace "your data never leaves your network" and "code never passes
  through Multica servers" with what actually happens: agents run on your
  machines, workspace content is stored by Multica, coding tools send
  prompts to their model providers, and self-hosting keeps workspace data on
  your servers. Links to the privacy policy.
- New and changed copy follows the terminology conventions: issue ->
  任务/タスク/태스크, agent run -> 运行/実行/실행 (Run in English),
  workspace -> 工作区, onboarding -> 上手引导.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-24 14:25:49 +08:00
pseudoyu 554937f36b docs(telegram): update French pages for photo, video and file support (#8790)
The French pages from #8757 were translated before #8727 landed, so both
still said the Telegram bot only accepts text. Translate the current English
passages, reusing the terms the French pages already use.
2026-09-24 13:08:11 +08:00
1a09c65b1a feat(llm): support MULTICA_LLM_DISABLE_THINKING env (MUL-7162) (#8657)
* feat(llm): support MULTICA_LLM_DISABLE_THINKING env

Co-authored-by: multica-agent <github@multica.ai>

* docs(llm): scope reasoning_effort note to quick actions

The disable-thinking docs claimed GPT-5.6-family models already get reasoning_effort=none from the server and do not need the new switch. That is only true for quick actions (GenerateJSON); chat auto-titling goes through GenerateText, which sets no reasoning field. Narrow the wording in .env.example and all four language docs so the translations stay in sync.

Co-authored-by: multica-agent <github@multica.ai>

* docs(llm): scope the disable-thinking note to accepting upstreams

The previous wording called the switch "the only way" to turn off auto-titling
reasoning, but on a standard OpenAI endpoint the switch adds chat_template_kwargs
to the title request and the upstream rejects it, so titles fail instead of
skipping thinking. Drop the "only way" claim and name the feature the way the
list below does (follow-up questions).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: zhudejun1 <zhudejun1@huya.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Agent <agent@multica.local>
2026-09-24 13:07:25 +08:00
Bing Free d5a25d7438 MUL-7608 feat(mobile): add Simplified Chinese localization (#8702)
Add a standalone Simplified Chinese UI to the mobile app, with a
Settings → Language picker (Follow system / English / 简体中文) whose
manual choice persists across restarts. Mobile keeps its own i18next
resources rather than reusing web/desktop copy, following the
conventions.mdx glossary: issue statuses and categories stay lowercase
English identifiers, Squad is 小队.

- Failure reasons: REASONS now mirrors web's REASON_LABEL, and a drift
  test pins it to the en/zh-Hans failure_reason keys.
- Production env: .env.production stays committed with the public
  multica.ai defaults; the *:prod scripts layer a gitignored
  .env.production.local on top for self-hosting or a custom bundle ID.
- Duplicate-mark activity rows from #8741 are localized too.
2026-09-24 13:07:10 +08:00
Bohan Jiangandmultica-agent 4f5fbc9216 fix(vcs): explain untrusted TLS certificates and trust private CAs via Helm (MUL-7639) (#8761)
* fix(vcs): report untrusted provider TLS certificates on connect (MUL-7639)

ConnectVCS reported every non-token failure as "could not reach the
provider instance" and logged nothing, so a Gitea behind a private CA
looked like a network problem. Log the underlying validation error and
tell certificate failures (untrusted CA, host name mismatch, other
verification failures such as expiry) apart from unreachable instances.
Status codes are unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* feat(helm): trust extra CA certificates in the backend (MUL-7639)

backend.extraCACerts.configMap mounts an existing ConfigMap of PEM
certificates read-only and points SSL_CERT_DIR at the system directory
plus the mount, so the backend trusts an internal CA without dropping
public CAs or disabling TLS verification. Unset renders unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* docs(self-host): document trusting a private CA (MUL-7639)

Co-authored-by: multica-agent <github@multica.ai>

* fix(helm): render when values predate extraCACerts (MUL-7639)

helm upgrade --reuse-values from an older chart carries no
backend.extraCACerts key, and reading .configMap on it failed the whole
render with a nil pointer even when no CA was wanted. Fall back to an
empty dict, and cover the missing-key, default and enabled renders in
the chart test.

Co-authored-by: multica-agent <github@multica.ai>

* docs(self-host): restart Compose backend after replacing a CA (MUL-7639)

up -d keeps the running container when only a mounted CA file changed,
so the backend kept its old trust store. Use restart instead.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-24 13:04:01 +08:00
pseudoyuandClaude Fable 5.1 62efe26aa0 MUL-7620 feat(telegram): send and receive photos, videos and files (#8727)
* feat(telegram): send and receive photos, videos and files

Telegram was text-only in both directions: dispatch dropped every non-text
update with an "unsupported" notice, the outbound subscriber only knew
sendMessage/editMessageText, and the channel was never declared to
DeclareChannelFileDelivery, so agents were told they cannot attach files.

Inbound: photos, documents, video, video notes, animations, audio and voice
notes become chat attachments through the engine's MediaResolver seam
(getFile, file host download, object storage, intent-ledger row before the
PUT, as Slack does). The caption is the message text and a placeholder marker
leads the sender's own text; the engine swaps it for the attachment link once
the bind commits. Quoted and recent group context still go in front of it.
Stickers and other non-file kinds keep the unsupported notice.

Outbound: files the agent bound to its reply are sent into the chat as their
own messages once the text settles (sendPhoto for common images up to 10 MB,
sendVideo/sendAudio for mp4 and common audio, sendDocument for the rest up to
50 MB). A photo Telegram refuses to process is resent as a document.

Both halves hang off the same store != nil branch that declares file
delivery, so the agent is promised the hop only where it exists. Compared
with the WeCom reference this drops relay routing, the three-state delivery
classification, per-reply metrics and the pending/admitted counters: Telegram
outbound is stateless HTTP and the delivery lease already guarantees a single
sender per turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(telegram): address media review — deliver files once, admit before spawning, keep the notice without storage

Review on #8727 raised five points; each lands here with a test.

1. A file-only reply could be delivered twice. closeTurn returned true
   whether this call ended the turn, a deeper attempt owned it, or it was
   settled already, and the empty-reply branch delivered files on all three.
   closeTurn now reports closedNow / closedAlready / closeHeld, and files go
   out only on closedNow. Regression tests: a replayed chat:done on another
   replica sends the files once; a completion an automatic retry has
   superseded sends nothing and the retry's answer still lands.

2. Admission sat behind the goroutine and the lookup. The attachment lookup
   now runs on the terminal worker, so a reply with nothing bound costs one
   indexed read and no goroutine; a delivery is spawned only for files known
   to exist, under a slot claimed first (non-blocking, four slots). Every
   slot busy sheds the reply with the notice instead of parking it.

3. Rebased on main over MUL-7585: recent group context, /new isolation and
   the privacy rule are kept; unaddressed group media is buffered and never a
   turn, as before; the media placeholder leads the sender's own text with
   quoted and recent context still in front.

4. Without object storage the member got no signal. The polling loop now
   knows whether the deployment stores media (ChannelDeps.AcceptsMedia, set
   from the same store != nil branch as the resolver) and keeps the old
   unsupported notice when it does not. A fetch that fails — over the 20 MB
   bot download limit, or a failed download — tells the sender so, instead of
   leaving the agent a placeholder nothing stands behind.

5. The failure notice claimed more than the code knew: a lost response may
   mean Telegram accepted the file. It now says delivery could not be
   confirmed and that the file stays attached in Multica, which holds either
   way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(telegram): offset the media placeholder past prepended context; report a failed attachment lookup

Second review round on #8727.

1. With quoted or recent group context ahead of the sender's text, the
   MediaRef carried only the placeholder, so the engine replaced occurrence
   0: a member who typed "[Image]" in the window received the sender's
   file and the sender's own marker stayed bare. inboundMedia now carries
   PlaceholderIndex — the enrichers only prepend, so it is the count of the
   marker ahead of the sender's segment, literals included (the DingTalk
   rule) — and the resolver sets it as InlineIndex. Regression tests cover
   the recent-context and the quoted-message paths.

2. A failed ListAttachmentsByChatMessage dropped the files silently while
   the text already on screen referred to them. The read has no side
   effects, so it is retried three times 250 ms apart; if it still fails
   the member gets a notice worded for what is known — whether the reply
   had files could not be checked — never the one that presumes a file
   existed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:41:47 +08:00
9df0cae78a MUL-7429: complete issues when every linked PR merges (#8758)
* feat(pr): complete issues when every linked PR merges (MUL-7429)

Title/branch identifiers link a PR; keywords and the body no longer matter.
Completion is evaluated only on a PR event for the issue (merge, link,
unlink), so settings changes and reopening never complete an issue.
Adds manual link/unlink with remembered exclusions and a per-issue
auto-complete switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(pr): issue-page PR automation and single auto-complete setting (MUL-7429)

The issue's Pull requests section says what the merge rule will do next,
links a PR by URL, removes one per row, and turns auto-complete off for
that issue. The workspace switch lives under Issue statuses; the GitHub
page reports it. Agent skill and docs describe the title/branch rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(pr): accept PR links pasted without a scheme (MUL-7429)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(skills): pin the PR auto-complete contract in the platform skill (MUL-7429)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(pr): re-check linked PRs at the completion write; skip stale GitHub events (MUL-7429)

The status write now requires every linked PR to still be merged when it
runs, so a PR linked between the decision and the write keeps the issue
open. GitHub PR upserts ignore events older than the stored row, like the
self-hosted path, so a late delivery cannot roll a merged PR back to open
and turn a redelivered merge into a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-24 02:58:19 +08:00
Bohan Jiangandmultica-agent 920c3ea6d9 feat(docs): add French documentation aligned with UI terminology (MUL-7637) (#8757)
* docs(i18n): translate documentation corpus to French (MUL-7637)

Add French (.fr.mdx) translations for all 45 docs pages plus meta.fr.json
navigation, mirroring the English source. Product and UI terms follow the
in-app French locale at packages/views/locales/fr/ (issue -> tâche,
run -> exécution, autopilot -> automatisation, Chat -> Discussion,
Settings -> Paramètres, etc.). Status and role identifiers stay lowercase
English per the conventions glossary, with the French UI label shown once
where they are defined.

Co-authored-by: multica-agent <github@multica.ai>

* feat(docs): serve French docs locale (MUL-7637)

Register fr in the docs i18n config and add the French Fumadocs chrome,
language-switcher label, and home hero copy. Fumadocs maps fr to Orama's
built-in French tokenizer, so search needs no custom localeMap entry.

Co-authored-by: multica-agent <github@multica.ai>

* feat(i18n): link French UI to French docs (MUL-7637)

Route French users from the landing page, runtime guides, the autopilot
webhook filter help, and the Slack/Lark/DingTalk/Telegram setup links to
/docs/fr. The four integration tabs and the runtime helper each carried a
copy of the same locale-prefix ternary; they now share docsLocalePrefix.

Co-authored-by: multica-agent <github@multica.ai>

* fix(i18n): localize remaining docs entry points (MUL-7637)

French users still reached English docs from the Help menu, the Agents and
Skills "Learn more" links, and the landing footer. The views links now use
docsLocalePrefix; the landing dictionaries take the docs href from
docsHrefForLocale, so locales that reuse another dictionary (French reuses
English) still link to their own docs.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-23 18:30:31 +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 <github@multica.ai>
2026-09-23 16:45:36 +08:00
Bohan Jiangandmultica-agent 9b8da372cc MUL-7450 fix(lark): surface the event-delivery check a silent Bot needs (#8735)
* fix(lark): surface the event-delivery check a silent Bot needs

A Feishu app whose event subscription delivers to a request URL instead
of the long connection binds successfully, shows Connected everywhere,
and never receives a message. Multica has no webhook ingress, so those
events go nowhere near us. Nothing in the product said so: the drop path
in the connector writes no log line, an inbound audit row is only reached
once a message enters the Router, and the troubleshooting docs listed
only the causes this case is not.

Three pointers, no behaviour change:

- The connector logs the event type when the decoder declines a frame,
  so a socket that is up but only receiving events we do not handle is
  distinguishable from a healthy one. Heartbeats carry no event type and
  stay silent, so the log does not fill with noise.
- The connected badge on the agent's Integrations tab names the long
  connection and links to the guide. That is where someone looks when
  the Bot stays quiet; the install dialog closes itself on success.
- The integration guide's troubleshooting list gains the subscription
  mode and event subscription, in all four locales.

Refs #8496

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): report a dropped event type once per connection

Review on #8735: one Info line per declined frame is unbounded. An app
that subscribes to reactions or membership churn delivers those
continuously, and there are thousands of Feishu installations.

Report each event type once per connection instead. The map is
read-loop-local, so it needs no lock and a reconnect re-reports. What
the line is worth stays the same: it names what IS arriving on a socket
whose drops leave no other trace. It says nothing about what is missing
— an app delivering to a request URL sends no frames at all, so it
produces no line, which is why install-time verification is the actual
fix for #8496 rather than this.

Also drops apps/web/next-env.d.ts from the branch: Next regenerated it
during a typecheck run and it has nothing to do with this change.

Refs #8496

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-09-23 15:25:45 +08:00
Francisco Guimarães (chico)andmultica-agent 5403083420 MUL-7607 fix(auth): restore invitation-based signup (#8706)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-23 14:33:43 +08:00
pseudoyuandClaude Fable 5.1 d31fcbc9b7 MUL-7585 feat(telegram): inline recent group context on @mentions (#8673)
* feat(telegram): inline recent group context on @mentions

When a member @-mentions the bot in a group, the agent only ever saw that
single line. Lark solves this with a <recent_context> prefetch
(lark/inbound_enricher.go, MUL-3084); Telegram could not copy it because
the Bot API has no history endpoint — getUpdates is consume-once.

Each installation's polling loop now keeps an in-memory ring of the last
DefaultRecentContextSize (10) human group messages per chat and per forum
topic, and inlines that window as a <recent_context> block ahead of an
addressed group message, in the same recent → quoted → own composition
Lark uses. The trigger and an explicitly quoted parent are excluded from
the window; /new starts a chat without ambient context; p2p chats and
unaddressed messages are never enriched. Unaddressed chatter is still
never persisted or turned into a session (MUL-2671): it only surfaces as
read-context on a turn a member directed at the bot, and the ring dies
with the polling loop.

The docs now say what the window contains and that Group Privacy must be
off in @BotFather for the bot to receive the surrounding messages at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(telegram): state the recent-context retention and privacy contract accurately

Review follow-up for #8673. Setup step 4 now presents Group Privacy on as
the default, minimal-visibility choice and turning it off as the optional
step that enables recent group context, with its cost spelled out. The
Groups section no longer claims unaddressed chatter is "never stored": it
says the message is held in a short in-memory buffer and saved with any
addressed turn that pulls it in, that a reply to the bot triggers the
context just like an @-mention, what Telegram delivers under each privacy
setting, and that an admin bot receives everything regardless. Same fix in
zh/ja/ko. The two code comments that relied on privacy mode being on, or
claimed the message was never persisted, now describe the real contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:22:48 +08:00
Multica Eveandmultica-agent 8c9f865e3e MUL-7480: Revert comment steering feature (#8648)
* Revert "MUL-7480: Link delivered steers to final replies (#8623)"

This reverts commit 3de6275eb3.

Co-authored-by: multica-agent <github@multica.ai>

* Revert "fix: preserve active task intent in steer frames (#8621)"

This reverts commit b866dacd1c.

Co-authored-by: multica-agent <github@multica.ai>

* Revert "MUL-7480: Steer active agent turns from comments (#8605)"

This reverts commit 8ce795b007.

Co-authored-by: multica-agent <github@multica.ai>

* chore(db): remove reverted comment steer schema

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Sol-Boy <github@multica.ai>
2026-09-21 18:29:50 +08:00
3de6275eb3 MUL-7480: Link delivered steers to final replies (#8623)
* feat: link steer receipts to final replies

Co-authored-by: multica-agent <github@multica.ai>

* fix: keep steer final reply locators stable

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Forge-Boy <forge-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-21 15:04:17 +08:00
8ce795b007 MUL-7480: Steer active agent turns from comments (#8605)
* feat: steer active agent turns from comments (MUL-7480)

Co-authored-by: multica-agent <github@multica.ai>

* fix: make comment steering hint driven

Co-authored-by: multica-agent <github@multica.ai>

* fix: harden active comment steering races

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Forge-Boy <forge-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-20 19:00:53 +08:00
6b69b3710c Revert "MUL-7517: clarify squad leader identity framing (#8597)" (#8600)
This reverts commit a51fd83072.

Co-authored-by: Forge-Boy <forge-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-20 14:57:49 +08:00
a51fd83072 MUL-7517: clarify squad leader identity framing (#8597)
* fix(squads): clarify leader identity framing (MUL-7517)

Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): address briefing review feedback (MUL-7517)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Forge-Boy <forge-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-20 14:48:17 +08:00
2e728fc6de MUL-7358 fix(agent): require OpenCode >= 1.1.54 so pre-fix builds cannot fill the host disk (#8510)
* fix(agent): require OpenCode >= 1.1.54 so old builds cannot fill the host disk

OpenCode <= 1.1.53 ignores TMPDIR, TMP and TEMP when its embedded Bun runtime
extracts a native module, writing into the shared system temp directory
whatever the daemon exports. Each successful run leaves one 4-8 MB module
behind under a fresh, non-content-addressed name, so nothing ever reuses or
overwrites it. Upstream fixed this in 1.1.54 (2026-02-10).

Reproduced on Linux against pinned builds, verifying each binary's
self-reported version: 1.1.49, 1.1.52 and 1.1.53 write into the shared /tmp
with the three variables pointed elsewhere; 1.1.54 and everything after honour
them. #8392 is what that costs a self-hosted daemon left on a pre-fix build —
~2,960 files, 11.16 GiB, root filesystem at 99%, and no error anywhere until
the disk runs out.

The per-task temp directory cannot contain a CLI that never reads the
variables, and deleting by filename in a shared /tmp is not safe — a module
another live process still has dlopen'ed looks identical. Refusing the CLI at
registration is the only place this can be stopped, and it turns a silent host
outage into "opencode version X is below minimum required 1.1.54 — please
upgrade".

Note this floor is unlike the others in the table: every existing entry names a
protocol the backend speaks through, so an older CLI cannot serve a task at
all. A pre-1.1.54 opencode serves tasks correctly; it damages the host while
doing it. The table comment now says which kind each entry is.

Refs #8392

Co-authored-by: multica-agent <github@multica.ai>

* docs: list the OpenCode minimum runtime version

The install-agent-runtime callout enumerates the per-runtime floors the daemon
enforces at registration; OpenCode now has one, so it belongs in the list.
Updated in all four translations.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-17 15:51:59 +08:00
1fcdff78d0 MUL-7436 feat(auth): slide UI session expiry instead of forcing a re-login every 30 days (#8486)
* feat(auth): slide UI session expiry instead of fixing it at login

A session token's exp was stamped once at login and never moved, so every
continuously active user was logged out 30 days later, always mid-task. The
session now re-issues itself once it drops below half its TTL, turning that
scheduled logout into an idle bound.

Renewal is stateless: it copies the claims of a token we signed and moves
exp, so there is no session table and no DB read on the hot path.

- Browsers are renewed inline by middleware.Auth on safe requests, keeping
  the session HttpOnly — no client change and no multi-tab coordination.
- Clients holding the session as a string (Desktop, mobile) get
  POST /api/auth/refresh, which only ever accepts a UI JWT. PATs, agent task
  tokens and cloud-node PATs are refused: a machine credential must not
  become an interactive session. An expired JWT cannot be revived.
- CSRF tokens are now bound to a new sid claim, stable across renewals, so
  re-issuing the auth cookie no longer invalidates a CSRF token another tab
  already read. The pre-existing binding stays valid so sessions that
  predate this keep working until they renew.
- CloudFront cookies are re-signed alongside a renewal, so the CDN policy
  cannot outlive its session.

The idle bound is one-sided and the comments say so: a session last used
while it still had over half its TTL left is not extended, so the
guaranteed survivable gap is TTL/2, not TTL.

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

* feat(auth): renew the session on Desktop, mobile, and legacy token-mode web

Completes the client half of sliding sessions. Browsers on cookie auth need
nothing — the server re-issues their cookie inline — so this covers the
clients that carry the session as a string and have to persist a new one.

Renewal follows use, not a clock. Checks run at launch, on returning to the
foreground, and on interaction; there is deliberately no interval timer,
because a timer would keep an abandoned but still-open window (or a
backgrounded phone) signed in forever. The server decides whether a session
is renewable and tells the client when to ask again, derived from the
deployment's configured TTL — so no client reads exp, hardcodes a cadence,
or has to reason about clock skew.

Renewal is not a session event, and each step protects that:
- Concurrent triggers collapse into one request, so two valid tokens never
  race to be the one stored. On mobile the single-flight flag is set before
  the first await, since reading the Keychain is itself async.
- A response that lands after logout or an account switch is discarded by
  comparing against the session the attempt started from; writing it back
  would resurrect a session the app has already torn down.
- Offline, 5xx and an unreadable body all keep the current session, which is
  still valid for at least another half TTL. Only a real 401 ends a session,
  and that path is untouched — nothing here can clear tabs or drafts.
- WebSocket clients now read the token at connect time instead of pinning
  the one captured when they were built, so a reconnect after a renewal
  authenticates with the current credential.

Web also retries a rejected CSRF token once. Session-bound CSRF makes that
unreachable in the steady state; it covers the single transition where a
session predating that binding is renewed for the first time.

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

* fix(auth): address review blockers on sliding session renewal

Six fixes from review, each with a regression test that fails without it.

An expired session was answered 403, not 401. The CSRF gate runs before the
JWT is validated, and it read `sid` through the expiry-enforcing parser — so
an expired cookie lost its sid, its perfectly good CSRF token failed to
verify, and the user's first action after expiry was a rejected write instead
of a return to the login page. Reading the session id and deciding access are
now different questions with different parsers: the first only needs the
signature, the second still enforces expiry.

Rolling back was not survivable. A previous release can verify only the
token-bound CSRF binding, so once a session was issued or renewed by this
one, a rollback left every signed-in user able to read and unable to write,
with no client retry able to help. Both bindings are now issued: clients echo
both, the server accepts either, and the token-bound cookie is re-minted on
every renewal so a rollback taken at any moment still finds a usable pair.

Mobile could resurrect a session it had just ended. Keychain deletes are
async, so a renewal that sampled storage after logout began but before the
delete landed would write its replacement back. A session epoch, incremented
synchronously at the start of logout, a 401 teardown and a new sign-in, closes
the window comparing tokens cannot — including the case where logout lands
during the renewal's own write, which is now undone rather than left behind.

Mobile only checked at launch and on foreground, so an app held in the
foreground and used continuously would never check again and could expire
under the user's hands. Every touch now passes through a capture-phase
responder that declines the gesture it observes.

CloudFront cookies were re-signed by a middleware not every renewing route
mounts — the plugin bridge renews without it, and would have slid the session
forward while leaving the CDN policy behind. Re-signing moved next to the
renewal itself, so the two cannot diverge by route.

The renewal check interval could exceed the window it has to land in: a flat
five-minute floor meant any TTL under ten minutes produced a cadence longer
than the renewal window, logging out clients that followed it exactly. The
cadence is now capped at half the threshold, which outranks both clamps.

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

* fix(auth): close the CORS, credential-write and short-TTL gaps

Three blockers from the second review round.

The two CSRF values now travel in one header instead of two. A second header
name would have to be in the server's CORS allowlist, and that list is
version-specific: a browser holding a cookie this release issued, talking to a
rolled-back server, would send a header that server never allowlisted. The
preflight fails, the request never reaches a handler, and no retry helps —
precisely the half-logged-in state the second cookie exists to prevent. With
one header name the server just tries both bindings, and the client switches
to the token-bound value when a server rejects the session-bound one. It
switches back as soon as the cookie changes, keyed on the rejected value
rather than a flag, so it cannot oscillate once per renewal. A test now pins
the CSRF header to the allowlist so a future second header fails here rather
than in production.

Mobile credential writes are serialized, and a stale renewal is refused rather
than undone. Compensating after the fact meant deleting whatever was stored —
which, after a logout and a fresh sign-in, is the new account's token: the app
would be logged in in memory and signed out on the next launch. The check and
the write now happen inside one serialized unit, so a logout or a sign-in
cannot land between them and a refusal writes nothing at all.

AUTH_TOKEN_TTL has a documented one-minute minimum. Clients are told when to
check again in whole seconds, so a shorter TTL asks the wire to carry a
sub-second cadence: it truncates, and below about four seconds it serialises
to 0. Values under the minimum are clamped up with a startup warning rather
than honoured. Mobile's floors were never actually lowered in the previous
commit — 1h/1m, not the 5s/5m claimed — so both clients now floor at 5s, below
anything the server can send, and fall back to 30s, which fits the smallest
supported window. The cadence tests assert the serialised integer a client
actually receives, not the internal time.Duration.

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

* fix(auth): keep a failed renewal cheap and a renewed token shared

Two blockers from the full-PR review, plus the documentation the config
change owes.

A failed check no longer consumes the normal cadence. The two were one
number, so a single 503 spent whatever the server had last suggested — with
the 30-day default that is three days. A client told to check again in three
days on day 12, returning on day 29 with one day of session left and hitting
one 503, would not have been allowed to retry until day 32: two days after the
session it was trying to save had expired. Failures now buy a short backing-off
delay of their own, reset by the next success, so recovery follows the network
rather than the cadence.

The bearer token is read from shared storage per request instead of cached per
client. Desktop runs one ApiClient per window over one localStorage, so a
session renewed in one window left the others sending a credential on its way
out until it expired and took the whole session down. A 401 is also now matched
against the credential that produced it: a request that went out just before a
renewal can be answered after it, and treating that as expiry would tear down a
session that is demonstrably alive, clearing tabs and drafts with it.

Documentation catches up with both the behaviour and the new minimum, in all
four languages: sessions are an idle bound rather than a countdown from
sign-in, the bound is one-sided, and AUTH_TOKEN_TTL clamps up at 60s. The
rationale for that minimum is corrected too — it is the 5s floor every client
applies to the derived cadence, not whole-second serialisation, which only
binds far below it.

Also removes two claims that were not true: that a failed renewal leaves at
least half a TTL (it happens inside the window, so less), and that an open
untouched window never renews (cookie-mode sessions slide on any authenticated
request, background polling included).

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

* fix(auth): back off on a renewal response that carries no cadence

A 200 whose body fails schema validation does not throw — parseWithFallback
returns zeroes — so both clients reset the failure count without moving the
next-check deadline, and every subsequent interaction fired another request at
a server already answering badly. A missing cadence is now treated as a
failure and takes the same short backoff; a token that did arrive is still
applied.

Also settles the documentation wording: the 30 days is not an idle timer.
Renewal only happens past the halfway point, so an unused session can end
sooner than 30 days after its last use, and saying otherwise promised
precision the design does not have.

Refs MUL-7436

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 18:44:59 +08:00
iiwish 91ad871864 MUL-6648: feat(cli): add issue comment update command (#7507)
* feat(cli): add issue comment update command

* fix(cli): require revisions for comment updates
2026-09-16 15:49:06 +08:00
3fe16b8fcb MUL-7422: paginate archived inbox and load it on demand (#8471)
* fix(inbox): paginate archived notifications on demand

Co-authored-by: multica-agent <github@multica.ai>

* fix(inbox): isolate archived deep-link lookup states

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 13:47:30 +08:00
96aa80ff9e MUL-7348: simplify redundant UI descriptions across product surfaces (#8393)
* refactor(ui): remove redundant descriptions across product surfaces

Implement MUL-7348 copy audit across shared views, Desktop, Mobile, and contact sales. Keep actionable constraints visible and move advanced explanations into explicit help. Update all four locales and document default-omit copy rules.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): preserve constraints and improve contextual copy help

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 03:23:03 +08:00
5f52380439 feat(i18n): add French locale (#7141)
* feat(i18n): add French locale

Adds `fr` as a supported UI locale for web and desktop.

- 25 translated resource files under `packages/views/locales/fr/`, at full
  parity with the English source (`locales/parity.test.ts` passes).
- Registers the locale in `SUPPORTED_LOCALES`, the views resource bundle, the
  settings language picker, and the `HTML_LANG` maps for web and desktop.
- Translates the celestial workspace name list. Proper nouns with no
  established French form are left unchanged.
- Landing page: adds the `FR` label but leaves the public `locales` array at
  four, since the marketing copy is not translated.
- Onboarding templates map `fr` to English content, matching the existing
  fallback for locales without authored templates.
- `pick-locale.test.ts` used `fr` as its example of an unsupported locale;
  those assertions now use `es`, plus a new case asserting `fr-FR` resolves
  to `fr`.

Terminology follows the glossary in
`apps/docs/content/docs/developers/conventions.mdx`. The UI uses the formal
register (vouvoiement) consistently.

* fix(i18n): translate the backlog status label in French

The French locale translated six of the seven issue statuses and left
`backlog` as "Backlog". The other locales translate all seven — `待规划`,
`백로그`, `バックログ` — so the odd one out was an oversight, not a choice.

`conventions.mdx` does say role and status enums stay in lowercase English,
but that rule is about surfacing the identifier inside prose ("switched to
`in_progress`"), not about the `status` display block, which every shipped
locale translates.

"À planifier" reads as a pair with "À faire" for `todo`, and matches what
`待规划` conveys: the issue exists but is not scheduled yet.

* fix(i18n): realign the French locale with main

Rebasing on main surfaced two drifts that the parity test caught:

- `settings.json` gained `shortcuts.fixed.open_settings` upstream; added
  the French string.
- `search.json` lost its `pages.*` block in #7054, which now generates the
  command palette's Pages group from the nav registry. Dropped the eight
  French keys that no longer have an English counterpart.

* fix(i18n): resync the French locale with main

157 English keys had no French translation and 97 French keys no longer had an
English counterpart. The parity suite caught all of them.

Added: custom issue statuses (76 keys), the Telegram integration (55), issue
revision conflicts and the status catalogue (20), plugin panels and hook
activity (24), revoked agent access in chat (4), autopilot run quota and the
worktree execution mode (2).

Removed: 97 keys upstream has dropped or renamed, most of them under plugins.*.

Two shape changes worth naming, because a key count does not show them:

- plugins.install used to be the string "Install" and is now an object with
  title/description/placeholder/review. A locale that keeps the old shape does
  not have a missing key, it has a key of the wrong TYPE, and parity alone
  stays silent about it — only writing to it fails.
- telegram_bind mirrors slack_bind key for key, so it is derived from the
  existing translation with the service name substituted rather than written
  again. Two texts that say the same thing drift when they are typed twice.

Register: vouvoiement throughout, consistent with the rest of the locale.

* fix(i18n): complete French locale integration and refresh translations

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 02:52:49 +08:00
klinsmaya 904693bed9 MUL-7336: feat(labels): add skill label CLI commands and skills list label filtering (#8368)
Brings Skill labels to the CLI and makes them usable for finding skills in the web app.

- `multica label` becomes resource-type aware (`--resource-type issue|skill`, `--description`), and `multica skill label list/add/remove` manages labels on a specific skill. Short IDs now resolve against the right catalog.
- The skills page toolbar gains a Labels filter; `GET /api/skills` bulk-attaches labels so the list does not go N+1.
- Agent labels are deliberately excluded, keeping the decision from #6245.
- CLI docs updated in all four locales.
2026-09-16 00:20:42 +08:00
Bohan Jiang d665f0ac60 MUL-7379 docs: retranslate the issue status model and correct category-wide behavior claims (#8438)
Documentation half of MUL-7379, after #8435 fixed the code. The stale pages did
not merely use outdated wording — they promised behavior the platform stopped
providing, in the three languages nobody re-read.

zh/ja/ko issues.mdx were left on the retired seven-category inheritance model
when MUL-7240 rewrote the English page. Each still said a new status "inherits
that category's behavior in full", with a seven-row table promising that an
in_review-category status finishes the autopilot run and auto-archives past
failure notices, an in_progress-category status rolls back to todo on failure,
and a backlog-category status parks — plus the worked example naming
`Code Review`. An admin following the Chinese page would create that status and
their autopilot runs would silently never finish. All three now mirror the
English source, including the installed-client wire-enum paragraph they lacked
entirely; ja and ko also gained the agent status-writing paragraph only en and
zh carried. Status keys and category names stay lowercase English per the i18n
glossary, which already listed the four values correctly.

assigning-issues (all four locales) said only the `backlog` CATEGORY parks and
a custom status in it behaves identically. WillEnqueueRun parks on the literal
key, so a reader following that page parks work on their own unstarted status
and gets exactly the run the page promises they will not. The same sentence
named a `cancelled` category, which is now `closed`.

inbox (all four locales) attributed failure-notice archiving to an `in_review`
category that does not exist; it now states the handoff rule #8435 implemented,
including why blocked is excluded.

The Korean settings subtitle said five lifecycle categories directly above the
four groups. CLI_AND_DAEMON.md presented the seven built-ins as the complete
enum while validateIssueStatus is deliberately shape-only.

One behavioral change, in its own commit with its test: the agent brief rendered
each lifecycle group as "- `unstarted`: `backlog`, `todo` (built-in)". That
leading backticked token used to always be a valid status key, so sharing the
formatting was harmless; `unstarted`, `started` and `closed` are now
category-only names that Resolve refuses, so the one line teaching agents how to
write status invited `multica issue status <id> started` — a 400. The category
label is now plain text and only settable keys keep backticks.

Review caught two overclaims, both corrected: filtering resolves on the concrete
status like boards, lists and sorting do, not on the category; and `done` is
both a lifecycle category and a built-in key, so it is not among the names
Resolve refuses.
2026-09-15 15:25:20 +08:00
dee1ed6bf9 MUL-7236: add anonymous self-host telemetry (#8252)
* MUL-7236 add anonymous self-host telemetry

Co-authored-by: multica-agent <github@multica.ai>

* fix(telemetry): apply review feedback

Co-authored-by: multica-agent <github@multica.ai>

* fix(telemetry): address review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Sol-Boy <sol-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-15 15:21:11 +08:00
Jiayuan Zhang 47054a7b6f docs: unify and simplify coding agent instructions (#8404) 2026-09-14 18:49:35 +08:00
YYClaw 8bf525eb2d MUL-7106 feat(dingtalk): add contextual replies and task lifecycle reactions (#8125)
* feat(dingtalk): add quoted replies and task reactions

Read group reply quotes from task-owned sealed input and render internal
attachment images as placeholders. Decode supported quoted rich-text nodes
without changing current-message command handling.

Track process-local reaction anchors, keep the latest input in each batch,
and retire owned inputs on task termination or local agent archive. Preserve
command success markers and workspace issue links. Keep shared engine,
query and schema behavior unchanged, with best-effort failure cleanup.

* docs(dingtalk): document quoted replies and message feedback
2026-09-14 14:48:10 +08:00
49cd223046 MUL-7339: fix(comments): stop rendering a placeholder for a deleted reply (#8371)
* fix(comments): stop rendering a placeholder for a deleted reply

A comment deleted while it still had replies keeps its row as a tombstone
so those replies keep a direct parent (#8296), and every client rendered
that row as a "This comment was deleted" line. A thread renders its
replies flat, in chronological order, so the tombstone carried no
structure the line could explain — it was a row that said nothing.

Deleted replies now render nothing at all: no row, no divider, and no
place in the reply or folded counts. Their own replies keep rendering
where they were (a tombstone always has a live descendant, since the
server prunes one that loses its last reply), and runs anchored to a
tombstone still render, so a deleted agent trigger never takes the
agent's run block or its published reply with it.

A deleted thread ROOT keeps its placeholder: it heads the thread, and
hiding it would leave its replies with nothing above them.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): land a deep link above a deleted reply instead of nowhere

A tombstone reply renders nothing as of the previous commit, so an inbox
notification or a share link naming one had no DOM anchor to scroll to
and no row to flash: the landing effect looked up an element that was no
longer there, returned, and left the user wherever the timeline opened.

The landing now resolves to the comment above where the tombstone was —
the nearest visible reply before it, or the thread root when it had none.
The original comment id stays the notification's identity: the landing
guards and the memento still key off it, so a repeat visit is still
recognized as the same landing.

Mobile applies the same rule to its flash target. The matrix lives in
comment-deletion.test.ts; issue-detail.test.tsx covers the wiring.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 14:30:43 +08:00
7dafc0cd62 MUL-7240: Unify issue lifecycle into four stored categories (#8261)
* feat: simplify issue status categories

Co-authored-by: multica-agent <github@multica.ai>

* feat: adopt four stored issue lifecycle categories

Co-authored-by: multica-agent <github@multica.ai>

* fix: preserve visible work when upgrading saved category preferences

Co-authored-by: multica-agent <github@multica.ai>

* docs: align lifecycle comments with stored and wire contracts

Co-authored-by: multica-agent <github@multica.ai>

* test: align mobile grouping expectations with four categories

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): group user views by concrete status keys

Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): simplify status editor copy and category selector

Co-authored-by: multica-agent <github@multica.ai>

* feat(statuses): allow ordering built-ins and unify row actions

Co-authored-by: multica-agent <github@multica.ai>

* feat(statuses): configure custom status icons independently of category

Co-authored-by: multica-agent <github@multica.ai>

* fix(statuses): dismiss built-in status notice

Co-authored-by: multica-agent <github@multica.ai>

* fix(issue-status): require moving issues before archive and polish controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(ci): renumber issue status migrations after main updates

Co-authored-by: multica-agent <github@multica.ai>

* fix(statuses): isolate archive inspection and align catalog behavior

Co-authored-by: multica-agent <github@multica.ai>

* fix(statuses): respect injectable catalog for table grouping

Co-authored-by: multica-agent <github@multica.ai>

* refactor(statuses): simplify built-in skill and runtime guidance

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 12:57:24 +08:00
Bohan Jiang a9e82c7973 MUL-7282: fix(comments): delete only the selected comment and keep its replies (#8323)
Deleting a comment removed every reply under it, through the parent_id ON DELETE CASCADE. Now delete removes only that comment: one with replies becomes a tombstone (deleted_at set, body/attachments/reactions/resolution cleared) so the replies keep their parent, and one without replies is removed along with any tombstone ancestor left empty.

Tombstones are read-only and are never agent input. Every write to a comment's children takes its owners first (issue -> comment -> child), and a comment commits together with the attachments it was posted with. Clients only promise that replies are kept when the server declares comment_delete_keep_replies_supported, and then delete through DELETE /api/comments/{id}/keep-replies, which older servers do not route.
2026-09-13 19:13:43 +08:00
Yushan 3551e72e76 MUL-7232 feat(daemon): add dsh desktop(self-hosted) support (#8237)
* feat(daemon): discover the DSH CLI bundled in DeepSeek Harness Desktop

DeepSeek Harness Desktop ships its CLI inside the .app and never installs
`dsh` onto PATH, so a GUI-launched daemon missed it on exec.LookPath — and the
login-shell sweep could not rescue it either, because no rc file has a reason
to know that path. Only a hand-made symlink made dsh visible at all.

Add the app-bundle fallback the Codex provider has had since #5205. The
candidate is a Node script rather than a native binary, so it counts only while
it is executable: an entry discovered without the executable bit would be
advertised as a healthy runtime and then fail on every spawn.

The Multica runtime profile gate still applies — a bundled CLI without the
profile is not a runtime.

* fix(daemon): report and install the missing DSH runtime profile

probeAgentCLIs is documented as pure discovery, but dsh's runtime-profile gate
lived inside it — one layer above the verdict machinery that turns a drop into
a /health skipped_agents reason and a daemon-log line. A dsh binary without the
profile therefore vanished from the runtime list with nothing anywhere saying
why, which is the shape the DSH reports in #7721 and #6936 run into.

Move the gate down to probeBuiltinRuntime and give it its own verdict: dsh is
now discovered like any other CLI, and failing to use it is a reported,
actionable drop. The verdict is deterministic, so it demotes immediately rather
than waiting out a confirmation window — it is a fact about the CLI's
environment, not about bytes mid-write. The executable is checked first,
because probeDshMulticaProfile cannot tell "the profile refused" from "there
was nothing to run", and would otherwise blame a missing profile for a path
that vanished under us.

Reporting is only half of it: DSH is the one built-in whose CLI is not enough
on its own, and no other provider needs anything installed into the agent's own
home. MULTICA_DSH_PROFILE_BUNDLE now names what to install — an npm spec, a
directory or a tarball, candidates tried in order. Unset stays the default and
means "do not touch the user's DSH installation"; there is nothing to point it
at yet either, because Multica's own bridge is unpublished (#6936).

DSH Desktop ships the pnpm that `dsh plugin` forwards to inside its own
runtime-commands directory and injects it only for the processes it spawns, so
the install prepends that directory to PATH. Without it the command fails with
"pnpm not found on PATH" on exactly the machines this is for. The install runs
once per daemon, and the discovery loop is kicked when it finishes so dsh comes
online immediately instead of at the next tick.

* fix(daemon): keep a registered dsh runtime in step with its runtime profile

Two states have to agree: the Multica runtime profile is installed, and a dsh
runtime is registered. Neither disagreement corrects itself, and both leave the
server routing work into a CLI that cannot start.

When the profile is removed, dsh stays discovered and keeps its runtime, so
nothing else in the discovery loop looks at it again. Observed in the field: a
profile removed a minute after startup stayed live for three minutes and a chat
task failed against it. When a profile is installed by hand, the runtime is
missing but the converge backoff has already doubled to agentConvergeMaxBackoff
(30m) over the rounds that could not register it, so the fix stays invisible
for up to half an hour.

Compare the two states on every tick and force a round on disagreement, in
either direction. A round forced for a registered provider has nothing missing
to register, so convergeRuntimeRegistrations now consumes the verdict map it
used to discard — safe because demoteUnusableRuntimes owns the claim barrier
and the seq-stamped hold, and stays the single owner of demotion.

The profile's presence is a stat of the manifest DSH itself looks for, so the
check costs one syscall per tick; the authoritative --probe stays inside the
probe round the check forces.

* docs(runtime): document the DSH runtime-profile variables

The two variables this branch adds were undocumented, and the install guide
still told readers to run `dsh plugin --profile multica add <bundle>` without
ever saying what <bundle> is — the hole #6936 was filed against.

Document MULTICA_DSH_PROFILE_BUNDLE and MULTICA_DSH_PLUGIN_PATH next to the
other MULTICA_DSH_* variables, and name the bundle in the DSH setup step: what
implements the protocol, where the bridge lives, and that the daemon reports
the missing profile on /health rather than registering a runtime that cannot
start. All four locale files, so the translations do not drift.

* refactor(agent): export the DSH stdio protocol version

The daemon's runtime-profile probe accepts or rejects a profile on this exact
number: one answering `--probe` with a protocol this backend does not drive
must not be registered. That decision was about to be made against a second
copy of the constant in internal/daemon, and a second copy is one that can
drift from the code actually speaking the protocol.

Export it so the probe reads the number from its only honest source. No
behaviour change: the value is still 1.

* fix(redact): mask credentials carried in URL userinfo

The connection-string rule covers postgres, mysql, mongodb, redis and amqp
only, so `https://user:token@registry/...` passed through untouched. That shape
is not hypothetical for agent output: npm and pnpm accept an authenticated
registry URL as a package spec, and both the package manager and the registry
echo the request URL back in their error text, which is how it reaches a log
line without anyone logging the spec itself.

Add a scheme-agnostic userinfo rule. The scheme and host survive — which
registry refused the request is the part an operator reads, and it is not the
secret — and userinfo requires both a colon and an `@` before the next path
separator, so `https://host:8443/p`, `ssh://git@host/p` and a bare `a:b` in a
path are all left alone. Tests cover both directions.

* fix(daemon): decide the DSH runtime-profile verdict on evidence

probeDshMulticaProfile returned a bare bool, so a timeout, a non-zero exit,
unparseable output, a future protocol version and a genuinely absent manifest
all collapsed into "the profile is not installed" — a verdict that demotes a
live runtime and, with a bundle configured, installs over the user's DSH home.
This rebuilds the verdict layer around what was actually observed.

Classification. The probe now returns one of OK / missing / incompatible /
unavailable. The frame decides, not the exit status: a profile that printed a
valid answer has answered, whatever the process did on its way out — which also
stops a process-tree lifecycle error from being read as a protocol failure.
With no usable frame, a cancelled ROUND stays transient (the daemon is shutting
down; that says nothing about the profile) and otherwise the manifest decides.
A manifest that IS present keeps every failure transient, which is what
protects a working install during an upgrade.

Timeouts are not transient by themselves. Asked for a profile it does not have,
DSH does not refuse `--probe` — it hangs, printing nothing, until something
kills it. Measured: still running after 10s, zero bytes on both streams. So a
timeout is the ordinary way a missing profile presents, and treating it as
transient made the profile undetectable on exactly the host the automatic
install exists for. The manifest is the same file DSH's own loadProfile
consults before refusing to boot, so a confirmed-absent one is evidence.

Order. The profile probe now runs only after version detection succeeds.
exec.LookPath is far too weak a proxy for "this CLI works": on Windows the CLI
is a .cmd shim it resolves happily and which exits 9009 — cmd.exe's "command
not found" — when what it forwards to is missing. Probing the profile first
reported such a machine as missing its runtime profile, a repair for a problem
it did not have.

Demotion. An incompatible profile is reported and never demoted: this daemon
can be the stale side of a protocol skew, and tearing down a live runtime
because THIS build is behind is a destructive answer to a version question.
The confirmation window that gated the not-executable verdict now gates every
condemnable verdict, keyed per verdict so a provider moving between states
starts a fresh clock rather than inheriting one earned by another problem.

Convergence. A registered dsh whose profile was removed forces a round on every
tick, because condemning it takes two of them and nothing else schedules the
second: with dsh still registered nothing is missing a runtime, so an unforced
tick returns without probing at all. The mirror direction — a profile with no
runtime — stays throttled to one forced round, since no round is guaranteed to
resolve it and forcing would bypass agentConvergeMaxBackoff forever.

Offline cause. A missing profile reaches the server as a structured
`dsh_profile` code rather than a bare offline, and carries whether the daemon
is installing the profile right now — the one case where queueing is right.
That claim is withdrawn if the install gives up: the runtime row is written
once, by the demotion that also removes the runtime from the index, so nothing
later would correct it and the work would queue forever behind an install that
already stopped.

Provisioning. Runs under the daemon's lifetime through processtree, so the
pnpm/node descendants cannot outlive a shutdown and keep writing into the DSH
home. Success is the manifest on disk, not the package manager's exit status,
and a candidate that exits 0 without producing a profile is a failed candidate
so the next one still gets its turn. The bundle spec no longer reaches a log
line — an npm URL can carry credentials and a path a username — and captured
output is redacted and bounded on a rune boundary. The PATH merge matches the
variable case-insensitively, because Windows spells it `Path` and an exact
`PATH=` miss left the package manager with only the plugin directory under Go's
case-insensitive de-duplication.

Startup. A daemon that registered nothing continues when it has just begun
installing the profile itself. On a host whose only provider is a dsh without
one, failing here is a deadlock rather than a fail-fast: the probe starts the
install, the error kills the process milliseconds later, the install's process
tree dies with it, and the next start repeats all of it. Nothing else is
forgiven. Because such a daemon tracks no workspace, a finished install picks
the workspace up before converging.

Repair. The reported repair names the missing package and carries no command:
the install needs a bundle only the operator can choose, and Repair.Command is
rendered to the user inside a shell fence as the line to run, so a placeholder
there is a paste-and-fail instruction.

Tests. The classification matrix, "manifest present + timeout / non-zero exit /
malformed output / future protocol ⇒ no demotion, no provisioning", the 9009
shim, the forced-round sequence, the withdrawal, the startup exception, the
empty install, and the case-insensitive PATH merge. Two existing tests read the
developer's real ~/.dsh and one fake dsh could not answer `--version` at all —
both now pinned to the test's own directory and to the shape the feature is
actually about.

* feat(daemon): discover the CLI shims DSH Desktop generates

The macOS fallback pointed at one path inside the app bundle, and Windows had
none at all — every candidate there was a /Applications path that cannot exist,
so a Windows host running DSH Desktop reported no dsh whatsoever. Replace it
with one layout that serves both platforms.

The CLI is not in the install directory. DSH Desktop generates shims under its
own per-user data directory — ~/Library/Application Support/DSH Desktop on
macOS, %APPDATA%\DSH Desktop on Windows — and on Windows that is roaming
application data even though the app installs to Program Files or
%LOCALAPPDATA%\Programs. One lookup therefore covers both install modes, since
a per-machine install still generates these shims per user.

Payload directories are content hashes (and, under host-commands, a hash plus a
generation uuid), so they are enumerated rather than composed. Only directories
holding a bin/ are accepted, so a stray file or half-extracted download is
skipped; modification time orders them so an upgrade's new payload wins over an
abandoned one, and the name breaks ties because ReadDir order is not a promise
and the daemon must not pick a different CLI on different rounds.

host-commands outranks cli, and that is load-bearing: nothing here executes a
candidate, so a broken-but-present shim is indistinguishable from a working one
at this layer — and on the install that prompted this, the cli shim was exactly
that, resolvable by exec.LookPath and exiting 9009. Ranking is the only lever.

Generated shims also outrank the macOS app-bundle script, which is entered
through `#!/usr/bin/env node` and so only works where node happens to be
installed; a shim runs the Desktop app's own runtime by absolute path and needs
nothing on PATH. That gap was latent on macOS too.

executableCandidate had to learn the platform difference for any of this to
work: Go synthesizes a regular file's mode from the read-only attribute and
never sets an executable bit on Windows, so the Unix test rejected every
candidate and the whole fallback would have been dead code there. Windows uses
exec.LookPath, the same check the launcher makes, so a candidate accepted here
is one that can actually start — which is also why the Node script has no
Windows counterpart and .js never appears in that list.

Only default locations are covered, and nothing reads an install receipt or the
registry, so a relocated install still needs MULTICA_DSH_PATH.

The path builders take goos/env/home as arguments rather than reading
runtime.GOOS and os.Getenv, so the Windows layout is asserted from any host —
the alternative leaves it testable only on Windows, which in this package has
meant untested.

* fix(server): refuse a missing DSH runtime profile under its own reason

A runtime taken offline for a missing profile reached the server as a bare
offline, so runtimeVerdict classified it as waitable/runtime_offline and
assignments and @mentions bound to that agent queued forever — even though,
with no automatic install configured, that wait never resolves on its own.
That contradicts the semantics settled in MUL-6164: when a human has to
intervene, refuse the trigger and say how to fix it.

Blocked, but not as runtime_unusable. The two causes have opposite repairs: an
unrunnable CLI is reinstalled, while a CLI missing its runtime profile is
working perfectly and reinstalling it changes nothing. Sharing one code meant
every client told DSH users to reinstall a CLI that was never the problem — the
chip and toast said "reinstall it there", and the durable system comment left
on the issue opened with "its CLI is installed but cannot be executed on that
machine", blamed a blocked npm postinstall, and rendered `dsh plugin --profile
multica add <bundle>` inside a bash fence as the line to run on that machine. A
placeholder presented as a command is the shape of instruction people paste
verbatim and then report as broken.

So: a new runtime_profile_missing reason code, carried through readiness,
dispatch and the localized copy in all four languages. The notice branches on
the reason rather than on whether a repair command happened to be present, and
the profile text names what is missing, says the CLI is fine, and points at the
two ways to supply a real bundle without printing a command to copy.

An install the daemon is running right now is the exception: that wait does end
by itself, so the work queues instead of being refused.

RuntimeBlockedNeedsNotice replaces three inline comparisons, because a code
added to one admission path and not the others is a trigger that vanishes with
no trace on exactly the surfaces that have no response for the user to read.

* docs(runtime): document DSH profile provisioning and shim discovery

Automatic provisioning is a new, persistent feature surface, and it had no
product surface: an admin had no documented way to configure it, and the one
paragraph that existed attached the install semantics to the wrong variable —
the sentences describing what MULTICA_DSH_PROFILE_BUNDLE does followed the
MULTICA_DSH_PLUGIN_PATH sentence, so "the variable is unset by default and the
install it enables is bounded" read as being about a variable that enables no
install, and PLUGIN_PATH was then described twice.

Environment variables, in all four languages: the install semantics move onto
the variable they belong to, and now state the bounds — at most once per daemon
lifetime, only on a confirmed-absent profile, failures logged with the package
manager's own output and retried only after a restart, a timeout or an
unsupported protocol version reported and never installed over. Plus how to
diagnose (search the daemon log for `DSH runtime profile`) and how to roll back
(delete $DSH_HOME/profiles/multica). MULTICA_DSH_PLUGIN_PATH is described once,
and Windows and Linux are named as the platforms with no bundled pnpm
directory, where the daemon uses whatever is on PATH unless this points at one.

Install guide, in all four languages: what to actually put in the variable.
Multica's bridge is not on a public npm registry yet (multica#6936), so a
self-hosted team builds it from the repository and points at the build
directory, or at the tarball npm pack produces when the daemon host is not the
build host; a package name becomes the simplest option once it is published.
With a warning that belongs on this value specifically — it is installed into
every daemon host's DSH home without further confirmation, so an unvetted
third-party package is a supply-chain decision, not a convenience.

Also documents where the daemon looks for a Desktop-managed CLI, including the
detail that trips people on Windows: it is roaming application data, not the
install directory. And the one exception to "the daemon must detect at least
one built-in CLI before it will start" — a daemon that has just begun installing
the profile itself starts with no runtimes, because otherwise a host whose only
tool is a DSH needing that profile could never finish the install.

* fix(daemon): find DSH Desktop's bundled pnpm on Windows too

The automatic install shells out to `dsh plugin`, which forwards to pnpm, and
the daemon inherits no path to the one DSH Desktop ships. The lookup composed a
single macOS path and was commented as macOS-only, so on Windows the install
failed with "pnpm not found on PATH" unless the operator set
MULTICA_DSH_PLUGIN_PATH by hand — on exactly the hosts the automatic install
exists for.

Both platforms bundle it under runtime-commands in the app's own per-user data
directory, but not in the same shape: macOS 2.0.5 keeps a flat
runtime-commands/bin, while a Windows install keeps
runtime-commands/generations/<id>/bin. Composing either path leaves the other
broken, so the lookup now shares the enumerator with CLI-shim discovery and
searches both shapes at both levels. A Desktop version that moves between them
is then a version this daemon keeps working with rather than one it silently
stops supporting.

Sorting is pooled per tier rather than per directory. Doing it per directory
made the answer depend on the order ReadDir returned DSH profile names, which
is the same non-determinism the name tie-break exists to remove: the newest
generation has to win even when it belongs to a profile that sorts later. An
existing test caught this while the enumerator was being extracted.

An explicit MULTICA_DSH_PLUGIN_PATH now replaces the search instead of merely
being tried first. An operator who pinned a directory is bypassing the bundled
pnpm on purpose, and quietly falling back to it would defeat that.

Documented in all four languages, where the previous text stated the opposite —
that Windows has no bundled directory.

* fix(server): bound the in-flight DSH install claim instead of trusting it

`installing` tells the server to queue triggers rather than refuse them, so a
claim that outlives its install recreates exactly the failure the structured
reason was added to prevent: work queued behind something that already stopped.

The withdraw cannot be made to guarantee that. Three paths leave the claim on
the row. The verdict reads dshInstallInFlight when it is built, but the wait is
recorded later, when the demotion knows the runtime ids — an install that fails
in between finds an empty list, and the `installing` written afterwards is never
withdrawn. The corrective Deregister is best-effort, and during shutdown it runs
on an already-cancelled lifecycle context. And a daemon restarted mid-install
does not track the row at all.

Bound the claim instead. SetAgentRuntimeOfflineWithReason stamps updated_at, and
the install it describes is itself bounded by the daemon's provisioning timeout,
so honouring `installing` only inside a window comfortably longer than that
covers a real install while capping every way the claim can be left behind. A
row with no usable timestamp cannot be bounded and is not honoured: refusing
during a real install costs one retry the notice already asks for, while
honouring a stale claim costs work that queues until it expires.

The withdraw stays as the fast path — this is the floor under it.

* refactor(daemon): scrub URL credentials in provisioning output, not globally

The rule was added to pkg/redact's shared pattern list, where redact.Text runs
over every agent's task messages, comments and error text — so a fix aimed at
one package manager's output changed what every provider emits. The gap is real
and still worth closing in that list, but widening a shared filter is its own
change with its own blast radius, and it does not belong in a PR about DSH.

Move it to dshProvisionOutput, which is the only place this PR needs it: a
bundle spec can be an authenticated npm URL, and both the package manager and
the registry echo the request URL back in their error text. The shared redactor
still runs after it for the token shapes it already knows.

Behaviour for the provisioning log line is unchanged; behaviour for every other
provider returns to main's. Same assertions as before, moved next to the code
they now cover, plus the false-positive cases — a port, an scp-style git remote,
a bare colon in a path — and one asserting the host survives, since which
registry refused the request is what an operator reads and is not the secret.

* fix(daemon): let a converge round condemn dsh only

convergeRuntimeRegistrations started consuming the verdict map it used to
discard, so that a round forced by a DSH profile mismatch could reach demotion —
a registered dsh whose profile was removed leaves nothing missing a runtime, so
the round has no registration left to do and would otherwise return having done
nothing.

But it acted on every provider's verdict, not just dsh. On main, demotion is
reached from refreshAgentVersions alone, on its own ten-minute cadence, and that
is the schedule every provider's verdict was tuned against. Claude Code, Codex
and the rest would have moved onto a different one as a side effect of a DSH
fix — a behaviour change for providers this PR has no reason to touch.

Narrow it to dsh. It is the only built-in with a precondition that can change
without any version changing, which is why it is the only one that needs a round
it can be forced into; every other provider keeps main's behaviour exactly.
2026-09-12 11:25:56 +08:00
Jiayuan Zhang 2ae2dbbb8f fix(annotations): confirm notes explicitly and stabilize source markers (#8322)
* fix(annotations): confirm notes explicitly and stabilize source markers

* fix(annotations): constrain note popups to their source panel

* test(annotations): confirm notes in comment thread integration tests

* test(annotations): use supported role query options
2026-09-11 18:42:01 +08:00
67bc6686d1 MUL-7260: annotate comments and descriptions (#8274)
* feat(issues): annotate agent replies in the existing reply composer

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): keep annotation popup open after text selection

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): simplify annotations in thread replies

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): publish annotations as quotes and notes only

Co-authored-by: multica-agent <github@multica.ai>

* test(issues): verify annotated reply rendering

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): preview annotations on hover and separate posted quotes

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): annotate all comments and start threads from descriptions

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): remove annotations at source and align toolbar action

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address annotation review findings

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): drop useless escape in the quote character class

`\-` before the closing bracket is what ESLint's no-useless-escape flags,
and it failed @multica/core#lint on CI. A hyphen in that position is
already literal, so the match set is unchanged.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-11 17:14:38 +08:00
da7fc439a8 MUL-7281: support universal Redis clients and cluster mode (#8300)
* feat(redis): support standalone and cluster clients

Co-authored-by: multica-agent <github@multica.ai>

* fix(redis): use one global Redis configuration

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Sol-Boy <sol-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-11 16:37:42 +08:00
mrlonely 24e200dca3 MUL-7000: fix(agent): record Antigravity token usage (#7961)
* fix(agent): record Antigravity token usage

* fix(agent): address Antigravity usage review

* fix(agent): preserve completed Antigravity replies

* fix(agent): ignore stale Antigravity active steps

* fix(antigravity): require latest response completion
2026-09-11 16:14:05 +08:00
Bohan Jiang 116761e505 MUL-7072 fix(github): retire reference-only PR links (#8245)
A bare body mention of an issue key claimed nothing, yet auto-link wrote a link row for it and flagged it reference_only — filtered out of the PR list, the close aggregate and the review dedup head SHA. The row had no reader, and once the PR went terminal the preserve gate froze the flag, so adding `Closes MUL-1` to a merged PR's body could never surface the PR on its issue. With no unlink surface anywhere, the one repair available from GitHub was the one that could not work.

Write no row for a passing mention, delete the historical hidden rows (migration 458), and drop the column's reads and writes; the column itself goes in a follow-up release that re-runs the delete as a mop-up. The visible set of links is unchanged, except that a post-merge `Closes` now links the PR (without retroactively completing the issue). An open PR whose claim is downgraded in place loses its link, which is what hiding used to accomplish.

Docs, the four locales, and the agent-facing platform skill are updated in step.
2026-09-10 17:16:50 +08:00
苗大 d3a435b5dd feat(cli): expose agent conversation starters on create and update (#8224) (#8225)
The HTTP API already persisted conversation_starters. agent create and
agent update now send that field when --conversation-starters is set;
copy still carries the source value without an override flag.
2026-09-10 14:12:16 +08:00
b5a7ee1e0e fix(comments): remove delayed assignee fallbacks (#8174)
* fix(comments): remove delayed assignee fallbacks

* fix(comments): retire fallback retry descendants

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): preserve fallbacks with merged comments

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 22:51:19 +08:00
8661e586f6 MUL-7063 fix(cursor): bound stalled runs after background shell launch (#8050)
* fix(agent): supervise Cursor background shells with a liveness guard

Cursor's stream-json reports a launched background shell as the
`isBackground` boolean at the root of its completed tool-call result, and
the adapter acted on none of it: a top-level execution that went quiet
after launching one kept holding its runtime slot until the execution
deadline (upstream #7833).

Bound that shape instead of condemning it. The structural observation now
arms a Cursor-private liveness guard: a rolling semantic-progress
deadline (10 minutes in production, overridable per backend so tests do
not have to wait a minute-scale timeout) while execution continues
normally. Only a supervised run that stops progressing and never emits
its terminal result is ended, and it fails closed with a dedicated,
content-free reason rather than a generic cancellation.

Precedence is decided when the cancellation happens, not guessed at
finalization: fire() declines to claim a run the execution deadline or
the caller had already ended, so `timeout` and `aborted` keep their
classifications, while a cancellation the guard provoked is never
rewritten into "execution cancelled". A valid terminal Cursor result
outranks a latched background observation and still completes.

Detection is structural only — no reading of the command string, no
substring of captured output, no assistant text — and malformed or
non-object result metadata leaves the call foreground, so it cannot arm
the guard or fail the rest of the tool parse. Raw result bytes are
forwarded unchanged. The guard owns one goroutine and one timer per
execution, started on the first background observation and waited for
before the reader hands over its last channel; the overwhelmingly common
foreground run never arms it and pays nothing.

Tests run cross-platform by re-executing the test binary as a fake
cursor-agent, so the pathological stream stays alive and only the daemon
can end it. Test A is RED on unpatched main, where the same run is
bounded solely by the 6s execution deadline with status "timeout".

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): keep the Cursor liveness guard on its own evidence

Cross-verification of 15605057e found three places where the guard acted on
weaker evidence than the spec allows, plus one helper that did not do what it
documented.

- cursorSemanticProgressEvent believed every `tool_call` subtype. Execute
  handles `started` and `completed` and counts anything else as unhandled, so
  a stream of subtypes it drops could extend the deadline forever. Progress is
  now limited to the subtypes that are actually handled.
- The lifecycle observation read `isBackground` off any completed tool result.
  The flag the spec names lives on the shell payload, and another tool
  reporting it is not evidence that Cursor detached a process, so the read is
  gated on the `shellToolCall` key. An unrecognised payload key now leaves the
  run unsupervised, which is the pre-existing behaviour, not a new failure.
- Precedence between a native terminal result and the guard is now decided when
  the result is observed. A result that lands after the guard ended the run is
  late evidence: it no longer rewrites the failure to `completed`, its text is
  not exposed as output, and the run keeps the guard's own reason. A cause the
  guard declined to claim — a real deadline or a caller cancellation — still
  leaves the result authoritative.
- cursorBackgroundGuard.Stop closes its channels through sync.Once, matching
  the idempotency its doc claims, and returns the guard's cause as of that
  moment, which is what makes the ordering above decidable at all: after fire()
  the guard's cancellation is otherwise indistinguishable from the caller's.

Tests: the guard's own contract (fire-once, progress extension, yielding to
real causes, Stop's report, concurrent Stop) is unit-tested directly; the
observation rules are pinned by structural table cases and by two subprocess
fakes — a stalled non-shell `isBackground` must still end on the execution
timeout, not the guard.

Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): clarify Cursor fake dispatch comment in TestMain

Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): recheck observed progress before liveness cancellation

Record semantic progress under the guard mutex and recheck its deadline when
handling timer or progress notifications, so a stale tick cannot cancel an
active run. Keep the background-liveness failure content-free.

Add a deterministic virtual-time regression for the expiry boundary and
strengthen the noisy subprocess regression to check the exact error.

Validation: focused regressions fail before the fix and pass after it;
Cursor/ParseCursor tests pass with -race; go vet ./pkg/agent passes.
Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): supervise background tools with the daemon tool budget

Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): retain background ownership and refresh watchdog state

Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): validate unreaped Darwin group anchors

Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): distinguish empty Darwin groups from lost ownership

Co-authored-by: multica-agent <github@multica.ai>

* test(cursor): isolate reviewed baseline regression checks

Co-authored-by: multica-agent <github@multica.ai>

* test(cursor): stop platform checks at the first failure

Co-authored-by: multica-agent <github@multica.ai>

* fix(cursor): verify Darwin process identities during cleanup

Co-authored-by: multica-agent <github@multica.ai>

* test(cursor): use standard agent limits on macOS

Co-authored-by: multica-agent <github@multica.ai>

* test(cursor): keep lifecycle reproductions ahead of full suite

Co-authored-by: multica-agent <github@multica.ai>

* test(cursor): keep macOS CI focused on lifecycle

Co-authored-by: multica-agent <github@multica.ai>

* chore(ci): drop the PR-specific Cursor regression reproduction steps

The two steps gated on `github.event.pull_request.number == 8050` proved the
new regression tests were RED on earlier heads of this PR. That is useful while
the PR is open and dead weight once it merges: the condition can never hold
again on main, and both steps pin commit SHAs that only exist on a closed PR's
branch.

The permanent coverage stays: the lifecycle job still runs on ubuntu, macOS and
Windows, and macOS still repeats the finalization test and checks the cgo-free
ownership path.

Co-authored-by: multica-agent <github@multica.ai>

* docs(daemon): state what a zero tool budget means for a background shell

Keeping a Cursor background shell in flight for the launched process's whole
lifetime is what puts a legitimate long background job on the tool budget
instead of the shorter idle one. It also changes what MULTICA_AGENT_TOOL_WATCHDOG=0
means in practice: such a run is now bounded only by MULTICA_AGENT_TIMEOUT,
which is 0 by default.

That follows from the invariant rather than contradicting it — a foreground tool
that never returns already behaves this way — but an operator reading "never
force-stop during a tool call" would not predict it, so say it where the knob is
defined.

Co-authored-by: multica-agent <github@multica.ai>

* test(daemon): pin the captured-background zero tool budget outcome

TestCursorBackgroundUncapturedDisabledToolWatchdog covers the case where nothing
was claimed, and releases the tool so the idle budget still applies. Its mirror
was untested: when ownership IS held the process is genuinely in flight, so a
zero tool budget deliberately force-stops nothing.

Without this test the two cases look like an inconsistency and invite a later
"fix" that quietly changes what the knob promises.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): let an observed terminal result outrank the idle watchdog

A Cursor run could still be reported as a hang after it had already succeeded.
On reading its authoritative result the backend called background.Close()
synchronously, before Session.Result reached the daemon. That call takes the
tracker lock, so it can queue behind a tool-watchdog Interrupt already in
progress — and that Interrupt is allowed to return false without moving native
accounting or its timestamp. The watchdog's revalidation then saw an unchanged
count, an unchanged timestamp and an empty queue, cancelled the run, and
executeAndDrain re-tagged the completed result as idle_watchdog.

The missing piece was a lifecycle boundary the daemon could see: cleanup
succeeding is not the same fact as the outcome being decided, and only the
latter should silence a liveness policy. Session.TerminalObserved carries it,
the Cursor backend publishes it before any cleanup that can block or fail, and
the watchdog checks it both before interrupting and immediately before
force-stopping. The re-tag at Session.Result is gated on it too, so even a
watchdog that wins the last instant cannot rewrite a decided outcome.

Both regression tests drive the exact interleaving through channel handshakes
rather than sleeps: the cleanup callback itself publishes the terminal
observation, so the terminal result provably lands while cleanup is in
progress. They fail on the parent commit with 'fired=true' and
'Status:idle_watchdog' respectively.

Co-authored-by: multica-agent <github@multica.ai>

* docs: sync the Cursor background watchdog contract to ja/ko/zh

The English page gained three operator-facing contracts that the other three
locales still described with the pre-change one-liner: that a Cursor background
shell stays in flight until its owned processes exit, that normal finalization
stops those processes, and what the per-platform fail-closed limits are. These
decide whether an operator sets an overall timeout, sets a tool budget, and
expects a background server to survive a run, so they cannot ship in one locale
only — the same watchdog settings are already maintained in four languages.

Also states explicitly, in all four, that a zero tool budget leaves claimed
background work in flight for as long as it lives, bounding the run only by
MULTICA_AGENT_TIMEOUT, which is itself 0 by default.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): keep a decided outcome through the drain-timeout classifier

The terminal boundary was checked in the watchdog and on the Result arm, but
not on the one path a losing race actually takes. The watchdog reads
terminalObserved and only then writes fired and cancels, so Cursor can publish
its terminal result in between. The cancellation that follows does not surface
on the Result arm — the backend is still finalizing and cannot deliver Result
yet — it surfaces on drainCtx.Done(), which classified purely on the fired flag
and returned idle_watchdog with an empty output for a run that had already
succeeded.

Check the boundary there too and wait, bounded, for the real result. Falling
through on expiry keeps the previous behaviour, so the backstop cannot hang a
run; the backend bounds its own finalization, which is what makes the wait
short in practice.

The earlier regression test could not reach this: there the watchdog returned
at its final gate, so fired was never set and neither the rewrite guard nor
this classifier ran. The new test makes TerminalObserved publish on the read
that the watchdog's final gate performs while still answering false to that
reader, which is the losing race exactly, and asserts the watchdog really did
cancel before checking the outcome. It fails on the parent commit with
Status:idle_watchdog and an emptied output.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): bound the whole of Cursor background cleanup at close

Each process termination was bounded, but the number of background shells a run
may launch is not, so finalization as a whole was not. That matters only after
a terminal result — precisely when the daemon's watchdog has deliberately
stopped supervising the run and MULTICA_AGENT_TIMEOUT is 0 by default, leaving
nothing else to bound it.

Give Close() one budget for the whole pass. Work still unconfirmed when it
expires takes the existing unconfirmed-at-close path, so its launch result is
still surfaced and its cleanup is still logged as unconfirmed rather than
reported as successful. Reap and Interrupt keep their previous behaviour.

The test drives 40 tools whose termination never confirms; it takes ~8s on the
parent commit and stays inside the budget here.

Co-authored-by: multica-agent <github@multica.ai>

* docs(ja): match the repo's existing wording in the Cursor watchdog contract

Uses 最終結果 as elsewhere in the docs, makes the two signalling clauses say
シグナル送信 explicitly, and names プロセス捕捉時 and the background process
rather than the launch.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): linearize the post-force-stop verdict on the result hand-off

Checking terminalObserved before classifying only moved the window: the backend
can publish between that read and the classifier that follows it. Reading a
flag and then acting on it cannot be made safe by adding more read sites.

Enter the hand-off unconditionally once the watchdog has fired, and decide from
what arrives. The result's delivery is then the linearization point, and the
backend contract — publish the observation before sending Result — is what
makes the check after delivery reliable rather than lucky. The Result arm
already read in that order; this brings the drain arm in line. An expired
budget still linearizes to the liveness verdict, so the branch stays bounded
whatever a backend does.

The previous test could not reach this: its backend sent Result immediately on
cancellation, so the outer select could satisfy itself on the Result arm and
the drain classifier never ran. The new one withholds Result until the daemon
has entered the hand-off, using the hand-off's own log record as both the gate
and the evidence that the branch executed — otherwise a passing status proves
nothing about which arm produced it. On the parent commit it fails with 'the
post-force-stop hand-off never ran'.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): bound every locked background-cleanup pass, not just the closing one

Close() took its deadline before the lock, which is correct, but the deadline
could not shorten the wait for it. A concurrent Interrupt() still scanned an
unbounded number of tools with no deadline of its own, so an unbounded pass
held by anyone else was an unbounded Close() one level down — and
interrupt-holds-the-lock-while-the-terminal-result-arrives is the exact
topology this boundary exists for.

Bound every pass that holds the lock by the same budget. If waiting for the
lock consumed it, the closing pass takes a fresh one rather than pushing every
tool onto the unconfirmed path without trying; two budgets is the honest
ceiling, and terminalResultHandoffBudget is derived from exactly that.

The existing test had no concurrent holder, so it could not see this. The new
one puts an Interrupt() pass on the lock first: it takes ~1.1s on the parent
commit against a 100ms budget.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): scope the result hand-off to backends that publish a boundary

Entering the hand-off on every force stop taxed the case the watchdog exists
for. A wedged backend has no result that could outrank the stop, so waiting for
one only delays freeing the runtime slot — the three existing idle-watchdog
latency tests caught this immediately, each taking the full budget instead of
one window.

Nil TerminalObserved is the signal and is now kept distinguishable rather than
collapsed into a false-returning shim at the point of use. Backends without a
boundary keep exactly their previous behaviour. Cursor, which has one, always
closes Result, so even a wedged Cursor run ends this wait through the closed
channel rather than the budget; the budget remains as the bound for a backend
that neither delivers nor closes.

The new test pins the fast path so the hand-off cannot quietly become a tax on
every force stop again.

Co-authored-by: multica-agent <github@multica.ai>

* test(agent): prove the interrupting pass holds the lock before Close asks

The gate was closed just before calling Interrupt(), which only says the
goroutine was about to try. Close() could win the lock, finish on its own, and
the test would pass even against an unbounded Interrupt() — a regression test
that can go green without exercising what it guards.

Close the gate from inside the first process's terminate() instead: finish()
holds mu for the whole pass, so reaching it is proof the pass owns the lock.
The ordering is now a fact rather than a scheduling habit.

It did not misbehave in practice — with the old gate the test still failed
110/110 against an unbounded Interrupt(), including at GOMAXPROCS=1 and 2,
because close() does not yield the P. It is now deterministic by construction:
20/20 at GOMAXPROCS 1, 2 and 8.

Also corrects the handoff budget derivation: closing the reaper's stop channel
wakes it immediately, so its tick is not a term. What deserves the margin is an
already-started termination overshooting its pass's deadline by its own bound.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: worker-opencode <worker-opencode@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <bohan@devv.ai>
2026-09-08 16:24:41 +08:00
mrlonelyandL 074f5ab237 MUL-6834: fix(codex): preserve token usage on cancelled turns (#7753)
* fix(codex): preserve usage on cancelled turns

* fix(codex): bound cancelled turn cleanup

* test(codex): measure real turn interrupt latency

* fix(codex): make cancelled usage accounting idempotent

* fix(codex): separate cache-write usage

---------

Co-authored-by: L <luoyu32@MBP-D417VT2KP3-0117.local>
2026-09-08 14:07:00 +08:00