The floating chat bubble's unread-count badge duplicated the chat tab's
unread indicator in the sidebar and visually overlapped it. Drop the badge;
the hover tooltip still communicates unread/running state.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)
Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:
- agent_task_queue.chat_input_task_id makes a task the owner of the user
messages it must consume; the send path creates the task + user message +
attachment bindings + session touch in one transaction, and the daemon is
notified only after commit. A claim reads exactly that batch, so a message
that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
transaction: a normal message, or a visible no_response outcome (with a
non-empty English fallback) when the final output is empty. The write failing
rolls the completion back and the handler returns 5xx so the daemon retries;
the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
(keeping the tool timeline), suppress Copy, keep it unread, and keep the
session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
selector, so a rolling deploy never replays Slack/Lark history.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)
Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
the prior behavior: empty output writes no assistant row, so chat:done carries
empty content and the channel outbound silently drops it — the no_response
fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
and preserves the dispatched task for redelivery instead of cancelling a valid
task on a transient DB error.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Testing surfaced two problems with the per-thread fan-out:
1. Authorization (blocker): CreateComment rejected any agent comment on the
task's issue whose parent_id != task.TriggerCommentID, so replies to the
OTHER coalesced threads were denied ('parent_id must equal this task's
trigger comment id') and those threads never got a reply. Allow the trigger
comment OR any comment the task coalesced (taskCoversReplyParent: trigger ∪
coalesced_comment_ids); every other parent on the issue is still rejected,
so this stays scoped to the set the run was actually given to answer.
2. Ordering: the agent answered the newest (triggering) comment first. The
fan-out instruction now numbers the targets and explicitly requires posting
OLDEST thread first, the newest/triggering thread last, so replies land in
chronological order. commentReplyThreads already lists oldest-first.
Tests: TestTaskCoversReplyParent (allow-list) and chronological-order
assertions in the cross-thread prompt test.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): bound daemon.log size with rotation (MUL-4330)
The background daemon redirected its stdout/stderr into daemon.log opened
O_APPEND and never rotated it, so the file grew without limit until it was
too large to open. Every structured log line already flows through slog
(including agent subprocess stderr, forwarded via newLogWriter), so the
daemon's logger is effectively the sole author of the file's volume.
Route the foreground daemon's slog output — both the injected component
logger and the package-global slog default — through a size-based rotating
writer (lumberjack) that keeps the active daemon.log small (20MB default,
5 gzip-compressed backups, 30d), all env-overridable. Raw crash output
(Go runtime panics, pre-logger errors) now goes to a separate daemon.err.log
so the child's inherited fds never hold daemon.log open, which would block
rotation's rename on Windows.
The Desktop app spawns the daemon via this same launcher and its log tail
already handles size-shrink, so both CLI and Desktop are covered.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): address log-rotation review — foreground output, Windows handles, bounded err log (MUL-4330)
Resolves the blocking review items on the daemon.log rotation change:
1. Windows first-upgrade rotation: a foreground managed daemon now re-points
its own stdout/stderr to daemon.err.log at startup (SetStdHandle) before
building the rotator, releasing any daemon.log handle an older self-update
launcher inherited (Go opens files without FILE_SHARE_DELETE, which would
otherwise block rename-on-rotate). No-op on Unix, where an open fd never
blocks rename.
2. `daemon logs -f` vs rotation: Unix uses `tail -F` (reopen by name); Windows
opens the reader with FILE_SHARE_DELETE so it can't block the rotator's
rename, and reopens the file on size-shrink to follow across rotation.
3. Self-update handoff no longer briefly runs two rotators on one file: the old
process closes its rotator and moves remaining handoff logs (incl. the slog
default) to the crash sink before the successor starts.
4. daemon.err.log is now bounded: it rolls to a single ".1" backup once past
5MB at open time, so a crash loop can't move the growth problem to it. It is
also surfaced in the troubleshooting docs.
5. Explicit `--foreground` in a terminal keeps live stdout/stderr logging (a
documented debugging path); only detached/background children rotate into
daemon.log. Decided by whether stderr is a terminal.
Also: rotation env knobs now reject 0/negative (0 means 100MB / keep-all in
lumberjack), preventing an accidental unbounded config. Adds unit tests for
the err-log rolling and positive-int parsing; Windows/Linux(arm64) cross-builds
and `GOOS=windows go vet` pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs: sync zh/ja/ko troubleshooting with daemon.log rotation + daemon.err.log (MUL-4330)
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): bump the agent's own runtime version in quick-create parent test (MUL-4330)
TestQuickCreateIssueParentTrustBoundary bumped an arbitrary `LIMIT 1`
agent_runtime, but the handler version-checks agent.RuntimeID — the runtime
bound to the request's agent. In the shared handler test workspace, other
tests register additional runtimes, so the two diverge and the agent's real
runtime keeps the seed's empty cli_version, tripping the daemon-version gate
(422 daemon_version_unsupported) before the parent_issue_id assertions run.
Bump the runtime tied to the agent instead, making the setup deterministic.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): route coalesced replies per root thread (MUL-4348)
When a busy agent coalesces multiple @mentions into one run, the run used
a single --parent (the newest trigger), so questions raised in separate
root threads were answered in one merged comment while the other threads
were left unanswered.
Group the trigger + coalesced comments by root thread server-side in the
prompt builder (commentReplyThreads). When the run spans >=2 distinct
threads, emit a per-thread reply plan (BuildMultiThreadCommentReplyInstructions)
that instructs one reply per thread with the exact --parent, explicitly
overriding the general 'one comment per run' rule. Multiple @mentions from
the SAME thread collapse to a single group upstream, so same-thread
follow-ups keep the ordinary single --parent=trigger path and can never be
split into duplicate replies. Single-thread / non-coalesced runs are
unchanged.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): sync workflow-brief reply step with per-thread fan-out (MUL-4348)
Review of #5202 found the cross-thread fan-out was injected only into the
per-turn prompt (buildCommentPrompt), while the persistent workflow brief
(writeWorkflowComment step 7) still emitted the single --parent=trigger
cookbook for every comment task. A cross-thread run therefore got two
slightly conflicting reply instructions, so the fan-out guarantee rested on
prompt wording/precedence rather than structure.
Carry the computed thread targets on TaskContextForEnv.CommentReplyTargets
(populated from the same commentReplyThreads() the prompt uses, so the two
surfaces cannot drift). When >=2 targets, the workflow reply step now emits
the per-thread fan-out plan too; same-thread follow-ups collapse to a single
group upstream and keep the single-parent cookbook, so they still can never
be split. Also clarified the multi-thread cookbook to show a distinct file
per reply (reply-1.md / reply-2.md).
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): reply under the specific mentioning comment per thread (MUL-4348 review nit #1)
Non-trigger threads previously replied under the thread root, while the
trigger's thread replied under the trigger comment — asymmetric, and it put
the answer at the top of the thread instead of next to the actual question
when the mention was a mid-thread reply. Reply under the NEWEST triggering
comment in each thread instead (inputs are chronological, so last-write-wins
per thread), making every thread consistent and nesting each answer beside
its question.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): tolerate binding token clock skew
Clamp binding-token expiry against the database clock while preserving the 15-minute TTL cap. Return the persisted expiry so binding cards reflect the value enforced by Postgres.
* docs(lark): correct stale table name in binding token TTL comments
Post-#124 the table is channel_binding_token (with the
channel_binding_token_ttl_cap CHECK); update the two comments in
types.go and binding_token_test.go that still named the pre-generalization
lark_binding_token table.
---------
Co-authored-by: Bohan-J <bohan.optimism@gmail.com>
Discover the Codex model list and per-model reasoning efforts from the installed CLI (codex debug models --bundled), with a verified static fallback for old/offline installs. Server gates token syntax; the daemon validates the exact (model, effort) pair.
Closes#5197
MUL-4354
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.
MUL-4348
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Follow-up to #5188 addressing the second-round review.
- ValidateThinkingLevel now fails an empty codex model closed instead of
borrowing the flagged Default (gpt-5.6-sol). An empty model follows
config.toml, which can resolve to any installed model; Sol alone advertises
`ultra`, so the old borrow green-lit levels Luna / gpt-5.5 don't support and
Codex doesn't reject. Checked before ListModels so a discovery error can't
fail it open. Frontend pickModelEntry mirrors this (no per-model effort
preview for an empty codex model); the persisted-orphan clear path stays.
- parseCodexDebugModels drops efforts without a known label so the picker
never advertises a level the Create/Update enum gate would 400 on save; the
contract test now drives the real parser with an unknown effort instead of
comparing two hand-written maps.
- gpt-5.6 price aliases anchor to a literal dot (not the [.-] class), so
dashed variants like gpt-5-6-luna surface as unmapped on both backend and
frontend rather than silently borrowing a tier.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): scope claim-time comment fetches to the task's workspace (MUL-4252)
The daemon claim path embeds the triggering comment and every coalesced
comment's full text into the agent prompt, but fetched them with an
unscoped `GetComment(id)` — a task row carrying a foreign comment UUID
would pull another workspace's comment text into the prompt. On a shared
SaaS backend (tens of thousands of workspaces in one DB) that is a tenant
boundary hole, latent today only because task rows are server-written.
Switch all three claim/reconcile GetComment calls to
GetCommentInWorkspace, scoped by the runtime's workspace (claim path) or
the issue's workspace (completion reconcile). The task's issue workspace
is already asserted equal to the runtime workspace, so same-workspace
delivery is unchanged; a foreign UUID now resolves to "missing" and is
skipped — matching buildCoalescedCommentData's documented behavior.
Adds DB-backed claim tests: same-workspace trigger comment is still
delivered; a foreign-workspace comment's content never surfaces.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): extend the workdir guardrail to --attachment paths (MUL-4252)
#5167 fenced --description-file/--content-file to the working directory
but left --attachment uncovered — the same /tmp stale-file leak in image
form: an agent that writes chart.png to a machine-shared path and attaches
it could upload another run's (possibly another workspace's) stale file.
Apply ensureAttachmentWithinWorkdir to each local --attachment path in
`issue create` and `comment add` (URL values are still skipped upstream),
reusing #5167's symlink-resolving fileWithinWorkingDir and the existing
--allow-external-file escape hatch. Rejection happens before the issue is
created, so a bad path never yields a half-created issue.
Co-authored-by: multica-agent <github@multica.ai>
* fix(service): scope trigger-summary + originator resolution to the task's workspace (MUL-4252)
PR review P1: the claim-time full-comment fetch was already scoped, but
the trigger_summary snapshot (first ~200 chars) still leaked. On the real
enqueue/merge paths a foreign comment UUID flowed through
buildCommentTriggerSummary / resolveOriginatorFromTriggerComment, which
used an unscoped GetComment; the truncated text was stored on the task row
and later returned in the claim / task-history response
(handler/agent.go trigger_summary).
Thread the issue's workspace through both helpers (and their exported
merge-path wrappers) and switch to GetCommentInWorkspace, so a
cross-workspace comment resolves to "missing": trigger_summary stays NULL
and no foreign originator is inherited. Every caller already has the
issue's WorkspaceID in scope (enqueue, mention/leader, deferred fallback,
merge, completion reconcile).
Rework the claim test to drive the REAL TaskService.EnqueueTaskForIssue
path (which snapshots the summary) and assert the stored row's
trigger_summary + originator_user_id stay NULL and the claim response
carries neither the foreign body nor the foreign summary. Verified the
test fails when the summary fetch is left unscoped.
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): validate all --attachment paths before uploading any in comment add (MUL-4252)
PR review P2: `issue comment add` checked-read-uploaded each attachment in
one loop, so a valid workdir attachment followed by an invalid (external /
symlink-escaping) one uploaded the first file — orphaning it as an
issue-level attachment — then aborted before posting the comment, and a
retry duplicated it.
Extract the URL-filter + workdir-guard + read step `issue create` already
used into a shared collectLocalAttachments helper and have comment add use
it: every attachment is validated and read up front, and nothing is
uploaded unless all pass. Adds a command-level test asserting a
valid-then-external attachment pair aborts with ZERO upload requests and
no comment (fails against the old interleaved loop).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* test(server): cap pkg/agent test concurrency under -race
pkg/agent spawns many subprocess-backed tests with hard 5s deadlines. On
high-core machines the default GOMAXPROCS fan-out (across packages via -p
and within a package via -parallel) starves the parent event loops so they
miss the deadline. Run the rest of the suite at full concurrency and cap
only pkg/agent so those tests stay within budget without slowing the whole
Go suite's CI wall-clock.
* test(server): fail make test when go list package discovery fails
* feat(models): add Codex gpt-5.6 series (sol/terra/luna) to model list & pricing (MUL-4347)
Co-authored-by: multica-agent <github@multica.ai>
* fix(models): official gpt-5.6 pricing, exact aliases, max/ultra effort levels (MUL-4347)
- Replace provisional gpt-5.6 rates with OpenAI's official announcement
values (sol 5/30, terra 2.5/15, luna 1/6); cache read 0.1x input, cache
write 1.25x input (frontend + backend, kept in sync).
- Anchor gpt-5.6 price aliases to exact match so unknown suffixed variants
surface as unmapped instead of borrowing a tier.
- Add Codex 0.144.1 max/ultra effort levels to the label map and server
enum so the daemon-advertised catalog matches what the API can persist;
add a catalog->API contract test.
- Clarify that the codex Default flag is the effort-validation anchor, not a
user-facing badge.
- Note the cache-write measurement limitation (codex usage stream doesn't
report cache-write tokens yet).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Batch sub-issue status changes triggered two wrong behaviours from one user
action:
- Frontend popped the pre-trigger "现在开始处理?" confirm modal for every
non-backlog target, but done/cancelled can never start a run, so it degenerated
into a misleading "won't start → OK" step. handleBatchStatus now applies
directly (product decision: batch status, including backlog → active promotion,
applies like a single-issue/CLI change). Assign agent/squad and delete still
confirm. The now-unreachable status mode is removed from RunConfirmModal and
its locale keys.
- Backend evaluated the stage barrier per-child inside the batch loop, using a
mid-batch sibling snapshot. A batch closing several stages at once emitted one
comment per intermediate stage, pinned the parent assignee's wake to a stale
"advance Stage N+1" instruction (the accurate wake was swallowed by the
pending-task dedup), and the outcome depended on issue_ids order.
BatchUpdateIssues now collects terminal transitions and evaluates each parent
once against the batch's final state (notifyParentsOfBatchChildDone): at most
one accurate comment + one wake per parent, order-independent. Single-issue
UpdateIssue is unchanged; WillEnqueueRun is untouched.
Tests: cross-stage batch done/cancelled (forward + reverse) and lower-stage-only
on the backend; status-direct / assign-confirm / delete-confirm routing on the
frontend.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277)
Replace the free-form numeric `size` on ActorAvatar with a constrained
`AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by
role instead of ad-hoc pixels. This eliminates the magic-number drift where
the same role rendered at different sizes across pages.
- Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map +
default tier).
- Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot`
(packages/views) now take `AvatarSize`; internal font/icon math and the
presence-dot threshold read px from the map.
- Migrate all web/desktop call sites (packages/ui + packages/views) from
numeric sizes to tiers using the role table
(12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl).
- Token-ise the derived consumers `AgentAvatarStack` and
`IssueAgentActivityIndicator` (px looked up internally for overlap/+N math).
Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker,
mobile, and component-name disambiguation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184)
main's avatar-shape decision rendered non-human actors (agent, squad,
system) and the workspace logo as rounded squares, and the upload cropper
mirrored that with a square crop window. Per the updated decision
(avatars_and_cropper_round_required), every avatar and the crop UI are now
circular; the square path is removed rather than left as dead config.
- Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split).
- avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is
always cropShape="round".
- avatar-upload-control: drop VARIANT_SHAPE; the control is always round and
no longer threads a shape to the dialog (variant still drives the fallback).
- Strip rounded-md/rounded-none square overrides from agent/squad/member
ActorAvatar call sites; round the read-only agent/squad static wrappers.
- WorkspaceAvatar: round the org logo so it matches the (now round) workspace
upload/crop and the shared avatar shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): make round avatar shape a hard invariant (MUL-4277)
Close the two remaining square squad-avatar paths flagged in review and
prevent call sites from re-squaring the avatar:
- base ActorAvatar: keep `rounded-full` as the last class in cn() so a
call-site `className` can no longer override the circle.
- SquadHeaderAvatar: drop `className="rounded"` (was overriding the base
circle into a small rounded square).
- SquadsPage no-avatar fallback: route through the shared ActorAvatarBase
(`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the
fallback matches the image path — one shape source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): round the agent/squad avatar loading skeletons (MUL-4277)
The avatar placeholder skeletons on the agent/squad list, detail, and
profile-card loading states were still rounded squares (rounded-md/lg) from
the pre-round era, so the avatar visibly popped from square to circle on
load — inconsistent with the round avatars and with the member/inbox/issue
skeletons that already use rounded-full.
Round all five: agents-page, agent-detail-page, squads-page,
squad-detail-page, squad-profile-card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(editor): Linear-style issue identifier autolink (MUL-4241)
Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue
chips and can be typed/pasted into a real mention, instead of staying inert
text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the
Phase 3 batch resolve API is intentionally deferred.
Phase 1 — readonly render autolink
- Pure, markdown-aware detector `preprocessIssueIdentifiers` in
@multica/ui/markdown rewrites bare identifiers to
`[MUL-123](mention://issue/MUL-123)`, skipping code, existing links,
URLs, and file/path tokens. Runs before linkify/file-card.
- `isIssueIdentifier` distinguishes a bare identifier from a real mention
UUID at render time (a UUID never matches the identifier pattern).
- Chat markdown and comment/description readonly both resolve identifiers
to a real issue via a workspace-scoped, exact-match TanStack Query
(`issueIdentifierOptions`), rendering a chip on a hit and plain text on a
miss / cross-workspace / while loading. The exact `identifier ===` filter
enforces the workspace prefix, since the backend search matches by number.
- Autolink is opt-in per surface; the shared editable preprocess pipeline is
untouched so editable content is never rewritten with fake mentions.
Phase 2 — editable editor input/paste
- Async ProseMirror plugin resolves a completed identifier (boundary typed
after it, or found in pasted text) and swaps it for an issue mention node,
serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine
user edits seed candidates (programmatic setContent is gated), so opening
existing content never rewrites it. Resolver injected from the setup layer;
no React hooks inside the extension.
Tests: detector (code/link/url/path skips, dedupe), core resolver (exact
match, wrong-prefix miss, empty response, key shape), chat + readonly render
(hit/miss/code/canonical), and the editable extension (type/paste/miss/
inline-code/mount-safe/incomplete-token).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241)
Howard final-review blocker: the async resolve rescanned the whole document
for every occurrence of the resolved identifier, so completing a new `MUL-1`
also rewrote a pre-existing `MUL-1` the user never touched (persisted-content
rewrite; violated "opening existing content is not rewritten").
Fix: capture the specific candidate range(s) a user transaction introduces —
the token before the caret when typing, the tokens inside the pasted slice on
paste — into plugin state, mapping each range forward on every subsequent
transaction. After async resolve, replace ONLY those mapped ranges, verifying
each still holds exactly that identifier with intact boundaries and no
code/link mark. No document-wide scan by identifier. Also skip link-marked
text at capture so an existing link label is never converted.
Regression tests: (1) typing a new MUL-1 converts only the new occurrence,
not a pre-existing identical one; (2) paste converts identifiers inside the
paste range but leaves an identical one outside it untouched; (3) an
identifier already carrying an explicit link mark is not replaced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): unify avatar upload with crop editing across web/desktop
Add a shared AvatarUploadControl + AvatarCropDialog used by the user,
workspace, agent, and squad avatar entry points. Cropping (pan/zoom, fixed
1:1) and compression run client-side on canvas; the existing /api/upload-file
+ avatar_url chain is reused unchanged (no backend/API/DB changes). This
collapses four hand-rolled upload buttons into one control and removes
AvatarPicker.
Also make the shared display avatar treat all non-human actors (agent, squad,
system) as rounded squares — completing the "circles are for humans"
convention the editors already assumed, so display and editors agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): rebuild avatar cropper to the "Edit avatar" reference form
Replace the hand-rolled canvas cropper with react-easy-crop to match the
requested design: full-bleed image with a dimmed overlay outside a bright
crop window, a rotate control, a zoom slider flanked by −/+, and a
Reset / Cancel / Save footer. Round window for people, rounded-square for
non-human actors; output stays a 512px square (webp, jpeg fallback) through
the same upload/avatar_url chain.
avatar-crop.ts keeps the encode pipeline and gains rotation-aware
getCroppedAvatarBlob; the interactive geometry now lives in react-easy-crop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): round square avatar crop frame
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): reject --description-file/--content-file paths outside the workdir (MUL-4252)
Cross-environment context leak root cause: a quick-create run wrote its
issue description to a fixed, machine-shared /tmp/desc.md. The Write
silently failed because a different environment's run had left a stale
file there, and `multica issue create --description-file /tmp/desc.md`
fed that stale content in as the new issue's description. Two profiles on
one host share /tmp even though their workdirs are isolated.
PR-1 (fail-closed guardrail + guidance):
- resolveTextFlag now rejects a --<name>-file path that resolves (after
EvalSymlinks on both sides) outside the current working directory,
turning "silently used another run's file" into a loud command error.
Escape hatch: --allow-external-file. Covers issue create/update
--description-file, comment add --content-file, and user profile
--description-file via the single choke point.
- Templates/brief: the quick-create prompt and the runtime brief now
require agent temp files to live inside the task workdir (never /tmp),
and to treat a failed write as fatal.
Server, daemon, DB, and claim delivery were exonerated in the
investigation; the fix stays in the CLI and the prompt layer.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): quick-create description guidance mandates --description-file for rich text
Addresses PR review (MUL-4252): the earlier "prefer inline --description"
line conflicted with the runtime brief (which prefers --description-file
for long bodies) and reintroduced the MUL-2904 risk — quick-create
descriptions are usually multi-line and carry code/quotes/backticks/$(),
which the shell rewrites or truncates when passed inline. Now: only short,
simple single-line bodies may go inline; anything multi-line or containing
special characters must be written to ./description.md and passed via
--description-file. Write-failure-is-fatal and workdir-only rules unchanged.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds the daily v0.3.42 changelog entry to all four localized landing sites: a dedicated Chat tab, LLM-generated chat titles, cancelled issues as a first-class column, agent model/effort on hover, plus reconnect/comment-delivery/agent-mention/link/version fixes.
Typecheck and the changelog unit test pass locally.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(server): remove generic LLM passthrough endpoints (MUL-4309)
Remove the OpenAI-compatible passthrough HTTP handlers
LLMChatCompletions / LLMChatCompletionsStream and their two routes
(/api/llm/v1/chat/completions[/stream]) plus their tests. Exposing a
generic LLM proxy backed by the deployment key let any logged-in user
run arbitrary completions on our dime.
pkg/llm and the MULTICA_LLM_* config are kept unchanged as the
server-internal LLM entry point, so chat title generation
(maybeGenerateChatTitleAsync -> h.LLM.GenerateText) continues to work
untouched. Updated the handler.go and .env.example comments to reflect
internal-only usage.
Co-authored-by: multica-agent <github@multica.ai>
* docs(server): fix stale comments referencing removed LLM passthrough handlers (MUL-4309)
Address GPT-Boy review nits: three doc comments still described the
deleted OpenAI-compatible HTTP proxy handlers / 503 behavior. Update
pkg/llm/client.go (package doc + ErrNotConfigured) and the Handler.LLM
field comment to describe the internal-only usage.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The standalone chat-session delete path pruned channel_chat_session_binding but
not channel_outbound_card_message. Both are keyed by chat_session_id with no FK
(MUL-3515 §4) and no reaper, so deleting a chat session left the card rows as
permanent orphans — the same no-FK-orphan class as the #4810 installation fix,
which already covers the workspace-delete / runtime-teardown / reclaim paths.
Add DeleteChannelOutboundCardMessagesBySession and call it in the same tx as the
binding prune; extend the delete-chat-session test to assert both are swept.
Follow-up nit from the #5103 review (Elon).
MUL-3937
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): attribute agent-created issues so downstream A2A mentions keep the originator (MUL-4305)
An agent creating an issue via the ordinary `issue create` path left the new
issue with no origin link, so resolveOriginatorForIssueTask could not recover
the top-of-chain human. Any assignment / squad-leader run derived from that
issue lost the originator, and A2A @-mentions those runs emitted failed the
canInvokeAgent gate against private agents (after MUL-3963).
Fix (mirrors the comment.source_task_id stamp from MUL-4015):
- CreateIssue stamps origin_type='agent_create' + origin_id=<acting task>,
resolved from the SERVER-trusted X-Task-ID (never a client-reported field).
- resolveOriginatorForIssueTask inherits the origin task's originator for
agent_create just like quick_create.
- Align the squad-leader gate originator with the enqueue path via the new
exported OriginatorForIssueTask, so the gate and the persisted task row
agree instead of drifting to an empty originator for agent-triggered assigns.
- Migration 149 adds 'agent_create' to issue_origin_type_check.
- Classify agent_create in analytics to avoid the unknown-origin warning.
Tests: agent_create attribution + gate/enqueue consistency (service), and the
HTTP boundary stamp + security regression (member / forged X-Agent-ID must not
smuggle an agent_create origin).
Co-authored-by: multica-agent <github@multica.ai>
* test(server): add end-to-end regression for agent-created issue originator chain (MUL-4305)
Locks the real product path the layered tests could miss, per PR review:
1. CreateAssignSquad_PrivateWorkerTriggered — human H triggers agent A → A
creates an issue via the ordinary create path AND assigns it to a squad
whose leader is a private agent owned by H → the leader's assignment run
@-mentions a second private agent J (owned by H) → asserts the leader task
carries H and J ends up with a queued task attributed to H. This is the
exact line-failure shape from the issue.
2. UpdateAssignSquad_HandlerGateAdmitsPrivateLeader — agent A creates an
unassigned issue then assigns it to a private-leader squad via UpdateIssue,
exercising the handler enqueueSquadLeaderTask gate (which the create path's
ungated service enqueue does not hit); asserts the leader task is enqueued
carrying H.
Both wire handler create stamp → origin resolution → squad-leader gate →
comment source-task stamp → private-worker invocation gate. Verified they FAIL
against a simulated pre-fix resolver (leader originator empty; private worker /
leader get 0 tasks) and PASS with the fix.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(channels): auto-reclaim orphaned bot installations + accurate rebind conflict copy
channel_installation has no FK to workspace/agent (MUL-3515 §4), so deleting a
workspace or hard-deleting an agent left the row behind, occupying the
(channel_type, app_id) routing slot forever — the bot could never be rebound and
the UI had no way to clear it (#4810). The 409 also always blamed "a different
Multica workspace" even when the real owner sat in the same workspace.
Auto-reclaim on delete:
- DeleteWorkspace and the runtime-teardown paths now sweep the workspace's /
archived agents' channel installations and every dependent row in-tx.
- The shared install path (Feishu + Slack) reclaims a DEAD prior owner — a
revoked placeholder or an orphan whose workspace/agent is gone — before the
upsert, healing installations stranded before this fix. A live owner (active
agent, including an archived one) is left in place, not stolen.
Accurate conflict copy:
- A rebind refused by a LIVE owner now distinguishes same-workspace / another
agent, an archived agent, and a genuinely different workspace, for both Slack
(typed sentinels) and Feishu (registration message).
MUL-3937
Co-authored-by: multica-agent <github@multica.ai>
* fix(channels): reclaim cross-workspace revoked bots + sweep card/dedup/audit (#4810)
Address the #5103 review (yyclaw + Steve):
- Reclaim: a REVOKED installation in ANY workspace is now dead (except the
caller's own row), not just same-workspace. Disconnect never hard-deletes the
row and there is no release UI, so a cross-workspace revoked row would pin a
bot's app_id slot forever, with the misleading "connected to another
workspace" copy resurfacing. A new binder proves control by holding the app
credentials, so reclaiming is safe. Live ACTIVE owners (incl. archived) are
still refused.
- Sweep the two dependent tables the cleanups missed, in all three paths
(reclaim / DeleteWorkspace / runtime teardown): channel_outbound_card_message
(no reaper, so a permanent orphan otherwise) and channel_inbound_message_dedup
(PurgeChannelInboundDedup has no caller).
- Audit rows: PURGE on the hard-delete paths instead of detaching them into
permanently unattributable NULL rows; keep DETACH on reclaim, where the
workspace survives and the row stays useful for triage.
- Tests: flip cross-ws revoked to reclaimed + add cross-ws active preserved;
extend the reclaim and both delete-path cleanup tests for card/dedup and the
audit purge/detach split; assert the channel sweep on the DeleteRuntimeProfile
entry point.
MUL-3937
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(comment): compensate dropped agent→agent @mentions in completion reconcile (MUL-4304)
When agent A explicitly @mentions agent B while B already has a
dispatched/running task, the create-time enqueue path can only fold the
comment into a QUEUED task; on a merge miss it defers to completion
reconcile. But reconcileCommentsOnCompletion listed only member comments
(ListMemberCommentsForIssueSince, author_type='member'), so A's
agent-authored mention was never replayed and B was silently never woken
— the intermittent 'agent @ agent fails to trigger' bug.
Broaden the reconcile query to member+agent comments and route each under
its own author_type. For an agent author, computeCommentAgentTriggers only
produces triggers for explicit @agent/@squad mentions (plus the narrow
assigned-squad-leader fallback), and reconcile still keeps only triggers
routing to the agent that just ran — so plain agent replies never qualify
and no unrelated agent is re-woken. Agent originator is resolved from the
comment's source task so canInvokeAgent authorizes A2A correctly.
Adds two covering tests: an agent-authored @B mention earns exactly one B
follow-up; a plain agent reply (no mention) earns none.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comment): address MUL-4304 review — exercise real dispatched drop + explicit-mention-only reconcile
Review must-fix 1: the regression test used a 'running' task, which does not
reproduce the drop (running-only is not AlreadyPending, so it takes the normal
fresh-enqueue path). Rewrite it to drive the ACTUAL failure: B has a DISPATCHED
task, agent A's explicit @B mention goes through the real trigger path
(triggerTasksForComment), assert it is dropped at creation (0 queued follow-up),
then complete B's task and assert reconcile recovers exactly 1 follow-up. Correct
the 'dispatched/running' wording in daemon.go and comment.sql to 'dispatched'.
Review must-fix 2: agent-authored comments on a squad-assigned issue can route
to the squad leader via routeAssignedSquadLeaderFallback (a non-mention route),
so 'plain reply yields nothing' was not unconditionally true. Scope reconcile's
agent-comment compensation to EXPLICIT @agent/@squad mentions only
(keepExplicitMentionTriggers, Source in {mention_agent, mention_squad_leader});
the squad-leader/assignee fallback and all other conversational routing are
intentionally not replayed. Add a squad-assigned plain-worker-reply test proving
the leader gets no completion-driven follow-up (verified failing without the
filter). Update doc comments accordingly.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Write a persistent daemon-task marker at the workspaces root so a subprocess that lost all MULTICA_* env vars and escaped above its workdir still fails closed instead of falling back to the user's config PAT. Includes daemon-startup pre-ensure, per-task and reuse-path self-heal, torn-marker reclaim, atomic write, and non-fatal degrade. Fixes#5043.
Starting a new chat (⊕ new chat on the Chat tab, or ⊕ / switching agent
in the floating window) now pulls keyboard focus into the compose box so
the user can type immediately, instead of having to click into it first.
Focus is driven by a monotonic `focusRequest` nonce bumped only by the
new-chat handlers, so selecting an existing chat or opening one via a
`?session=` deep link never steals focus. ContentEditor.focus() latches
through to onCreate when the editor is not yet mounted (immediatelyRender:
false), so the freshly-mounted compose box focuses on its first frame.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): LLM-generated chat session titles with silent fallback (MUL-4295)
Generate a concise, language-matched title for a chat session after the
first user message, replacing the raw first-message-derived title. The
work is best-effort and fully non-blocking:
- Triggered on the first user message in SendChatMessage (detected via
ChatSessionHasUserMessage before insert), run in a detached goroutine
so it never delays the send or first response.
- Reuses pkg/llm GenerateText on the configured default model
(MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini); no model from the client.
- Self-hosted with no LLM key (h.LLM.Enabled()==false): silent no-op,
the original title stands. Same on timeout / upstream error.
- CAS write (UpdateChatSessionTitleIfCurrent) so a manual rename during
generation is never clobbered and titling runs at most once.
- Pushes chat:session_updated so the frontend refreshes in place.
- sanitizeChatTitle strips quotes/brackets, 'Title:'/'标题:' prefixes,
trailing punctuation, and caps at chatSessionTitleMaxLen.
Tests cover all six cases: configured→semantic title, disabled→fallback,
upstream error→fallback, manual rename→no clobber, empty output→fallback,
idempotent second run, plus sanitize rules and the realtime push.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): panic-contain title goroutine + loop sanitizer to a fixed point (MUL-4295)
Address PR #5141 review (张大彪 / multica-eve, Phase B):
1. The detached title-generation goroutine now has a defer recover() at the
top of its body. It runs outside chi's Recoverer, so an unhandled panic
in GenerateText / sanitize / the DB write / publish would crash the
server process. Best-effort path: log and keep the original title.
2. sanitizeChatTitle now alternates prefix-stripping and wrapper-stripping
in a loop until the string is stable, so a forbidden label hidden inside
a wrapper ("Title: Fix login", 「标题:修复登录问题」) is fully cleaned
regardless of nesting order. Added both cases to the sanitize test table.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): fold trailing-punctuation trim into sanitizer fixed-point loop (MUL-4295)
Address PR #5141 follow-up review: the trailing-punctuation trim ran once
AFTER the prefix/wrapper loop, so a trailing '.' / '。' left the closing
wrapper unrecognized and the forbidden prefix untouched for inputs like
"Title: Fix login". and 「标题:修复登录问题」。. Trailing trim now runs inside the
same loop, so removing the trailing punctuation re-exposes the wrapper (and
the prefix it hid) on the next pass. Added both cases to TestSanitizeChatTitle.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path.
- execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions.
- Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go.
- Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps).
- Update tests to slim-only expectations and docs/feature-flags.md.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Supersedes the read-only gfm-autolink approach (#5091), which split URL
linkification across two engines: the editor kept the string preprocessor
(urls:true) while the read-only renderer let remark-gfm autolink (urls:false)
plus a remark-cjk-autolink plugin. gfm autolink still swallowed the closing
`**` into the href whenever a CJK punctuation immediately followed
(`**url**(MUL)`), so bold-wrapped URLs stayed broken in Chinese prose.
Fix it once, at the shared string layer: collectLinkifyMatches now drops a
trailing run of markdown delimiters (`*`, `~`) from each URL match, so
`**url**` yields a clean `**[url](url)**` and the emphasis closes. Editor and
read-only share preprocessMarkdown / preprocessLinks again — one linkify logic,
no renderer-specific machinery.
- linkify.ts: trailing-delimiter strip in collectLinkifyMatches; CJK rescan is
keyed off the terminator index, independent of the trim.
- Remove the urls:false split (detectLinks / preprocessLinks / preprocessMarkdown)
and delete the remark-cjk-autolink plugin.
- Tests: **url**, **url**(CJK, CJK multi-URL, explicit link untouched, and the
trailing-* tradeoff.
Known tradeoff: a bare URL that genuinely ends in `*` (e.g. a glob) has the `*`
dropped from the link — identical to GitHub's autolink, locked by test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195)
Consecutive comments on an issue were silently dropped: a new comment that
arrived while the agent already had a queued/dispatched task was discarded by
the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up
instruction with no visible trace. Comments — unlike chat — are deliberate,
addressed, persisted input and must never vanish.
This makes comment handling at-least-once while keeping concurrency bounded to
one run per (issue, agent):
- Merge, don't drop (PR1): a comment landing while a not-yet-started task
exists is folded into that task — the prior trigger becomes a coalesced
comment and the new one becomes the trigger, so a single run still covers
every deliberate comment. Falls back to a fresh enqueue if the pending task
was claimed mid-flight, so nothing is lost in the race.
- Completion reconciliation (PR2): on task completion, a member comment newer
than the run's started_at schedules exactly one follow-up via the normal
trigger pipeline. Loop-safe: member-authored only, capped by the existing
per-(issue,agent) dedup, and terminating.
- Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in
the run prompt so the covered comments are explicit.
Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[].
Tests: merge-not-drop preserves all three of a rapid burst and repoints the
trigger to the newest; reconciliation query gates on member/since; e2e
CompleteTask enqueues a follow-up for a mid-run member comment and does not for
none.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195)
Resolves GPT-Boy's Request-changes review on PR #5068.
Must-fix #1 — merge no longer inherits a stale originator/runtime context.
MergeCommentIntoPendingTask now only folds a comment into a pending task
whose originator_user_id IS NOT DISTINCT FROM the new comment's originator.
runtime_mcp_overlay / runtime_connected_apps are a pure function of
(originator, agent) and the agent is fixed, so a matching originator keeps
the stored overlay/attribution valid; a differing originator (e.g. user B
commenting on a task originated by user A) matches no row and the caller
enqueues a fresh follow-up with B's own context instead of reusing A's.
trigger_summary is refreshed to the new trigger comment.
Must-fix #2 — completion reconcile no longer re-wakes unrelated agents.
reconcileCommentsOnCompletion computes the latest member comment's triggers
and keeps ONLY the agent that just completed, instead of fanning the comment
out through the full pipeline. An @-mention of agent B during agent A's run
is triggered once at creation time and is no longer replayed (double-run)
when A completes.
Should-fix #3 — coalesced-comment prompt no longer assumes a single thread.
The claim response now carries each folded comment's thread id / author /
created_at / content (CoalescedCommentData); the prompt embeds them directly
so the agent addresses cross-thread folded comments without the wrong
"they are in the triggering thread" hint. Old servers that ship only ids
fall back to an issue-wide fetch, still without the same-thread assumption.
Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate),
TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile
scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback
(prompt). Existing MUL-4195 suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195)
Second-round review follow-up on PR #5068.
Must-fix #1 — originator-mismatch no longer drops the comment.
The previous originator gate returned ErrNoRows on a different originator and
the caller fell through to a fresh enqueue, which collided with the
idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task
per (issue, agent)) — silently dropping the second user's comment. Replaced
the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps
originator_user_id, runtime_mcp_overlay, runtime_connected_apps and
trigger_summary to the new comment's originator. A different member's comment
folds into the single coalescing run carrying the latest instruction's own
identity/overlay (no cross-user capability bleed, no drop, no collision).
Must-fix #2 — comment arriving in the claim→StartTask window is no longer lost.
Merge now targets only PRE-CLAIM states ('queued','deferred'); a
dispatched/running task is never a merge target, so a post-claim comment is
never falsely stamped into coalesced_comment_ids as "delivered". Completion
reconcile is re-anchored on dispatched_at (the moment the claim response is
built) instead of started_at, and sweeps ALL undelivered member comments since
that anchor — replaying each through the normal enqueue path so they coalesce
into one bounded, agent-scoped follow-up run. This covers the dispatch→start
window a started_at anchor missed.
Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues
(which could collide with a dispatched sibling); it defers to the active
task's completion reconcile via HasActiveTaskForIssueAndAgent, and only
fresh-enqueues when no active task exists.
Tests: rewrote the query test to
TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched;
added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler
enqueue path, two distinct originators) and
TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All
existing MUL-4195 handler/cmd-server/daemon/service suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195)
Third-round review follow-up on PR #5068.
Race: a member comment is created while the task is still queued, but its
merge loses the race to the daemon claiming the task (queued→dispatched). The
merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to
reconcile — but the comment's created_at is BEFORE dispatched_at, so the
dispatched_at-anchored reconcile skipped it and the comment vanished with no
task coverage.
Fix: anchor completion reconcile on the task's created_at (which always
precedes dispatch) instead of a dispatch/start timestamp, and exclude the
run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because
merges only ever touch pre-claim rows, that set is exactly what the claim
response carried, so any member comment created since the task was made that
is NOT in it was genuinely undelivered and earns a bounded follow-up. This
catches the pre-dispatch merge-race comment and the dispatch→start comment,
while never re-firing a comment that was delivered as a pre-claim coalesced
entry.
Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the
race (comment created pre-dispatch, task dispatched before merge, plus a
delivered coalesced comment) and asserts exactly one follow-up, triggered by
the race comment, with the delivered coalesced comment excluded. Existing
reconcile fixtures updated to set a realistic created_at (the production
invariant that created_at is the earliest task timestamp).
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195)
Fourth-round review follow-up on PR #5068.
MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered
by created_at DESC. When a (issue, agent) pair had both an older queued task
(the run about to be claimed) and a newer deferred assignee-fallback task, a
new comment merged into the deferred row instead of the queued one — so the
comment missed the imminent run and the deferred fallback could later promote
into a duplicate/conflicting run.
This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a
queued/dispatched task (it never inspects deferred), so the coalescing target
must be the queued row. Restricted the merge target to status = 'queued'
(the unique index guarantees at most one). Deferred fallbacks keep their own
fire_at/promotion escalation lifecycle and are never a merge target.
Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older
queued task + a newer deferred fallback for the same (issue, agent), merges a
new comment, and asserts it lands on the queued task (trigger repointed, old
trigger coalesced) while the deferred fallback is left untouched.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Integrate the official openai-go SDK (v3) as a thin, reusable LLM layer
(pkg/llm) backing lightweight utility calls that do not need the agent
runtime (chat titles, quick-create drafts, ...).
Expose two user-authenticated, OpenAI-compatible chat-completions
endpoints:
- POST /api/llm/v1/chat/completions (JSON response)
- POST /api/llm/v1/chat/completions/stream (SSE stream)
Requests decode directly into the SDK's ChatCompletionNewParams and
responses are relayed via RawJSON() for byte-exact OpenAI-format
compatibility. Base URL and API key are configurable (MULTICA_LLM_*),
and the model is taken from the request with a configurable default
fallback (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini). When unconfigured
the endpoints return 503.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The multica target ran `go run` with no -ldflags, unlike `build`, so
main.version stayed at its hardcoded "dev" default for any daemon
started via `make daemon`. The quick-create CLI version gate treats
"dev" as unparsable (fails both the semver check and the git-describe
dev-build exemption), so Create with agent blocked with "doesn't
report a CLI version" for any locally dev-run daemon.
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.
- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
default visible/hidden status derivation, the assignee-grouped board's
default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
surface data layer. Cancelled flows through to list/board/swimlane columns,
header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
board show/hide controls consistently (hideStatus already used ALL_STATUSES).
The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.
Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): surface cancelled issues via status filter (MUL-4261)
Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.
Implement plan A (fetch-always, hide-by-default):
- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
into the byStatus cache and rebuckets correctly when an issue is
cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
issues are excluded from `surfaceIssues` (and therefore list/board/
swimlane columns, header facet counts, batch selection, and isEmpty)
unless the filter explicitly selects "cancelled". Then a Cancelled
section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
hideable/persistent board column.
Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.
Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261)
The swimlane derived its status columns as
`BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing
canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES
omits `cancelled`, a filter-selected Cancelled column was silently dropped
even though the controller's `visibleStatuses` included it — the surface
fetched and gated cancelled correctly, but swimlane never rendered it.
Filter against ALL_STATUSES instead: same canonical ordering, but a
selected `cancelled` column now survives. `hiddenStatuses` stays
board-only, so cancelled is still never a hideable/persistent column.
Regression tests:
- swimlane renders a Cancelled column + its cards when cancelled is in
visibleStatuses, and omits it otherwise (verified failing pre-fix);
- controller asserts hiddenStatuses never contains cancelled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): autolink read-only URLs in the parse tree, not raw text
Read-only markdown surfaces (comments, descriptions, chat) pre-linkified
bare URLs by rewriting the raw source to [url](url) before parsing. Because
linkify-it treats `*` as a valid URL character, a bare URL followed by a
bold close — `**PR:https://…/5081**` — had the trailing `**` swallowed into
the match and rewritten as [url**](url**). That consumed the emphasis closer
(the bold never closed; the leading `**` rendered as literal asterisks) and
corrupted the href with a trailing `**` (MUL-4242).
Let remark-gfm autolink URLs in the parse tree instead, where emphasis is
already resolved so an adjacent delimiter can never be absorbed. The custom
string pass now runs in a `urls: false` mode on read-only surfaces and only
linkifies file paths (which gfm never does). A small remark plugin
(remark-cjk-autolink) re-applies the existing CJK URL boundary to gfm's
autolink literals so `https://x/a。后面` still stops at 。.
The Tiptap editor path is unchanged (`urls: true`): @tiptap/markdown does not
autolink bare URLs, so it still needs the string pass.
Note: read-only URL autolinking now follows GFM semantics (scheme, www., or
email required); bare fuzzy domains like `NBA.com` render as plain text on
read-only surfaces, matching CommonMark/GFM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): keep every URL in a CJK-separated run linked in readonly
Follow-up to the read-only autolink fix. remark-gfm glues `url1、url2` into a
single autolink literal because it treats CJK punctuation as a URL character;
remark-cjk-autolink trimmed only at the first terminator and dropped the tail
to plain text, so the second URL stopped being a link — a same-class regression
of MUL-4242 for CJK-punctuation-separated URLs (flagged in review).
Re-derive the segments with detectLinks (which reuses collectLinkifyMatches'
truncate-and-rescan) and rebuild the [link, text, link, …] sequence, so every
URL in the run stays linked. Adds a read-only test for
`两个地址 https://a.com/x、https://b.com/y`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): add exponential backoff with jitter to WSClient reconnect
The WebSocket client used a flat 3-second reconnect delay with no
backoff, jitter, or attempt limit. When the server restarts, every
connected client (web + desktop) reconnects at exactly T+3s, creating
a thundering-herd connection spike.
Replace the fixed delay with exponential backoff:
- Base delay 1 s, doubling each attempt (1 → 2 → 4 → 8 → …)
- Cap at 30 s to keep recovery time reasonable
- ±20 % jitter to decorrelate clients that disconnect simultaneously
- Give up after 20 consecutive failures (log error, allow manual retry)
- Reset the counter on successful authentication
Add 7 unit tests covering the backoff curve, cap, jitter range,
counter reset, max-attempt cutoff, and disconnect cancellation.
Closes#5035
* fix(core): clamp jittered delay to max and make jitter test deterministic
Address Copilot review feedback:
1. Clamp the final delay to RECONNECT_MAX_DELAY_MS after jitter is
applied. Previously, when base was already at the 30s cap, +20%
jitter could push the delay to 36s, violating the configured max.
2. Replace the nondeterministic jitter test (which relied on real
Math.random() producing ≥2 distinct values in 20 samples) with a
deterministic stub that alternates between 0 and 1, asserting
exact min/max delays (800ms and 1200ms).
* fix(core): remove reconnect attempt limit, retry indefinitely with capped backoff
Address maintainer feedback (NevilleQingNY):
The web/desktop UI does not currently expose a visible disconnected
state or manual retry action, so the 20-attempt give-up limit would
leave an open tab silently stale after a long outage. Remove the
limit and let the client retry indefinitely with the 30s capped
jittered delay.
- Drop RECONNECT_MAX_ATTEMPTS constant and the give-up early-return
- Update JSDoc to document the indefinite-retry contract
- Replace "stops after max attempts" test with "keeps retrying
indefinitely with capped delay" that verifies 25+ attempts still
schedule reconnects at 30s
Chat list avatars read too large at 36px; inbox list was 28px, so the two
surfaces were inconsistent. Standardize both to 32px (the common list-row
avatar size) — chat list 36->32 (fallback placeholder size-9->size-8),
inbox list 28->32.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent profile hover card now surfaces the runtime-native model id
(mono, e.g. `claude-opus-4-8`) with the reasoning/effort token as a badge,
so a quick hover answers "which model is this agent running?" without
opening the detail page. Empty model renders a "Runtime default" placeholder.
Also fixes the Skills row: chips wrap to multiple lines, so the vertically
centered label drifted to the middle chip — it now pins to the first chip
row (items-start + pt), and each chip truncates so a long skill name can't
blow out the card width.
The effort badge is gated on `effort` alone (not `hasModel`): an agent with
no pinned model can still persist a thinking_level override that applies at
run time, and hiding it would misreport the agent's real config. Adds
agent-profile-card.test.tsx covering the model/effort render states,
including the `model:"" / thinking_level:"high"` regression.
New i18n: profile_card.model_label / model_unset (en/zh-Hans/ja/ko).
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): advance selection to next chat when archiving the open session
Archiving the chat currently open in the two-pane Chat tab left the
conversation pane showing a now read-only, dangling session. Mirror the
Inbox list's handleArchive: move selection to the next chat in the
sorted, non-archived history list, fall back to the previous one, and
clear only when nothing is left. Archiving a non-open row is unchanged.
Closes MUL-4278
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): route archive-advance through the shared controller
Address review of #5110:
- Advance now routes through handleSelectSession so selectedAgentId stays
in sync when the next chat belongs to a different agent (a follow-up
"new chat" no longer defaults to the archived chat's agent).
- Move the advance/next-prev/clear logic into use-chat-controller
(advanceSelectionAfterArchive + archiveSession) and drive both Chat-tab
entry points from a single ChatPage.handleArchive: the thread-list row
AND the conversation header ⋯ menu (the header previously only flipped
status, stranding the user on the archived read-only conversation).
- Mobile: archiving the open fullscreen conversation returns to the list.
- Floating window: archiving the open chat now advances to the next chat
(with cross-agent sync) instead of clearing, matching the Chat tab.
Tests: controller test covers advance/fallback/clear/no-op + cross-agent
sync; thread-list test now asserts it delegates to onArchive.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Tiptap's Placeholder only reads its text at mount, and ContentEditor had a
defaultValue-sync effect but no placeholder-sync effect. Switching between
sessions of the same agent doesn't remount the editor, so the placeholder
froze on the previous value — e.g. stuck on "This session is archived" after
visiting an archived session, even on an active, usable input.
Mutating the extension's string option at runtime does not repaint (Tiptap
snapshots a string placeholder at mount). A function placeholder, however, is
re-invoked on every decoration pass, so:
- extensions: `placeholder` option now accepts `string | (() => string)`.
- content-editor: pass a getter over a live `placeholderRef`; a new sync effect
updates the ref and dispatches an empty transaction (docChanged=false, no
onUpdate loop) to force a decoration recompute — no remount required. This
also fixes placeholder staleness when the session/agent archive state changes.
- chat-input: update the editorKey comment (placeholder no longer relies on the
agent-switch remount).
- tests: getter reads the live value + one repaint on change; no repaint when
the placeholder prop is unchanged.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>