197 Commits

Author SHA1 Message Date
Multica Eve
f51828f568 MUL-4936: Fix empty replies in resumed Codex and Hermes chats (#5675)
* fix(agent): isolate resumed Codex turns (MUL-4936)

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

* fix(agent): drain late Hermes replies (MUL-4936)

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

* fix(agent): harden resumed chat cleanup (MUL-4936)

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

* fix(agent): filter Codex subagents before turn gate (MUL-4936)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 16:54:30 +08:00
Wangjue Yao
adc7636674 MUL-4921: fix(codex): report resumed usage as task delta (#5569)
* fix(codex): report resumed usage as task delta

Codex session logs expose cumulative total_token_usage values. Subtract the pre-task baseline so issue usage does not count earlier turns again.

Refs #5568

* fix(codex): harden resumed usage accounting

* fix(codex): scope fallback usage to current thread

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

* fix(codex): harden rollout usage edge cases

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 14:59:04 +08:00
ZIce
910671b185 Fix Codex task MULTICA_TOKEN passthrough (ZIC-82)
Merge approved PR after independent final review; all required CI checks passed.
2026-07-17 14:29:46 +08:00
Multica Eve
ea8ccf3123 MUL-4903 fix(agent): harden Cursor terminal failures (#5559)
* fix(agent): harden Cursor terminal failures

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

* fix(agent): preserve UTF-8 stderr tails

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:09:20 +08:00
lizhongxuan
6c84664089 test(agent): serialize thinking cache tests (#5551) 2026-07-17 11:20:57 +08:00
Jiayuan Zhang
ed9adc2bbe feat: improve create issue field controls (#5532)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:52 +08:00
milymarkovic
983545b34d KAP-867: retry safe Codex initialize timeouts
Merge reviewed changes after fresh approval and green CI.
2026-07-16 16:35:51 +08:00
Multica Eve
cf1fc64671 MUL-4824: fail closed on incomplete Claude-style result streams (#5522)
* fix(agent): fail closed on incomplete stream results

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

* fix(agent): tighten empty result fallback

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:22:46 +08:00
Multica Eve
5251a0958d fix(daemon): bound silent OpenCode streams (#5523)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:14:36 +08:00
Multica Eve
f0d7a444ac MUL-4860 fix(acp/kiro): GPT-5.6 Sol completion — object rawOutput + positive-proof guard (follow-up to #5511) (#5515)
* fix(kiro): require positive proof for -32603 completion rescue (MUL-4860, #5509)

Addresses the #5511 review (Elon):

Must-fix 1 — a missing result is not proof of success. A mid-command
crash / internal session failure produces the same 'tool use, no result,
-32603' shape as a genuine completion, so the previous 'saw the use but
never saw a result' rescue could mark an unfinished task completed. The
guard now rescues only on POSITIVE proof: a terminal ToolResult with
status=='completed' for a finishing tool (goal_complete / comment-add).

Must-fix 2 — the previous sticky booleans only ever wrote true, so a
later failed delivery could not override an earlier success, and call
ordering was ignored. Completion state is now the status of the MOST
RECENT finishing-tool result (keyed per CallID): 'progress completed →
final failed → -32603' stays failed; 'first failed → retry completed →
-32603' is a real completion.

Comment-add is still recognized by its command payload regardless of the
tool's display title (the #5509 core ask), so a completed comment-add
carrying a non-terminal title is preserved through the close handshake.

Tests: result-less running tool stays failed (both goal_complete and
comment-add), completed non-terminal-title comment-add is preserved,
failed-final-overrides-earlier-success, and completed-retry-after-failure.

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

* fix(acp): don't drop completed tool_call_update when rawOutput is an object (MUL-4860, #5509)

Captured a live kiro-cli 2.12.3 + gpt-5.6-sol ACP trace and found the
true root cause: the completed tool_call_update sends rawOutput as an
OBJECT ({"items":[{"Json":{...}}]}), but handleToolCallUpdate typed
rawOutput/output as Go strings. json.Unmarshal then failed with
'cannot unmarshal object into Go struct field .rawOutput of type string'
and the handler returned early — silently dropping the ENTIRE update,
status:"completed" included. So no completion signal ever reached the
kiro completion-preservation guard, and the durable-but-closed task was
marked failed. This also explains the issue's 'shell tool recorded as
running': the tool_call title is literally 'Running: <cmd>' (kind
execute), which normalizes to 'running', not 'terminal'.

Fix: type rawOutput/output as json.RawMessage and render them via a new
acpRawText helper that accepts both a JSON string and a structured
value. This is a shared ACP-layer fix (hermes/kimi/kiro).

Combined with the earlier payload-based comment-add recognition and the
positive-proof completion guard, the completed result now flows through
and the -32603 close handshake correctly preserves completion.

Adds TestKiroBackendPreservesCompletionOnRealGPT56SolFrames (the exact
captured wire shape end-to-end) and TestACPRawText. Verified the
end-to-end test fails (status=failed) when rawOutput is typed as string
and passes after the fix.

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

* fix(kiro): remove duplicate stale-session branch that faked completion (MUL-4860, #5509)

Addresses Elon's 3rd-round review of #5515. The -32603 guard had two
consecutive, identically-conditioned else-if branches for a stale resumed
session (opts.ResumeSessionID != "" && isACPSessionNotFound(err)). The
first wrongly forced finalStatus="completed" and kept the stale
SessionID; the second — the correct handler that clears SessionID so the
daemon's fresh-session retry fires — was unreachable. So a recovery whose
resumed session was gone at session/prompt time was reported as a fake
success AND skipped the retry.

Remove the bogus first branch, keeping the positive-proof completion
branch and the original stale-session (SessionID="") branch.

Adds TestKiroBackendClearsSessionIDWhenPromptSessionNotFound (the prompt
path; existing coverage only exercised session/set_model). Verified it
returns completed+ses_stale on the pre-fix code and failed+empty
SessionID after. Also gofmt on the touched files.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:36:45 +08:00
Multica Eve
dd692058d7 fix(kiro): preserve completed status on GPT-5.6 Sol -32603 close handshake (#5509) (#5511)
The -32603 completion-preservation guard coupled to the Claude/Kiro ACP
event shape. GPT-5.6 Sol leaves the finishing tool (goal_complete /
'multica issue comment add') parked at 'running' and titles the shell
tool with a name that does not normalize to 'terminal', so:

- isKiroIssueCommentAddTool's msg.Tool=='terminal' gate dropped the tool
  use entirely, and
- with no completed/failed ToolCallUpdate, no ToolResult is emitted, so
  the saw*Completed flags never flipped.

Completed tasks were therefore reversed to failed with
agent_error.provider_server_error ~17s after finishing their work.

Fix:
- Recognize comment-add by its command payload, independent of the
  normalized tool title.
- Track each finishing tool along use / result / completed axes and
  preserve completed when the -32603 close handshake follows a tool use
  that never produced a terminal result. A genuinely failed ToolResult
  still trips saw*Result and keeps the task failed.

Adds regression fixtures for the running-tool path (comment-add and
goal_complete) plus a failed-result safety case.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 12:33:15 +08:00
Tran Quang
6455d390e2 fix(agent): fail opencode runs whose stream ends without a terminal signal (#5238)
* fix(agent): fail opencode runs whose stream ends without a terminal signal

* fix(agent): treat step_finish(reason "tool-calls") as non-terminal in the EOF guard

Review follow-up: step bracketing alone left a false-green window. A tool
step closes with step_finish(reason "tool-calls") before its continuation
step_start, so a stream that dies in that gap had no open step and still
reported "completed".

Parse part.reason (live-probed on opencode 1.17.16: tool loop emits
reasons "tool-calls" then "stop") and keep the run non-terminal after a
"tool-calls" finish until the next step_start. Any other reason —
including absent, for older opencode versions that predate the field —
stays terminal so healthy runs on old protocols are not mass-failed.

Tests: tool-calls finish then EOF now fails; the multi-step happy path
uses the real wire shape ending in reason "stop"; a reason-less
step_finish (legacy protocol) still completes.

* fix(agent): keep stop tool steps pending continuation

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 16:40:59 +08:00
Multica Eve
66316c2614 fix(grok): harden ACP authentication and capabilities (#5440)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 13:35:55 +08:00
Sanjay Ramadugu
06d79c1750 feat(agent): add Grok Build CLI as an ACP runtime (#5285)
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.

Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.

`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).

Closes #2895
2026-07-15 12:30:36 +08:00
Bohan Jiang
6cc553e5a3 fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424) (#5360)
* fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424)

Codex 0.143+ backfills a per-home session-state DB by enumerating every
rollout visible under sessions/ during `initialize`. The per-task
CODEX_HOME symlinked the shared ~/.codex/sessions in, so a machine with a
large accumulated history (one reporter: ~2000 rollouts / ~22 GiB) stalled
`initialize` for tens of seconds — the app-server started but the task
produced no output before it was cancelled (github #5273).

Give each task its own local sessions/ directory instead:

- Fresh task: create an empty local sessions/ so backfill is trivial.
- Reused task with a real sessions/ dir: it is authoritative — leave it.
- Reused task still holding a legacy symlink (older build): migrate in
  place. Replace the symlink with a real dir; when resuming, symlink only
  the single rollout being resumed (never copy — a rollout can be GiB and
  this is on initialize's critical path); and drop the stale, rebuildable
  session-state DB (state_*.sqlite*, session_index.jsonl) so Codex
  re-indexes the task-local sessions. Unrelated per-task DBs (goals_*,
  logs_*, memories_*) are left intact.

Also point the token-usage fallback scan at the backend's per-task
CODEX_HOME instead of the daemon-global home, so usage isn't lost now that
sessions are isolated there.

Complements the #5319 handshake watchdog (which turns a silent stall into a
loud, phased timeout); this removes the underlying cause.

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

* fix(daemon): address Codex session isolation review (MUL-4424)

Resolves the three blockers from Elon's review of #5360:

1. local_directory context loss. local_directory tasks get a fresh
   codex-home per task ID (the daemon never reuses their workdir), so
   task-local isolation stranded every follow-up run with an empty
   sessions dir and silently restarted the conversation. Their only
   stable, GC-safe cross-task store is the user's own ~/.codex/sessions
   (a persistent store under WorkspacesRoot would be orphan-GC'd), so keep
   the shared-sessions symlink for them (IsLocalDirectory). Managed tasks
   stay isolated.

2. Migration resume robustness.
   - Rollout lookup now covers the flat layout and background-compressed
     .jsonl.zst rollouts, not just nested YYYY/MM/DD *.jsonl — both are
     legitimate Codex 0.144 history that were previously judged "not
     found", silently dropping resume.
   - Exposure hard-links first, then symlinks, never copies — hard links
     need no privilege and work on Windows within a volume, so the
     zero-copy path is exercised identically on CI.
   - The daemon now verifies the rollout is actually present in the task
     CODEX_HOME (execenv.CodexResumeRolloutPresent) before the brief is
     generated; if absent it clears the resume from both the backend and
     the brief instead of telling the agent it is continuing a lost thread.

3. session_index.jsonl is no longer deleted during migration — Codex uses
   it as the authoritative thread-id -> name store (not rebuildable from
   rollouts). Only the rebuildable state_*.sqlite* is reset.

Tests: 2-round local_directory resume across task IDs; compressed/flat
lookup; hard-link zero-copy (os.SameFile); session_index preserved;
CodexResumeRolloutPresent + the daemon gate helper (present keeps /
absent drops / non-codex + empty no-op).

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

* fix(daemon): scope Codex sessions to a per-issue store; disclose lost resumes (MUL-4424)

Addresses the three blockers from Elon's second review of #5360.

1. local_directory still enumerated the whole machine history. The prior
   fix re-linked the entire ~/.codex/sessions into every fresh local_directory
   codex-home, so Codex still backfilled from thousands of unrelated rollouts on
   `initialize` (measured ~8.3s with 3450 rollouts; the reporter's 22 GiB could
   exceed the 30s watchdog). Point sessions/ at a persistent, per-(agent, issue)
   store under the shared Codex home (multica-sessions/<agent>/<issue>) that holds
   only this issue's rollouts. It is keyed stably across task IDs and lives
   outside the task-scoped envRoot the GC reclaims, so follow-up runs resume it
   while `initialize` only ever sees this issue's history.

2. Windows cross-volume resume was lost. Exposing a single rollout by hard-link
   (same-volume only) then file symlink (needs Windows privilege) can't cross a
   volume boundary. The store now lives on the shared Codex volume, so the resume
   rollout is hard-linked there zero-copy, and sessions/ is exposed to the task
   home via a directory link — a symlink on Unix, a junction on Windows — which
   crosses volumes without privilege and never copies a (possibly GiB) rollout on
   initialize's critical path. There is no remaining per-file cross-volume link.

3. An unavailable resume was a silent downgrade. Both resume gates
   (gateResumeToReusedWorkdir, gateCodexResumeToRolloutPresence) now set
   PriorSessionResumeUnavailable, and the runtime brief renders a Session
   Continuity Notice telling the agent to disclose to the user, up front in its
   reply, that the previous conversation context could not be restored and this
   run starts fresh — turning a silent restart into a user-visible one. The task
   is not failed: it can still do useful work without the prior context.

Managed fresh / reused-real-dir tasks keep their task-local, GC-collected
sessions dir unchanged; only the legacy-symlink migration with a resume routes
through the store (cross-volume-safe), and a home already linked to the store is
treated as authoritative on reuse.

Tests: local_directory per-issue store (only this issue's history, no whole-
machine leak); no-key fallback to an empty dir; two-round resume across task IDs
through the store; legacy migration routed through the store with a zero-copy
hard link; reused store link stays authoritative; both gates set the
resume-unavailable flag; brief renders the continuity notice only when a resume
was lost. execenv + daemon + pkg/agent packages, go vet, and gofmt all pass.

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

* fix(daemon): disclose live resume-RPC loss; bound Codex session store lifecycle (MUL-4424)

Addresses the two blockers from Elon's third review of #5360.

1. A real thread/resume failure was still a silent new session. The brief's
   Session Continuity Notice only covers losses the daemon detects before launch
   (workdir not reused, rollout absent). But when the rollout is present yet
   Codex rejects the live thread/resume (corrupt/incompatible rollout, server-side
   thread GC, schema drift), startOrResumeThread falls back to thread/start and
   the run succeeds on a fresh thread with no user-facing signal. Carry the
   original resume intent into the backend as ExecOptions.ResumeExpected (set from
   the post-gate PriorSessionID, so a pre-flight drop still routes through the
   brief and never double-notifies), and when a resume was expected but the
   backend landed on a fresh thread, prepend the same continuity notice to the
   first turn/start input. This also covers the daemon's transport-error
   fresh-session retry, which clears ResumeSessionID but not ResumeExpected.

2. The persistent per-issue store had no data lifecycle. multica-sessions stores
   live outside the task-scoped envRoot the GC reclaims (so resume survives across
   task IDs), which meant a done/abandoned issue's prompts and full rollouts (one
   reporter: a single 1.5 GiB rollout) accumulated forever and were never freed on
   issue/agent/workspace deletion. Add PruneCodexSessionStores: the daemon GC loop
   reclaims any store untouched for GCCodexSessionTTL (default 14 days, configurable
   via MULTICA_GC_CODEX_SESSION_TTL, 0 disables). A store's newest rollout mtime is
   its last activity, so an active or recently-resumed task keeps its store fresh
   and is never reclaimed, while a deleted issue's store ages out — an eventual
   reclamation guarantee without needing deletion events.

Tests: codexTurnInput discloses on resume fallback and stays silent on success /
fresh start (paired with the existing live-RPC fallback test); store pruning
reclaims aged stores, keeps recent ones, isolates issues, cleans empty agent
dirs, and is disable-able. execenv / daemon / pkg/agent, go vet, gofmt all pass.

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

* fix(daemon): protect a reopened Codex session store from GC mid-mount (MUL-4424)

Addresses Elon's fourth-review blocker: reopening an issue idle past
GCCodexSessionTTL could lose context, because mounting its per-issue session
store (MkdirAll + rollout lookup + task-home link) never refreshed the store's
mtime, so a GC cycle firing before the resumed turn wrote its first rollout saw
a >TTL-old store and reclaimed it — a stat->remove race with no in-use guard.

Two complementary defenses:

- Activity refresh: linkCodexSessionsToStore now os.Chtimes the store to now
  after linking, so codexStoreStat (which reads the newest mtime as last
  activity) sees a just-used store. This fixes the sequential repro — a mount
  immediately followed by a prune keeps the store.

- In-process active-store guard: the daemon marks the per-issue store in-use
  (execenv.CodexSessionStorePath) from before Prepare/Reuse mounts it until the
  task ends, and PruneCodexSessionStores now takes an isActive predicate and
  skips any store a live task holds. Because prepare and prune run in the same
  process, this closes the remaining concurrent stat->remove window the mtime
  refresh alone cannot. Reference-counted, mirroring the env-root guard.

Tests: a reopened >TTL store survives a GC cycle after remount and stays
resumable; an idle-on-disk store marked active is skipped, then reclaimed once
inactive; the existing idle-reclaim / isolation / disable / empty-agent-dir
cases still pass. execenv + daemon, go vet, gofmt all pass.

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

* fix(daemon): make Codex store delete atomic with mark-active (MUL-4424)

Addresses Elon's fifth-review blocker: the active-store guard's check and delete
were not one atomic step. PruneCodexSessionStores called isActive (which locked,
read, and unlocked) and only then RemoveAll'd, leaving a window where a task
could markActiveCodexStore between the check and the removal and still lose its
store — the exact mark-then-delete interleaving Elon reproduced.

Replace the point-in-time isActive predicate with a reserve-for-deletion
protocol that shares one lock with mark-active:

- reserveCodexStoreForDeletion(store) atomically refuses when a live task holds
  the store (or another delete already reserved it) and otherwise marks it
  reserved, all under one activeCodexStoresMu acquisition. PruneCodexSessionStores
  reserves before RemoveAll and commits after, so confirm-inactive and remove are
  effectively atomic against a concurrent mark.
- markActiveCodexStore now waits (on a sync.Cond) while a store is reserved, so a
  task never mounts a store mid-removal; committing the removal wakes it and the
  store is recreated fresh by Prepare (with the continuity notice).

So mark-before-reserve keeps the store (reserve refused); reserve-before-mark
removes it and blocks the late mark until the removal commits. The genuinely
idle case still reclaims.

Tests (daemon, run under -race): mark-then-reserve is refused; reserve blocks a
concurrent mark until commit then the store reads active; a second reserve is
refused mid-flight. The execenv prune tests move to the reserve seam; the
activity-refresh / reopen-then-prune / isolation / disable cases still pass.

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

* fix(daemon): namespace Codex session stores per profile for cross-daemon safety (MUL-4424)

Addresses Elon's sixth-review blocker: the in-process reservation guard cannot
span processes, but Multica supports multiple profile daemons on one machine
(e.g. production + staging) that share the same ~/.codex. Each daemon's GC
scanned the whole multica-sessions root, so a staging daemon could reclaim a
store a production task was actively resuming — its reservation lived only in the
other process's memory.

Isolate by profile instead of trying to lock across processes:

- Store path is now <shared>/multica-sessions/<namespace>/<agent>/<issue>, where
  namespace is the daemon's profile (empty -> "default"). PrepareParams/ReuseParams
  carry Profile; codexSessionStoreKey and CodexSessionStorePath fold it in.
- PruneCodexSessionStores takes the profile and scans ONLY that namespace, so a
  daemon never even sees another profile's stores, let alone deletes them. The
  per-profile trees are disjoint, so the in-process guard is sufficient within a
  namespace (profiles get separate daemon state, so no two daemons share one).

Test: a "staging"-owned idle store is untouched by a default-profile prune and
reclaimed only by staging's own prune. Existing prune/guard/reopen tests move
under the namespace. execenv + daemon under -race, go vet, gofmt all pass.

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

* fix(daemon): make the Codex store profile→namespace map injective (MUL-4424)

Addresses Elon's seventh-review blocker: the per-profile namespace was derived by
dropping unsafe characters, which is not injective. The CLI treats the empty
(default) profile and a profile literally named "default" as separate daemons,
yet both mapped to namespace "default"; likewise "staging.prod" and "stagingprod"
both mapped to "stagingprod". Two distinct daemons then shared one store tree, so
one could again reclaim the other's live session — the cross-process blocker
reopened for those profile names.

Make codexSessionStoreNamespace injective: the empty profile gets a reserved
bare literal "default", and every named profile is hex-encoded (bijective,
filesystem-safe) under a "p_" prefix a bare literal can never collide with. So
"" -> "default" while "default" -> "p_64656661756c74", and "staging.prod" /
"stagingprod" get distinct hex segments. sanitizeCodexPathSegment stays for the
UUID agent/issue segments (injective for real UUIDs); only the user-controlled
profile needed the encoding.

Tests: codexSessionStoreNamespace is distinct for "" vs "default", punctuation
variants, case variants, and an encoded-looking name; and end-to-end, pruning one
profile never reclaims the other's store for the "" vs "default" and
"staging.prod" vs "stagingprod" pairs. execenv + daemon under -race, go vet,
gofmt all pass.

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

* fix(daemon): fixed-length Codex store namespace so long profiles fit (MUL-4424)

Addresses Elon's eighth-review blocker: hex-encoding the full profile doubled the
namespace segment length. A profile can be as long as a filesystem segment allows
(~255 bytes) and the CLI persists it as its own config dir, but the store
namespace "p_" + hex(profile) reached 2 + 127*2 = 256 bytes at 127 chars,
overflowing the 255-byte single-segment limit — the profile's own dir created
fine, then the session store failed with "file name too long".

Derive the namespace from a fixed-length hash instead: a named profile is now
"p_" + hex(sha256(profile)) — a constant 64 hex chars (66 with the prefix),
filesystem-safe and collision-resistant. The empty (default) profile keeps its
reserved bare literal "default", which the "p_"-prefixed 66-char segment can
never equal. Still injective across the CLI's distinct-daemon cases; just no
longer length-expanding.

Test: the namespace stays <=255 bytes and creatable for profiles up to the
255-byte segment limit (127- and 255-char cases that overflowed under hex); the
prior injectivity and cross-profile prune-isolation tests still hold. execenv +
daemon under -race, go vet, gofmt all pass.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 00:48:30 +08:00
Jordan
c27919a4d0 feat(agents): add DevEco Code (deveco) runtime agent (MUL-4050) (#4916)
Adds DevEco Code (Huawei's HarmonyOS coding agent, built on the OpenCode engine) as a first-class runtime provider: backend/model parser, daemon discovery (probe + login-shell list + Windows .cmd native resolver), runtime profile migration (protocol_family whitelist), provider UI, metrics, and four-language docs. MCP injection is deferred (UI gates it off). Migration numbered 175.
2026-07-14 15:21:45 +08:00
Rusty Raven
ef9b334408 MUL-4398: fix Hermes bound-skill discovery with per-task overlay (#5308)
* fix(execenv): overlay per-task HERMES_HOME so Hermes discovers bound skills

Hermes has no workspace-relative skill discovery — it scans <HERMES_HOME>/skills
first, then skills.external_dirs from config.yaml (verified against the bundled
agent/skill_utils.py). The daemon wrote assigned skills to the generic
.agent_context/skills/ fallback, which Hermes never reads, so they silently never
took effect (#5242).

When (and only when) an agent has skills bound, redirect HERMES_HOME to a minimal
per-task compatibility overlay; a skill-less Hermes task keeps its real home and
original behavior:

- mirror every top-level entry of the shared home via symlink except the
  overlay-owned ones (denylist), reconciling entries deleted from the shared home;
- derive a task-local config.yaml whose skills.external_dirs references the shared
  skills dir plus the user's existing external_dirs, expanded against the
  sanitized effective child env (unknown vars preserved, blocklisted keys resolved
  to the process value) and normalized to absolute paths;
- write only the bound skills into the task-local skills/ dir (home skills scanned
  first, so they win); global skills are referenced, not copied;
- keep memories/ overlay-owned (fresh per-task dir) AND disable the external
  memory.provider, so neither on-disk memory nor a shared backend crosses tasks;
- keep active_profile/profiles out of the overlay so Hermes can't follow a sticky
  profile and redirect past it at startup.

Profile handling mirrors hermes_cli.profiles: the daemon reads -p/--profile with
agent.HermesProfileFromArgs and seeds the overlay from that profile's home via
ResolveHermesSourceHome (default/invalid -> base, valid name -> <base>/profiles/
<name>, validated; a missing named profile fails closed). The profile flags are
stripped from the acp argv ONLY when the overlay is active (hermesLaunchArgs), so
a skill-less task's profile passes through unchanged. HERMES_HOME is no longer
custom_env-blocklisted: no skills -> user value passes through; skills -> overlay
overrides after layering. Fail closed — Prepare errors, Reuse returns nil.
Task home 0700, derived config 0600 via atomic replace. Platform-native default
home (%LOCALAPPDATA%\hermes, incl. the LOCALAPPDATA-missing fallback, on Windows).

Tests span execenv/daemon/agent: no-skill no-op, child-env layering + env
sanitization, profile parse/unquote + conditional strip + final args/env per
scenario, custom/profile/default/invalid/missing/Windows source home, sticky-
profile not mirrored, memory dir isolation + external provider disable, mirror
reconciliation, external_dirs rebasing + sanitized/unknown-var expansion,
local-precedence slug, perms, fail-closed, resume teardown. Docs (en + ja/ko/zh).

Fixes #5242

* fix(hermes): make profile selection one resolver contract matching Hermes

Round 5 review: the profile chain approximated Hermes' semantics in three
separate places (argv parsing, source-home selection, arg filtering), so it
diverged from native Hermes in several merge-blocking cases. Collapse it into
one authoritative resolution:

- agent.ParseHermesProfileArgs replaces HermesProfileFromArgs/
  FilterHermesProfileArgs. It reproduces _apply_profile_override step 1/1b
  (first occurrence, value-flag skipping, `--` and `mcp add --args` boundaries,
  space-form profile-id guard) and returns the exact argv occurrence to consume;
  StripHermesProfileArgs removes only that occurrence.

- execenv.ResolveHermesProfile replaces ResolveHermesSourceHome. It derives the
  Hermes root exactly like get_default_hermes_root (an already-profile-scoped
  HERMES_HOME roots at its grandparent), selects an explicit profile first,
  otherwise trusts a profile-scoped home (step 1.5) and only then the sticky
  <root>/active_profile (step 2), and validates via normalize/validate_profile_name
  (reserved hermes/test/tmp/root/sudo and empty inline `--profile=` are hard
  errors). Profiles always resolve under the root, so `-p default` re-roots and
  `-p <sibling>` is a sibling, never nested.

- The daemon runs one parse + resolve, fails the task closed on a reserved/
  invalid selection (matching Hermes' sys.exit(1)), and exports the selected
  source home as the effective env's HERMES_HOME so ${HERMES_HOME} in a profile's
  skills.external_dirs expands against the selected profile home (as native
  Hermes does before loading config.yaml), not the root or the overlay.

Regressions added: root + sticky named profile selection; already-profile-scoped
home with no flag; that home with -p default and -p <sibling>; reserved and empty
inline profile values; and a selected profile whose external_dirs contains
${HERMES_HOME}.

* fix(hermes): overlay-owned derived .env + symlink-resolved root

Round 6 review, two remaining overlay-bypass paths:

1. A source `.env` could redirect HERMES_HOME after profile resolution. Hermes
   runs `_apply_profile_override()` then `load_hermes_dotenv()`, which loads
   `<HERMES_HOME>/.env` with override=True — so a mirrored source `.env` carrying
   an out-of-band `HERMES_HOME=` overwrote the overlay's home, repointing skill
   discovery and memory back at the source. `.env` is now overlay-owned and
   DERIVED (writeDerivedHermesEnv): it preserves the source's credentials/settings
   but strips any `HERMES_HOME` assignment and pins `HERMES_HOME` to the overlay
   last (single-quoted, literal), written 0600 via atomic replace. It is written
   even when the source has none, so Hermes' project-`.env` fallback (override=True
   only when no user `.env` loaded) can't relocate the home either.

2. Root derivation was lexical-only, diverging from `get_default_hermes_root`,
   which compares `env_path.resolve()` with `native_home.resolve()`. A HERMES_HOME
   symlinked into `<native>/profiles/<x>` was treated as its own root, so
   `-p default`/`-p <sibling>` resolved wrong. `hermesRootFromHomeFor` now resolves
   symlinks (Path.resolve(strict=False)-style best effort) for the containment
   decision while keeping the returned root unresolved, matching Hermes.

Regressions: source `.env` with HERMES_HOME replayed through the override=True
dotenv order (bound skill + task memory stay on the overlay; creds preserved);
minimal overlay `.env` created when the source has none; and a symlinked profile
home resolving `-p default`/`-p <sibling>` to the native root.
2026-07-14 15:05:18 +08:00
Bohan Jiang
7a1a2a9de4 fix(agent): select an offered ACP permission option so Hermes writes aren't denied (MUL-4441) (#5351)
Fixes #5300. The daemon hardcoded optionId="approve_for_session" when auto-approving Hermes' session/request_permission, but Hermes' ACP edit-approval offers only ["allow_once","deny"] and rejects anything else, silently blocking every file write. handleAgentRequest now selects an option the agent actually offered — a safe session/single-use grant, else an offered reject_once to deny just that action, else a JSON-RPC error — never a permanent allow_always or a whole-turn cancelled. Includes regression + branch-coverage tests.
2026-07-14 01:32:17 +08:00
Multica Eve
41b3045efa MUL-4424: bound Codex app-server startup RPCs (#5319)
* fix(codex): bound app-server startup RPCs

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

* test(codex): de-flake bounded-handshake test

The single 500ms handshake bound was shared by the successful preamble
RPCs, so a slow fork/exec of the /bin/sh fake app-server could make
initialize spuriously time out under parallel load. Raise the test bound
to 3s (still below the 5s semantic timeout and 10s harness ceiling) and
loosen the elapsed assertion to match.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-07-13 16:51:34 +08:00
Jiayuan Zhang
a14098288b feat: redesign agent Skills and MCP capabilities (#5277) 2026-07-12 02:53:17 +08:00
Wood
f7ca045fb1 feat(daemon): discover Codex model and reasoning catalog dynamically (#5198)
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
2026-07-10 14:32:05 +08:00
Bohan Jiang
8e0fbecab5 fix(models): codex empty-model effort validation + exact 5.6 aliases (MUL-4347) (#5196)
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>
2026-07-10 14:00:38 +08:00
Bohan Jiang
6b980a8e71 feat(models): add Codex gpt-5.6 series (sol/terra/luna) to model list & pricing (MUL-4347) (#5188)
* 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>
2026-07-10 13:00:34 +08:00
CAVIN
521052a00b fix(daemon): recover stale Claude resume sessions (#5173)
Co-authored-by: CAVIN <zzz163519@users.noreply.github.com>
2026-07-10 12:49:23 +08:00
Matt Van Horn
e36c0cd404 fix: preflight Claude root/sudo launches with an actionable error (#4944)
Detect the root/sudo + bypassPermissions launch condition before starting Claude Code and fail fast with an actionable error (run as non-root, or set IS_SANDBOX=1 in a genuine container/sandbox).

Closes #3278
MUL-4095
2026-07-07 12:19:50 +08:00
ZIce
5dfb0bec06 Fix Codex MCP allowlist config rendering (#4949)
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 16:49:29 +08:00
Multica Eve
083e045ec6 MUL-4103: harden Windows browser MCP config (#4976)
* fix: harden Windows browser MCP config

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

* fix: address browser mcp review nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 16:14:03 +08:00
Multica Eve
f67f0bc9d8 fix: block claude settings flag for antigravity (#4974)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 15:58:38 +08:00
etern
e3de28ecd7 fix(agent): pi agent final output excludes intermediate steps (#4894) (MUL-4030)
* fix(agent): pi agent final output excludes intermediate steps

Updated PI agent to only retain the final result in JSON output.

Previously, `text_delta` included both intermediate steps and final
content.

Now, output is reset on each `text_start` to concatenate only the
final text.

* fix(agent) Replace `message_update.text_start` with `turn_start` event. add test

`turn_start` begins a new turn, Reset output on it to exclude
intermediate texts.

a1b336d73e/packages/coding-agent/docs/rpc.md (events)
2026-07-06 15:34:22 +08:00
Bohan Jiang
346f818206 fix(runtime): add traecli to custom runtime profile whitelist (#4972)
Trae (traecli) already has a New() backend, launch header (traecli acp
serve) and provider branding, but was missing from every protocol_family
whitelist, so custom runtime profiles based on Trae were rejected and it
never appeared in the family picker.

Add traecli to SupportedTypes (Go), RUNTIME_PROFILE_PROTOCOL_FAMILIES
(TS), the lockstep test's want map, and a new migration 136 widening the
runtime_profile_protocol_family_check constraint.

MUL-4094, #4945

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 15:03:17 +08:00
Bohan Jiang
129efb7688 feat(runtime): allow qoder as a custom runtime profile base (#4883) (#4912)
Qoder CN (`qoderclicn`) users could only reach a working custom runtime by
misrouting through the Kiro backend, which launches `<cmd> acp
--trust-all-tools`. That is incompatible with Qoder's global `--acp` / `--yolo`
argv, so the task failed immediately with `kiro initialize failed` and no run
messages.

Expose `qoder` in the custom-profile protocol_family whitelist across every
lockstep layer:
- server/pkg/agent SupportedTypes (+ whitelist pin test)
- migration 134 runtime_profile protocol_family CHECK (NOT VALID, mirroring 126)
- packages/core RUNTIME_PROFILE_PROTOCOL_FAMILIES

The existing qoderBackend already honors an ExecutablePath override and launches
`<cmd> --yolo --acp`, so a profile with protocol_family=qoder and
command_name=qoderclicn now launches with the correct argv instead of the Kiro
shape. Provider branding/logo for qoder already exists.

MUL-4018

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-04 23:16:01 +08:00
Multica Eve
1ff99e5afc fix: honor completed codex turns during process eof races (#4899)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 18:30:24 +08:00
LinYushen
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-07-03 14:18:43 +08:00
ZeroIce
1e85eb0aac Fix Kiro ACP usage accounting (#4867)
Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 12:00:08 +08:00
Andrew Noble
c7e10ffa85 fix(agent): offer no Claude effort levels when the CLI predates --effort
claudeEffortSuperset treated a --help with no --effort line as 'help
format drifted' and fell back to the full level superset. On a host
whose claude binary predates the flag (e.g. 2.1.2), that let
ValidateThinkingLevel approve the agent's persisted thinking_level, so
the daemon injected --effort and the CLI exited 1 with
"error: unknown option '--effort'" — hard-failing every task instead
of degrading to a plain run. Field case: a stale root-owned
/usr/local/bin/claude shadowing a current install for daemons whose
PATH orders /usr/local/bin first (Multica desktop app), while a shell-
launched daemon on the same machine resolved a current claude — the
same agent then failed or succeeded depending on which daemon won the
task claim race.

Distinguish the two cases: --effort present but value list unparsed →
keep the full-superset drift fallback; --effort absent entirely →
return no levels, so the daemon's existing per-model guard drops the
level with a warning and the task runs without the flag.

Co-authored-by: Fable Chief Strategist <noreply@anthropic.com>
2026-07-03 11:58:43 +08:00
ZeroIce
8b529efc09 Fix recovered Antigravity transcript messages (#4814)
Emit the recovered Antigravity transcript reply as a MessageText event so the
execution transcript catches up with Result.Output on empty-stdout recovery.

Closes #4779
MUL-3941
2026-07-02 12:44:37 +08:00
bhirstmedia
170750242b BHI-12314: add Claude Sonnet 5 catalog and pricing support (MUL-3910) (#4783)
Co-authored-by: Ember <ember@Embers-iMac.localdomain>
2026-07-01 14:59:00 +08:00
Multica Eve
f0c3b0911c MUL-3828: fix Cursor and Kiro runtime completion transcripts (#4738)
* fix: preserve cursor and kiro completion transcripts

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

* fix(kiro): tolerate env-prefix and sh -c wrapper in comment-add detection

Harden isKiroIssueCommentAddCommand so the completion-preservation guard
also recognizes 'VAR=x multica issue comment add ...' and
'sh -c "multica issue comment add ..."' invocations. Addresses review nit
on MUL-3828 PR #4738.

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <bhjiang@outlook.com>
2026-07-01 13:23:12 +08:00
Xisheng Parker Zhao
4b9ea4aa68 feat(agent): add ByteDance TRAE CLI (traecli) as an ACP backend (#4724)
Adds the official ByteDance TRAE CLI (the `traecli` binary documented at
https://docs.trae.cn/cli — the product paired with the Trae IDE, not the
open-source bytedance/trae-agent) as a built-in agent backend. traecli is
ACP-native, so it is driven over the standard ACP JSON-RPC transport via
`traecli acp serve --yolo`, reusing the shared hermesClient exactly like the
Kiro and Qoder backends.

Validated end-to-end against the real traecli v0.120.42 with a logged-in
account: initialize advertises loadSession:true + mcpCapabilities{http,sse};
session/new returns result.sessionId + models.availableModels (18 models
discovered); session/prompt streams session/update notifications with
sessionUpdate=agent_message_chunk (hermesClient already normalizes this Zed-ACP
wire shape); a real board task ran 14 tool calls and completed in ~47s.

Implementation:
- server/pkg/agent/traecli.go: ACP backend; session/load resume
  (loadSession:true), session/set_model, MCP via ACP mcpServers, --yolo
  bypass-permissions for headless runs, blocked-arg filtering (acp, serve,
  --yolo, --print, --output-format, --permission-mode)
- agent.go: New() + launch header "traecli acp serve"
- models.go: discoverTraecliModels via the shared discoverACPModels
- daemon/config.go: auto-detect the `traecli` binary
  (MULTICA_TRAECLI_PATH / MULTICA_TRAECLI_MODEL)
- daemon.go: inline the runtime brief (traecli reads .trae/rules/, not
  AGENTS.md) and surface the runtime as "Trae" (providerDisplayName)
- execenv: AGENTS.md + .traecli/skills wiring; ~/.traecli/skills local root
- packages/core mcp-support: traecli consumes mcp_config
- frontend: official Trae provider logo
- docs: providers.mdx matrix + section, CLI_AND_DAEMON.md, README

Tests: fake-ACP unit tests matching the real wire format (streaming,
blocked-arg filtering, session/set_model failure, session/load resume) plus a
gated real-binary smoke test (TestTraecliRealACPSmoke) that skips when traecli
is absent or not logged in. Built-in provider only (mirrors qoder): not in
SupportedTypes / RUNTIME_PROFILE_PROTOCOL_FAMILIES, so no migration is needed.

Resolves #4376.
2026-07-01 13:19:06 +08:00
Bohan Jiang
9ee2bd4c34 fix(agent): recover Antigravity reply from transcript when agy stdout is empty (#4744)
agy 1.0.14 print mode can complete a turn (tools executed, final reply produced) while writing zero bytes to stdout, so the daemon recorded a blank but "completed" run and the user saw no answer (MUL-3726, #4595).

When an otherwise-completed turn returns empty stdout, recover the assistant text agy durably wrote to its per-conversation transcript, bounded to the current turn (reset on each USER_INPUT, status=DONE only) so a resumed conversation never re-emits prior turns' answers. App data dir is read from the daemon-owned --log-file rather than guessing $HOME. All paths fail soft to "" so genuine no-text completions and other statuses are unchanged.

Verified against real agy 1.0.14 output plus unit + end-to-end + resume-boundary tests.
2026-06-30 14:47:16 +08:00
Bohan Jiang
4fb6c0fb0e fix(daemon): bound runtime --version probe so one wedged CLI can't block all runtimes (MUL-3812) (#4685)
* fix(daemon): bound runtime --version probe so one wedged CLI can't block all runtimes

A CLI whose `--version` never returns (e.g. a brew-installed claude wedged
by a bun regression) stalled the daemon's sequential runtime registration
loop forever. Registration runs inside the blocking preflight that gates
/health, so the daemon never flipped from "starting" to "running" and every
runtime on the host appeared disconnected — not just the broken one.

detectCLIVersion now derives a 10s timeout context and sets cmd.WaitDelay so
a node/bun shim that leaves a child holding the stdout pipe open can't defeat
the timeout. A wedged probe now fails fast; the existing per-agent error skip
isolates the broken runtime and the rest register normally.

MUL-3812

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

* test(agent): reap the hang script's orphaned child instead of leaking it

The MUL-3812 regression test spawned a background `sleep 60` that outlived
the killed parent and lingered for up to 60s on CI. The hang script now
records the child's PID and t.Cleanup reaps it, so no temporary process is
left behind.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 12:43:32 +08:00
Bohan Jiang
78d668a2f2 fix(agent): clarify Antigravity daemon mode
Fixes MUL-3726
2026-06-28 13:13:36 +08:00
Multica Eve
aa4478af52 fix(codex): unhang cleanup after stdout scanner overflow (#4520) (#4563)
When codex emits a single stdout line larger than the daemon's 10 MB
bufio.Scanner cap (typical trigger: thread/resume on a long-history
session), the reader goroutine returns scanner.Err()="token too long",
markProcessExited fails the in-flight RPC, and the lifecycle goroutine
enters its failure path. That path calls drainAndWait() — stdin.Close()
+ cmd.Wait() — before sending the failed Result. But cmd.Wait() never
returns: codex is alive and blocked writing the rest of the oversized
line into a stdout pipe nobody is reading, so it never reaches its
stdin read syscall and never sees the EOF. The lifecycle goroutine
therefore never sends Result{failed} to its caller, the outer daemon
blocks on the result channel, and the existing PriorSessionID-with-
empty-SessionID fallback never fires — the task is permanently
stalled and codex (Node wrapper + native Rust app-server) leaks until
the OS reaps them.

The cancel() that would have unblocked things via cmd.WaitDelay's
SIGKILL was registered as a defer AFTER drainAndWait, so LIFO defer
order put cancel last — drainAndWait blocks first, cancel never runs.

Fix:

1. drainAndWait now runs the existing graceful-then-cancel pattern
   itself, in two bounded phases. Phase 1 waits for readerDone (capped
   by codexGracefulShutdownTimeout, so we still give codex its OTEL
   flush window on clean exits); on timeout it cancels the runCtx so
   cmd.Cancel kills the tree and the reader unblocks. Phase 2 bounds
   cmd.Wait() the same way for the scanner-overflow case, where
   readerDone closed early but the process is still alive on a full
   stdout pipe. The success-path cleanup that previously duplicated the
   graceful-cancel pattern around readerDone collapses to a single
   drainAndWait() call.

2. cmd.Cancel is set to send SIGKILL to the whole codex process group
   (Setpgid via configureProcessGroup, signalProcessGroup on cancel)
   instead of just the leader. This addresses YOMXXX's
   orphaned-Codex-child concern: the Node wrapper and the native
   app-server it spawns now both die when cleanup forces the kill,
   rather than the native binary leaking as an orphan reparented to
   init. configureProcessGroup is a no-op on Windows.

3. codexGracefulShutdownTimeoutNanos atomic.Int64 mirrors
   opencodeTerminateGraceNanos so the regression test can shrink the
   grace window from 10 s to 500 ms. Production code is unchanged
   (default 10 s).

Outer daemon (daemon.go) already retries with a fresh session when
result.Status == "failed" && PriorSessionID != "" && result.SessionID
== ""; the failed Result now actually reaches it, so the recovery
fires on its own without any daemon-side change.

Tests:

- New regression TestCodexExecuteCleansUpWhenScannerOverflowsOnResume
  spawns a fake codex that emits an 11 MB single-line thread/resume
  response (trips the scanner cap) and then sleeps without re-reading
  stdin. With the original drainAndWait body it blocks at the 10 s
  executeFakeCodex deadline ("timeout waiting for result") — verified
  by temporarily reverting just the helper body — and with the fix it
  completes in ~1.3 s with Result.Status="failed",
  Result.SessionID="" so the outer fallback can fire.
- Full codex test suite, full agent package, daemon + execenv +
  repocache packages, go build ./..., and go vet on agent/daemon all
  pass.

Out of scope (deferred to follow-up per YOMXXX): bumping the 10 MB
bufio.Scanner cap on codex / claude / copilot / cursor / hermes /
kimi / kiro / codebuddy / antigravity / qoder / openclaw / opencode
(pi already sits at 32 MB), and the shared bounded JSON-RPC line
reader that would eliminate the single-line-overflow risk class
entirely. Buffer size alone is not the fix — recovery behaviour is.

Refs: GH#4520

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 14:46:20 +08:00
Multica Eve
b71d9d0ab9 MUL-3674: Preserve Kiro goal completion on close error (#4560)
* fix(daemon): preserve Kiro goal completion on close error

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

* fix(daemon): require completed Kiro goal marker

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 12:43:04 +08:00
Bohan Jiang
a03055b07d fix(agent): terminate opencode process group before closing stdout (#4533) (#4541)
On cancellation/timeout the opencode backend closed the stdout read end
immediately, leaving the child writing into a closed pipe. Every write then
returns EPIPE and, per anomalyco/opencode#33653, can spin an orphaned process
at 100% CPU — surfacing as high idle CPU after a cancelled task or daemon
restart (MUL-3655).

Cleanup now runs opencode in its own process group and, on cancel, drives a
graceful group-wide SIGTERM → grace → SIGKILL, closing the stdout pipe only as
a last-resort unblock once the tree has been signalled (SIGKILL is uncatchable,
so no member can write again — no EPIPE window). The group signal also reaps
tool subprocesses opencode spawned instead of orphaning them. WaitDelay remains
the hard backstop.

Adds unix tests covering the graceful path and the SIGTERM-ignored → SIGKILL
escalation, asserting the whole process group is reaped and the run never
deadlocks on the scanner. Windows behaviour is unchanged (no process groups).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 00:41:25 +08:00
Bohan Jiang
76c58a4ee8 MUL-3617: remove Gemini CLI runtime (#4503)
* fix: remove gemini cli runtime

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

* fix: skip unsupported custom runtime profiles

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:15:42 +08:00
Bohan Jiang
3103ed1082 fix(agent): surface Antigravity provider log failures (#4494)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 12:45:52 +08:00
Bohan Jiang
ac84b8c70c fix(agent): stop Antigravity turns dying at agy's hidden 5m print-timeout (#4462)
agy's --print-timeout defaults to 5m when the flag is omitted, but the
daemon treated "omit the flag" as "no cap". In the default no-cap config
every Antigravity turn was therefore silently capped at 5 minutes: any
run whose build/tests outlived the budget had agy abort mid-turn, print
"Error: timed out waiting for response", and exit 0 — which the backend
recorded as a successful "completed" with truncated output (the reported
"Antigravity disconnects", MUL-3570 / #4453).

- Always pass --print-timeout: the configured cap when positive, else a
  large value (24h) that defers to the daemon's idle/tool watchdogs.
- Detect agy's print-mode timeout marker in the run log and surface the
  result as a timeout instead of a truncated success.

Verified by reproducing against agy 1.0.8 and with new unit + end-to-end
backend tests.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 18:57:50 +08:00
Naiyuan Qing
4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

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

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

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

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

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

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

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

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

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

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

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

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

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

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

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

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

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

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

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

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

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

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00
DylanLi
78342a39ce MUL-3305: feat(agent): add qoder CLI as a choice of agent provider. (#2461)
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage

- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)

Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden Qoder ACP drain and wire project skills path

- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qoder): add provider logo and wire MCP config into ACP sessions

- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
  the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
  config (object map) into ACP array format for session/new and
  session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
  empty/nil, and multi-server cases

Co-authored-by: Orca <help@stably.ai>

* fix(test): capture both return values from InjectRuntimeConfig in Qoder test

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): preserve remote MCP headers and promote provider errors

Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.

1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
   only forwarded url/type, so an authenticated remote MCP server looked
   configured in Multica but failed inside the Qoder session. Replace it
   with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
   use), which preserves headers as [{name, value}], sorts for
   deterministic output, and handles remote transport aliases. Fail
   closed on malformed mcp_config instead of silently dropping servers.

2. Provider failures could report as completed tasks. stderr was wired
   via io.MultiWriter and the result was only promoted to failed when
   output was empty, so a terminal upstream error (HTTP 429 / expired
   token) racing a stopReason=end_turn with text still became
   "completed". Switch to StderrPipe + an explicit copier, drain it
   (bounded by the existing grace window, since qodercli can leave a
   child holding the inherited fds) before the decision, and run the
   shared promoteACPResultOnProviderError.

Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): align ACP session handling

Co-authored-by: Orca <help@stably.ai>

* fix(agent): guard qoder late output after drain

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

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:55:45 +08:00