Compare commits

..

80 Commits

Author SHA1 Message Date
Jiayuan Zhang
16f7be57c4 fix(cli): use localhost for CLI callback when app URL is a public hostname
When `multica login` runs against production (multica.ai), the CLI was
using the app URL hostname as the callback host, producing a callback
URL like `http://multica.ai:PORT/callback`. This URL fails frontend
validation (which only allows localhost and private IPs) and can't
actually reach the CLI's local HTTP server.

Now only private IPs (RFC 1918) are used as the callback host, which
matches the intended self-hosted LAN scenario. Public hostnames
correctly fall back to localhost.

Fixes #974
2026-04-14 17:45:42 +08:00
Bohan Jiang
7f0c23a6ba Merge pull request #960 from blackhu0804/test/cli-client-context-headers
test(cli): cover API client context headers
2026-04-14 17:14:12 +08:00
Bohan Jiang
e6767d2ba3 Merge pull request #968 from multica-ai/agent/j/0ae3c9f0
docs: add manual testing checklist to PR template
2026-04-14 17:06:30 +08:00
Bohan Jiang
1ceb75e218 Update PULL_REQUEST_TEMPLATE.md 2026-04-14 17:06:13 +08:00
Jiang Bohan
9138c05993 docs: revise PR checklist to match Paperclip-style format
Replace the original checklist + manual testing section with a
unified checklist modeled after the Paperclip open-source project:
thinking path, model disclosure, local tests, test coverage,
UI screenshots, documentation, risk assessment, and reviewer comments.
2026-04-14 17:04:18 +08:00
Naiyuan Qing
091ed7370a Merge pull request #953 from multica-ai/fix/editor-bubble-menu-v2
fix(editor): fix BubbleMenu dropdown clicks by replacing DropdownMenu with Popover
2026-04-14 16:47:18 +08:00
Naiyuan Qing
35557c0b11 fix(test): add missing selection mock in ContentEditor test
The merge from main introduced `editor?.state.selection.empty` in
ContentEditor. The test mock was missing `state.selection`, causing
a TypeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:44:35 +08:00
Naiyuan Qing
03ad47200b merge: resolve conflict with main (accept link-preview.tsx deletion)
# Conflicts:
#	packages/views/editor/link-preview.tsx
2026-04-14 16:39:24 +08:00
Bohan Jiang
93b754de53 Merge pull request #969 from multica-ai/agent/j/696a5ce1
docs: add v0.1.33 changelog (2026-04-14)
2026-04-14 16:37:08 +08:00
Jiang Bohan
609d2e06ae docs: remove desktop auto-update from v0.1.33 changelog 2026-04-14 16:35:59 +08:00
Jiang Bohan
7c436c0dcb docs: add v0.1.33 changelog entry (2026-04-14) 2026-04-14 16:33:41 +08:00
Naiyuan Qing
55ae78b902 fix(editor): replace DropdownMenu with Popover in BubbleMenu to fix focus
Base UI's DropdownMenu uses FloatingFocusManager which steals focus from
the editor (initialFocus + closeOnFocusOut), causing the BubbleMenu to
hide before dropdown item clicks can register. Popover supports
initialFocus={false} and finalFocus={false}, keeping editor focus intact
throughout the interaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:32:02 +08:00
Jiang Bohan
cc00fda513 docs: add manual testing/acceptance checklist to PR template
Adds a new "Manual Testing / Acceptance" section to the PR template
with checklist items for verifying changes in a real environment:
happy path, edge cases, visual regressions, cross-platform testing,
API consumer compatibility, and log inspection.
2026-04-14 16:25:55 +08:00
Bohan Jiang
04e571b02f Merge pull request #964 from multica-ai/feat/agent-env-tab
feat(views): extract environment variables into separate agent tab
2026-04-14 16:18:12 +08:00
Jiang Bohan
c62bd0ca12 feat(views): extract environment variables into separate agent tab
Move the Environment Variables section from the Settings tab into its
own "Environment" tab (KeyRound icon) between Tasks and Settings. Each
tab now has independent save state.
2026-04-14 16:06:06 +08:00
Bohan Jiang
51c7dbbeee Merge pull request #962 from multica-ai/fix/editor-link-preview-mount-crash
fix(editor): avoid accessing editor.view during initial render in link preview
2026-04-14 15:48:38 +08:00
Jiang Bohan
46d745cb60 fix(editor): avoid accessing editor.view during initial render in link preview
EditorLinkPreview's useRef initializer accessed editor.view?.dom which
throws when the editor view is not yet mounted (Tiptap uses a Proxy
that rejects property access before mount). Defer the contextElement
assignment to the selectionUpdate callback where the view is guaranteed
to exist.
2026-04-14 15:47:52 +08:00
Bohan Jiang
0a998d1cef Merge pull request #846 from multica-ai/agent/j/feb218fd
feat(agent): support custom environment variables for router/proxy mode
2026-04-14 15:34:21 +08:00
Bohan Jiang
a366984014 Merge pull request #961 from multica-ai/fix/comment-trigger-new-tag
fix(daemon): emphasize NEW comment in trigger prompt to prevent session confusion
2026-04-14 15:27:43 +08:00
Jiang Bohan
9ba9ea66f8 fix(daemon): emphasize NEW comment in trigger prompt to prevent session confusion
When a comment-triggered task resumes an existing session, the agent
may mistake the new comment for a previous one and skip it. Add [NEW
COMMENT] tag to the prompt and reinforce in AGENTS.md workflow that
the agent must respond to THIS specific comment, not prior ones.
2026-04-14 15:26:49 +08:00
Bohan Jiang
2be6fdae90 Merge pull request #956 from yyy9942/fix/cancel-task-race-condition
fix(server): handle cancel request for already-completed tasks gracefully
2026-04-14 15:20:44 +08:00
Bohan Jiang
653c0adeee Merge pull request #959 from multica-ai/agent/j/e52c9eda
fix(views): issue mentions missing status/title after page refresh
2026-04-14 15:18:06 +08:00
yyy9942
4458753102 fix(server): handle cancel request for already-completed tasks gracefully
When a task finishes between the UI rendering the Stop button and the
user clicking it, CancelAgentTask returns no rows. Previously this
surfaced as a 400 error. Now CancelTask checks for pgx.ErrNoRows and
returns the current task state instead of an error.

Closes #954
2026-04-14 16:15:22 +09:00
black-fe
3c0ed0f732 test(cli): cover API client context headers 2026-04-14 15:13:54 +08:00
Naiyuan Qing
999d0728c5 fix(editor): remove preventDefault from dropdown triggers in BubbleMenu
The onMouseDown preventDefault on HeadingDropdown and ListDropdown
triggers was interfering with Base UI's menu event flow, causing:
- Dropdown appearing at top-left corner (positioning mismatch)
- Menu item clicks not applying formatting

The BubbleMenu plugin's own preventHide mechanism (capture-phase
mousedown listener) already handles preventing the menu from hiding
during dropdown interaction. Our extra preventDefault was redundant
and conflicting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:13:32 +08:00
Jiang Bohan
b6a69c113e fix(views): fetch individual issue for mentions not in list cache
Issue mentions in comments showed only the identifier (no status icon
or title) after page refresh when the referenced issue wasn't in the
issueListOptions cache (e.g. done issues beyond the first 50).

Fall back to issueDetailOptions to fetch the individual issue when it's
not found in the list. The detail query is only enabled when the issue
is missing from the list, so it adds no overhead for the common case.
2026-04-14 15:03:42 +08:00
Bohan Jiang
7995f7368f Merge pull request #957 from multica-ai/agent/j/9ecd3271
fix(issues): include done issues in parent/sub-issue picker
2026-04-14 14:56:47 +08:00
Jiang Bohan
ed1a1dc6b1 fix(issues): include done/cancelled issues in parent/sub-issue picker search
The IssuePickerDialog was not passing include_closed: true to searchIssues,
so done and cancelled issues were invisible in the picker.
2026-04-14 14:55:42 +08:00
Naiyuan Qing
97755ae45d feat(editor): add link hover card with URL preview and actions
Show a floating card on link hover with truncated URL, Copy and Open
buttons. Uses @floating-ui/dom computePosition portaled to body
(escapes overflow:hidden). 300ms show delay, 150ms hide delay with
card hover support.

- New link-hover-card.tsx with useLinkHover hook + LinkHoverCard
- Integrated in ContentEditor (disabled when BubbleMenu active)
- Integrated in ReadonlyContent (always active)
- Styled with popover design tokens (matches bubble-menu)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:53:32 +08:00
Bohan Jiang
7a896d3852 Merge pull request #897 from multica-ai/agent/j/177ad75f
fix(openclaw): handle pretty-printed multi-line JSON output
2026-04-14 14:49:04 +08:00
Bohan Jiang
da63165cdc Merge pull request #955 from multica-ai/agent/j/3c269006
fix(issues): update UI immediately when parent/sub-issue changes
2026-04-14 14:48:38 +08:00
Jiang Bohan
013584ef80 fix(issues): invalidate new parent's children cache on parent_issue_id change
Both the useUpdateIssue mutation and the WS onIssueUpdated handler only
invalidated the OLD parent's children query. When parent_issue_id changes,
the new parent's sub-issues list was stale until page refresh.
2026-04-14 14:46:55 +08:00
Jiang Bohan
bb4944bae2 fix(openclaw): handle pretty-printed multi-line JSON output
OpenClaw outputs its --json result as pretty-printed multi-line JSON to
stderr. The line-by-line scanner never found a valid JSON object on any
single line, causing the raw JSON to be returned as the chat response.

After exhausting line-by-line parsing, try parsing the accumulated
output as a whole before falling back to raw text.

Closes MUL-725
2026-04-14 14:39:32 +08:00
Bohan Jiang
42e392c727 Merge pull request #950 from multica-ai/agent/j/6b9aa53b
feat(cli): add --parent flag to issue update command
2026-04-14 14:37:12 +08:00
Bohan Jiang
158a100779 Merge pull request #949 from multica-ai/agent/j/73a6b30b
feat(issues): add parent/sub-issue linking via More menu
2026-04-14 14:36:59 +08:00
Naiyuan Qing
e178682acd fix(editor): use native BubbleMenu and simplify link click
BubbleMenu:
- Replace custom useFloating + createPortal with Tiptap's native
  <BubbleMenu> component (battle-tested focus management)
- Add scrollTarget (auto-detect nearest scroll container) so the
  plugin repositions on scroll
- Add scroll-aware display:none for nested container clipping
  (plugin's hide middleware can't detect it — virtual element has
  no contextElement)
- Add .trim() to textBetween check to filter whitespace-only selections
- Enable hide middleware for viewport-level hiding

Link click:
- Both editable and readonly modes now open links directly
- Remove EditorLinkPreview component and link-preview.tsx entirely
- ReadonlyContent links use same direct-open pattern

Cleanup:
- Delete link-preview.tsx (not needed)
- Remove @floating-ui/react-dom dependency
- Remove .link-preview-card CSS
- Add @tiptap/extension-bubble-menu dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:34:52 +08:00
Bohan Jiang
8779db976c Merge pull request #948 from multica-ai/agent/j/400a618f
feat(agent): add live log support for Gemini CLI
2026-04-14 14:31:52 +08:00
Jiang Bohan
eba68c15fd feat(cli): add --parent flag to issue update command
Allows setting or clearing an issue's parent via the CLI:
  multica issue update <id> --parent <parent-id>
  multica issue update <id> --parent ""  # clear parent
2026-04-14 14:24:19 +08:00
Jiang Bohan
345cb984a9 feat(issues): add "Set parent issue" and "Add sub-issue" to More menu
Add two new options to the issue detail More dropdown that let users
link existing issues as parent or sub-issue via a search dialog.
2026-04-14 14:20:48 +08:00
Jiang Bohan
f3355049bc feat(agent): add live log support for Gemini CLI via stream-json
Switch Gemini backend from `-o text` (batch output) to `-o stream-json`
(NDJSON streaming) so tool calls, text, and errors are forwarded to the
UI in real time instead of collected at the end.

Parses all Gemini stream-json event types: init, message, tool_use,
tool_result, error, and result — including per-model token usage from
the result stats.
2026-04-14 14:17:13 +08:00
Naiyuan Qing
dca86acc69 Merge pull request #938 from 1WorldCapture/fix/lyo-7-description-click-focus
fix(views): focus description editor when clicking empty area
2026-04-14 14:06:32 +08:00
Bohan Jiang
c71525e198 Merge pull request #910 from multica-ai/agent/j/openclaw-p0-p1
feat(agent): OpenClaw backend P0+P1 improvements
2026-04-14 14:02:38 +08:00
devv-eve
977dc6479d fix(daemon): prevent task stall when agent process hangs on stdout (#947)
When an agent CLI process hangs (e.g. a tool call blocks on unreachable
I/O), the daemon's scanner blocks indefinitely on stdout, preventing the
Result from ever being sent. This causes tasks to stay in "running"
state permanently with no further events.

Three-layer fix:

1. Agent backends (claude, opencode, openclaw, gemini): add a watchdog
   goroutine that closes the stdout/stderr pipe when the context is
   cancelled, forcing the scanner to unblock. Also set cmd.WaitDelay
   so Go force-closes pipes after 10s if the process doesn't exit.

2. daemon executeAndDrain: add an independent drain timeout (backend
   timeout + 30s buffer) with context-aware select on both the message
   channel and the result channel, so the daemon never blocks forever.

3. daemon ping path: add context-aware select so pings don't deadlock
   if the agent backend stalls.

Closes #925

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:00:27 -07:00
Jiayuan Zhang
a97bd3da0b fix(auth): support non-localhost CLI callback for self-hosted VMs (#944)
The CLI auth callback was hardcoded to localhost, breaking self-hosted
setups where the browser runs on a different machine than the CLI.

- CLI: derive callback host from configured app URL; bind to 0.0.0.0
  when the app URL is not localhost so remote browsers can reach it
- Frontend: expand validateCliCallback to accept RFC 1918 private IPs
  (10.x, 172.16-31.x, 192.168.x) in addition to localhost

Closes #923
2026-04-14 13:50:02 +08:00
Jiayuan Zhang
9dfe119f47 fix(daemon): use runtime's owner_id for agent migration on upgrade (#941)
* fix(daemon): prevent duplicate runtime registration on profile switch

The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.

Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
  The unique constraint (workspace_id, daemon_id, provider) already
  prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
  runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
  with no active agents after 7 days.

Closes MUL-695

* fix(daemon): address code review issues on PR #906

1. Move gcRuntimes() to the main sweep loop — previously it was inside
   sweepStaleRuntimes() after an early return, so it only ran when new
   runtimes were marked stale. Now it runs every sweep cycle independently.

2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
   reference (not just active ones). The FK agent.runtime_id is ON DELETE
   RESTRICT, so archived agents also block deletion.

3. Scope MigrateAgentsToRuntime to the same machine by matching
   daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
   agent migration when the same user has multiple devices.

* fix(daemon): use runtime's owner_id for agent migration, not caller's

The migration was gated on ownerID.Valid which is only true for PAT/JWT
registrations. Daemon token registrations (the common case for background
daemon restarts) had ownerID as zero, skipping migration entirely.

Fix: use registered.OwnerID (preserved via COALESCE on upsert) instead
of the caller's ownerID. This ensures migration runs even when the daemon
re-registers via daemon token after an upgrade.
2026-04-14 13:42:27 +08:00
Bohan Jiang
f2efd4b529 Merge pull request #942 from multica-ai/agent/j/e9dce818
fix(cli): fix Windows login requiring two attempts
2026-04-14 13:19:46 +08:00
Jiang Bohan
a1de20e971 fix(cli): fix Windows login requiring two attempts
On Windows, `cmd /c start <url>` treats `&` in the URL as a shell
command separator, truncating the login URL at the first `&cli_state=`
parameter. This causes the OAuth state validation to fail silently,
requiring users to login a second time.

Adding an empty title argument (`""`) before the URL is the standard
Windows fix — `start` interprets the first quoted argument as a window
title, so without it, URLs containing special characters get mangled.
2026-04-14 13:10:28 +08:00
Jiayuan Zhang
27d0865f5f Merge pull request #920 from sanjay3290/fix/gemini-timeout-status
fix(daemon): correct Gemini backend status on timeout and cancellation
2026-04-14 13:03:17 +08:00
Bohan Jiang
2cd6024851 Merge pull request #820 from zoharbabin/feat/local-storage-and-stdin
feat(cli): add --content-stdin flag to issue comment add
2026-04-14 13:02:01 +08:00
Bohan Jiang
5e74c411dc fix(server): cancel active tasks when issue status changes to cancelled (#940)
When a user cancels an issue, active agent tasks now get cancelled
automatically. Previously, task cancellation only triggered on assignee
changes — the cancelled status was incorrectly treated like any other
agent-managed status transition.

Closes #926
2026-04-14 12:53:45 +08:00
Lyon Liang
418049856f merge: resolve conflicts with upstream/main 2026-04-14 12:49:40 +08:00
Naiyuan Qing
00042c0ec7 Merge pull request #932 from multica-ai/NevilleQingNY/weekly-commit-analysis
fix(editor): rewrite bubble menu, link handling, and link preview cards
2026-04-14 12:03:17 +08:00
devv-eve
7c7d7feed3 fix(storage): scope S3 upload keys by workspace (#936)
* fix(storage): scope S3 upload keys by workspace

Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.

Refs: MUL-577

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

* fix(storage): scope user uploads under users/{user_id}/ prefix

Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.

Refs: MUL-577

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

* fix(storage): fix LocalStorage for nested key paths

- Add MkdirAll before WriteFile to create intermediate directories
  for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
  of stripping to just the filename
- Update tests to match new behavior

Refs: MUL-577

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

* fix(upload): validate ownership before writing to storage

Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.

Refs: MUL-577

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

* fix(upload): restore workspace membership check before upload

The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.

Also restores the comment explaining the 200-on-DB-error behavior.

Refs: MUL-577

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:01:50 -07:00
Naiyuan Qing
6a451c1ce7 fix(editor): rewrite bubble menu and link preview with useFloating
Replace Tiptap's BubbleMenu plugin with @floating-ui/react-dom for
all floating editor UI (formatting toolbar, link preview cards).

Architecture:
- useFloating({ strategy:"fixed" }) + createPortal(body) escapes
  all overflow:hidden ancestors (Card component, scroll containers)
- autoUpdate + contextElement monitors all scroll ancestors for
  repositioning; manual update() on transaction for virtual ref changes
- open prop resets isPositioned on visibility change (no stale-position
  flash at 0,0)
- display:none for hiding (not return null which causes blur/focus
  cycle, not visibility:hidden which leaves transition artifacts)
- No blur listener — portal DOM updates cause false editor blurs;
  outside-click + scroll + resize + Escape handle all close cases

Bug fixes:
- BubbleMenu: remove all custom visibility hacks, let selection state
  drive show/hide
- Link preview: new shared card (Copy + Open) for editable editor and
  readonly markdown, portaled to body with fixed positioning
- TitleEditor: use JSON content format (not HTML interpolation that
  loses < > characters)
- Blob URLs: strip from getMarkdown output during upload
- Markdown paste: check clipboard.files first to avoid intercepting
  file paste events
- FileCard: escape HTML attributes in preprocessing
- Link extension: enable linkOnPaste, set defaultProtocol to https,
  switch URL normalization to protocol blocklist (only block
  javascript:/data:/vbscript:)

Dependencies: add @floating-ui/react-dom, remove @tiptap/extension-bubble-menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:00:17 +08:00
devv-eve
8c0708bb5d fix(server): validate workspace membership for subscriptions and uploads (#935)
* fix(server): validate workspace membership for subscription targets and file uploads

Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.

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

* test(server): add negative tests for cross-workspace subscription and upload

Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
  subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
  unknown user types

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:22:03 -07:00
Lyon Liang
9170b01739 fix(views): focus description editor when clicking empty area 2026-04-14 11:04:58 +08:00
Zohar Babin
d37595b85e fix(cli): address review feedback on --content-stdin flag
- Make --content and --content-stdin mutually exclusive with explicit error
- Use TrimSuffix instead of TrimRight to only strip the trailing newline
- Return "stdin content is empty" instead of misleading "required" error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 19:14:55 -04:00
Jiayuan Zhang
03310a581a doc: document Homebrew CLI installation (#921) 2026-04-14 05:31:10 +08:00
Sanjay Ramadugu
fe0d450471 fix(daemon): correct Gemini backend status on timeout and cancellation
Check runCtx.Err() before readErr/waitErr so that context-driven
process kills (timeout, user cancellation) report the correct status
("timeout" or "aborted") instead of "failed".

When exec.CommandContext kills the gemini process, io.ReadAll can
return a non-nil error as a side-effect of the closed pipe. The
previous code checked readErr first, masking the real cause. This
aligns gemini.go with the ordering already used in claude.go and
hermes.go.

Fixes #914
2026-04-13 16:30:28 -04:00
Jiayuan Zhang
bc1185f525 Merge pull request #755 from sanjay3290/feat/gemini-backend
feat(daemon): add Google Gemini CLI backend
2026-04-14 02:46:20 +08:00
Bohan Jiang
0d95a7c7ef fix(auth): increase email verification code resend cooldown to 60s (#912)
The 10-second cooldown was too short. Increase to 60 seconds in both
frontend countdown timer and backend rate limit.
2026-04-14 02:35:34 +08:00
KimSeongJun
8587243ab6 web: stabilize login and dashboard redirects (#900) 2026-04-14 02:26:24 +08:00
Bohan Jiang
740d8e773d Merge pull request #841 from multica-ai/agent/j/7bb4859f
feat(desktop): version detection + one-click update
2026-04-14 02:19:25 +08:00
Jiang Bohan
9550e6c4e0 fix(desktop): prevent double-click download and fix dismiss behavior
- Guard handleDownload to only trigger from "available" state
- Only allow dismiss when update is available, not during download/ready
- Use shadcn design tokens (text-success) instead of hardcoded colors
2026-04-14 02:18:55 +08:00
Jiayuan Zhang
880c614039 Merge pull request #905 from multica-ai/agent/emacs/3a193323
fix(issue): inherit parent project for sub-issues
2026-04-14 02:06:26 +08:00
Jiayuan Zhang
f1f693afa5 fix(cli): redirect to web onboarding when new user has no workspaces (#903)
* fix(cli): auto-create workspace for new users during setup

When a new user runs `multica setup` and has no workspaces,
the onboarding flow now auto-creates a default workspace
(named "<name>'s Workspace") instead of failing when the
daemon tries to start with zero watched workspaces.

As a safety net, setup commands also skip daemon start
gracefully if no workspaces are configured, instead of
erroring out.

* fix(cli): redirect to web onboarding instead of auto-creating workspace

When no workspaces exist, the CLI now opens the web onboarding wizard
in the browser and polls until the user completes workspace creation.
This reuses the existing 4-step onboarding flow (workspace → runtime →
agent → done) instead of duplicating creation logic in the CLI.

* fix(cli): address code review — token login crash and misleading success msg

1. Token login (`multica login --token`) on a fresh account no longer
   crashes: waitForOnboarding uses tryResolveAppURL (returns "" instead
   of os.Exit(1)) and falls back to printing manual instructions.

2. Setup commands no longer print "✓ Setup complete!" when onboarding
   was not finished. Shows "⚠ Setup incomplete" with next steps instead.
2026-04-14 02:04:53 +08:00
Jiang Bohan
c148288d5a merge: resolve conflicts with main (deep linking + auto-updater)
Integrate deep link protocol handling, desktopAPI, and auth token flow
from main alongside the auto-updater feature.
2026-04-14 02:02:00 +08:00
Jiayuan Zhang
ff5f6ac2ee fix(daemon): prevent duplicate runtime registration on profile switch (#906)
* fix(daemon): prevent duplicate runtime registration on profile switch

The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.

Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
  The unique constraint (workspace_id, daemon_id, provider) already
  prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
  runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
  with no active agents after 7 days.

Closes MUL-695

* fix(daemon): address code review issues on PR #906

1. Move gcRuntimes() to the main sweep loop — previously it was inside
   sweepStaleRuntimes() after an early return, so it only ran when new
   runtimes were marked stale. Now it runs every sweep cycle independently.

2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
   reference (not just active ones). The FK agent.runtime_id is ON DELETE
   RESTRICT, so archived agents also block deletion.

3. Scope MigrateAgentsToRuntime to the same machine by matching
   daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
   agent migration when the same user has multiple devices.
2026-04-14 01:52:34 +08:00
Jiang Bohan
a0d43ca31a feat(agent): OpenClaw backend P0+P1 improvements
Combined P0 and P1 improvements to the OpenClaw agent backend, informed
by PaperClip's adapter architecture:

P0 — User experience:
- Streaming output — emit MessageText as NDJSON events arrive in real
  time, instead of waiting for the final result blob
- Tool use support — parse and emit MessageToolUse/MessageToolResult
  from streaming events, matching Claude and OpenCode backends
- Model & system prompt — pass --model and --system-prompt to the
  OpenClaw CLI when configured

P1 — Robustness:
- Hardened JSON parsing — tryParseOpenclawResult requires lines to
  start with '{', eliminating fragile brace-scanning that could
  false-match JSON fragments in log lines
- Lifecycle event handling — new "lifecycle" event type with phase
  tracking (error/failed/cancelled), plus structured error objects
  (error.name, error.data.message) matching PaperClip's pattern
- Usage field name variants — parseOpenclawUsage supports multiple
  naming conventions (input/inputTokens/input_tokens, cacheRead/
  cachedInputTokens/cache_read_input_tokens, etc.) with incremental
  accumulation across step_finish events

Backwards compatible with the legacy single JSON blob format.
31 tests covering all new functionality.

Closes MUL-726
2026-04-14 01:52:03 +08:00
Jiayuan Zhang
a29ecfe02a test(issue): cover explicit sub-issue project 2026-04-14 01:51:48 +08:00
Jiayuan Zhang
8d3cb21c03 fix(auth): detect cookie-based session during CLI setup flow (#904)
* fix(auth): detect cookie-based session during CLI setup flow

When users run `multica setup` after logging into multica.ai, the CLI
redirects to the login page which only checked localStorage for an
existing session. Since the web app stores auth tokens as HttpOnly
cookies (not localStorage), the session was never detected and users
had to log in again.

Now the login page also tries `api.getMe()` (which sends the HttpOnly
cookie automatically) when no localStorage token exists. A new
`POST /api/cli-token` endpoint lets cookie-authenticated sessions
obtain a bearer token to hand off to the CLI.

* fix(auth): prioritise cookie auth over localStorage in CLI setup flow

Address code review feedback: cookie-first detection avoids authorising
the CLI with a stale or mismatched localStorage token. The useEffect now
calls getMe() without a bearer token first (relying on the HttpOnly
cookie), and only falls back to localStorage if cookie auth fails.

handleCliAuthorize uses an authSourceRef to pick the matching token
source — issueCliToken for cookie sessions, localStorage for token
sessions — preventing the click handler from re-reading a potentially
stale localStorage entry.
2026-04-14 01:46:14 +08:00
Bohan Jiang
2b16cbb27a Merge pull request #908 from multica-ai/agent/j/8cd32f71
fix(selfhost): auto-derive WebSocket URL for LAN access
2026-04-14 01:45:01 +08:00
Jiang Bohan
a757f3a8c4 fix(selfhost): auto-derive WebSocket URL for LAN access (#896)
When NEXT_PUBLIC_WS_URL is not set, the WebSocket URL defaulted to
ws://localhost:8080/ws. This broke real-time features (chat streaming,
live updates, notifications) for self-hosted deployments accessed over
LAN — the browser tried connecting to localhost on the client machine
instead of the Docker host.

Now the web app derives the WebSocket URL from window.location, routing
through the existing Next.js /ws rewrite. This works for localhost, LAN,
and custom domain setups without any extra configuration.

Also adds NEXT_PUBLIC_WS_URL as a Docker build arg for explicit override,
and documents LAN access configuration in SELF_HOSTING_ADVANCED.md.

Closes #896
2026-04-14 01:42:42 +08:00
Jiayuan Zhang
56c38dc521 fix(issue): inherit parent project for sub-issues 2026-04-14 01:30:40 +08:00
Jiayuan Zhang
4bc9969765 fix(scripts): use fully qualified brew package name in install.sh (#901)
BREW_PACKAGE="multica-ai/tap/multica" was defined but never used.
All brew install/upgrade/list commands used the bare name "multica",
which could fail to resolve the correct tap formula. Replace all
occurrences with "$BREW_PACKAGE" to match the Go CLI (update.go)
and Makefile behavior.
2026-04-14 01:19:45 +08:00
Jiang Bohan
a73a9d4036 fix(agent): address PR review — env var blocklist, unmarshal logging, stable React keys
1. Security: add isBlockedEnvKey() blocklist that rejects MULTICA_*
   prefix and critical system vars (HOME, PATH, USER, SHELL, TERM,
   CODEX_HOME) from custom_env injection
2. Observability: log warnings when json.Unmarshal fails on custom_env
   (agentToResponse + claim endpoint)
3. UX: use stable auto-increment IDs for env entry React keys instead
   of array index to prevent input focus/state issues on add/remove
2026-04-13 17:39:02 +08:00
Jiang Bohan
4165401d16 feat(agent): support custom environment variables for router/proxy mode
Add per-agent custom_env configuration that gets injected into the agent
subprocess at launch time. This enables users to configure custom API
endpoints (ANTHROPIC_BASE_URL), API keys (ANTHROPIC_API_KEY), and cloud
provider modes (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) without
requiring code changes.

Changes:
- Migration 040: add custom_env JSONB column to agent table
- Backend: custom_env in agent CRUD API + claim endpoint
- Daemon: merge custom_env into subprocess environment variables
- Frontend: env var editor in agent settings (key-value pairs with
  visibility toggle for sensitive values)

Closes #816
Related: #807, #809
2026-04-13 16:47:56 +08:00
Jiang Bohan
e5881601ad feat(desktop): add auto-update with GitHub releases
Check for updates on startup via electron-updater. When a new version is
detected, show a notification in the bottom-right corner with download
and restart-to-install actions.
2026-04-13 15:31:58 +08:00
Zohar Babin
77dbcaefad feat(cli): add --content-stdin flag to issue comment add
Allow agents to pipe comment content through stdin instead of the
--content flag, avoiding shell escaping issues with backticks, quotes,
and other special characters in markdown content.

Usage: cat <<'COMMENT' | multica issue comment add <id> --content-stdin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:23:16 -04:00
Sanjay Ramadugu
f99f50eb0c feat(daemon): add Google Gemini CLI backend
Registers `gemini` as a sixth supported agent provider alongside claude,
codex, opencode, openclaw, and hermes.

- Daemon config probes for `gemini` on PATH (MULTICA_GEMINI_PATH /
  MULTICA_GEMINI_MODEL env overrides mirror the other providers).
- New agent.geminiBackend in pkg/agent/gemini.go: spawns
  `gemini -p <prompt> --yolo -o text [-m <model>] [-r <session>]`,
  reads stdout to completion, and returns a single MessageText plus
  the standard Result struct (Status / Output / DurationMs).
- Execution environment writes a GEMINI.md file into the task workdir
  (mirroring the existing CLAUDE.md / AGENTS.md injection for other
  providers) so Gemini discovers the Multica runtime meta-skill
  through its native mechanism.

Tests:

- pkg/agent/gemini_test.go — unit coverage for buildGeminiArgs
  (baseline, model override, resume session, omit-when-empty).
- internal/daemon/execenv/TestInjectRuntimeConfigGemini — verifies
  GEMINI.md is written and that CLAUDE.md/AGENTS.md are NOT.

Scope (intentional for v1):

- Text output only (`-o text`). Streaming tool events via
  `--output-format stream-json` is a follow-up once we have a
  reliable reproduction of Gemini's event schema.
- No MCP config plumbing. Gemini's `--allowed-mcp-server-names`
  filter pairs well with the per-agent MCP work on feat/per-agent-mcp;
  stacking the two can land as a follow-up.
- No token usage scraping (Gemini's accounting lives on the Google
  Cloud side, not a local JSONL log like claude/codex).
- No session resume wiring beyond accepting the ExecOptions field —
  the daemon does not yet persist Gemini session IDs because the text
  output mode does not expose them.

Migration / env changes:

- New optional environment variables MULTICA_GEMINI_PATH and
  MULTICA_GEMINI_MODEL. Default path is the string "gemini" (resolved
  via PATH at daemon startup). If no Gemini install is detected, the
  provider is simply absent from the runtime — no behavior change for
  existing deployments.
2026-04-11 22:58:49 -04:00
96 changed files with 3937 additions and 628 deletions

View File

@@ -35,11 +35,13 @@ Closes #
## Checklist
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
## AI Disclosure

View File

@@ -7,8 +7,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
### Build from Source
@@ -22,11 +21,17 @@ cp server/bin/multica /usr/local/bin/multica
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
`multica update` auto-detects your installation method and upgrades accordingly.
## Quick Start

View File

@@ -40,7 +40,7 @@ which brew
If `brew` is found, install via Homebrew:
```bash
brew tap multica-ai/tap && brew install multica
brew install multica-ai/tap/multica
```
Then verify:
@@ -51,6 +51,12 @@ multica version
If the version prints successfully, skip to **Step 3**.
To upgrade later, run:
```bash
brew upgrade multica-ai/tap/multica
```
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
If Homebrew is not available, download the binary directly.

View File

@@ -37,8 +37,10 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_WS_URL
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

View File

@@ -50,13 +50,23 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
## Quick Install
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
### macOS / Linux (install script)
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
**Windows (PowerShell):**
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex

View File

@@ -50,13 +50,23 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
## 快速安装
### macOS / Linux推荐 Homebrew
```bash
brew install multica-ai/tap/multica
```
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
### macOS / Linux安装脚本
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
如果没有 Homebrew可以使用安装脚本。脚本会安装 Multica CLI检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
**Windows (PowerShell):**
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex

View File

@@ -29,6 +29,12 @@ This clones the repository, starts all services via Docker Compose, installs the
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
>
> ```bash
> brew install multica-ai/tap/multica
> ```
---
@@ -70,8 +76,7 @@ Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
You also need at least one AI agent CLI installed:

View File

@@ -218,6 +218,26 @@ NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## LAN / Non-localhost Access
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
```bash
# .env — replace with your server's LAN IP
FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d --build
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
## Health Check
The backend exposes a health check endpoint:

View File

@@ -32,4 +32,8 @@ win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
npmRebuild: false

View File

@@ -22,11 +22,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"electron-updater": "^6.8.3",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",

View File

@@ -1,6 +1,7 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { setupAutoUpdater } from "./updater";
const PROTOCOL = "multica";
@@ -114,6 +115,8 @@ if (!gotTheLock) {
createWindow();
setupAutoUpdater(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {
if (mainWindow) {

View File

@@ -0,0 +1,46 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("download-progress", (progress) => {
const win = getMainWindow();
win?.webContents.send("updater:download-progress", {
percent: progress.percent,
});
});
autoUpdater.on("update-downloaded", () => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded");
});
autoUpdater.on("error", (err) => {
console.error("Auto-updater error:", err);
});
ipcMain.handle("updater:download", () => {
return autoUpdater.downloadUpdate();
});
ipcMain.handle("updater:install", () => {
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}

View File

@@ -7,10 +7,19 @@ interface DesktopAPI {
openExternal: (url: string) => Promise<void>;
}
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
updater: UpdaterAPI;
}
}

View File

@@ -15,12 +15,35 @@ const desktopAPI = {
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
};
const updaterAPI = {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
ipcRenderer.on("updater:update-available", handler);
return () => ipcRenderer.removeListener("updater:update-available", handler);
},
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -8,6 +8,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -51,6 +52,7 @@ export default function App() {
<AppContent />
</CoreProvider>
<Toaster />
<UpdateNotification />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
type UpdateState =
| { status: "idle" }
| { status: "available"; version: string }
| { status: "downloading"; percent: number }
| { status: "ready" };
export function UpdateNotification() {
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const cleanups: (() => void)[] = [];
cleanups.push(
window.updater.onUpdateAvailable((info) => {
setState({ status: "available", version: info.version });
setDismissed(false);
}),
);
cleanups.push(
window.updater.onDownloadProgress((progress) => {
setState({ status: "downloading", percent: progress.percent });
}),
);
cleanups.push(
window.updater.onUpdateDownloaded(() => {
setState({ status: "ready" });
}),
);
return () => cleanups.forEach((fn) => fn());
}, []);
const handleDownload = useCallback(() => {
// Prevent double-click: immediately transition to downloading state
if (state.status !== "available") return;
setState({ status: "downloading", percent: 0 });
window.updater.downloadUpdate();
}, [state.status]);
const handleInstall = useCallback(() => {
window.updater.installUpdate();
}, []);
// Only allow dismiss when update is available (not during download or ready)
if (state.status === "idle") return null;
if (dismissed && state.status === "available") return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
{state.status === "available" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">New version available</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} is ready to download
</p>
<button
onClick={handleDownload}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Download update
</button>
</div>
</div>
)}
{state.status === "downloading" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Downloading update...</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.round(state.percent)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{Math.round(state.percent)}%
</p>
</div>
</div>
)}
{state.status === "ready" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -8,8 +8,7 @@ description: Install the Multica CLI and start the agent daemon.
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
### Build from Source
@@ -48,11 +47,17 @@ rm /tmp/multica.tar.gz
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
`multica update` auto-detects your installation method and upgrades accordingly.
## Quick Start

View File

@@ -19,10 +19,28 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
Or install manually:
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
### macOS / Linux (install script)
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon:
```bash
# Configure, authenticate, and start the daemon
multica setup
```

View File

@@ -33,6 +33,10 @@ multica setup self-host
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
</Callout>
<Callout>
For a step-by-step setup, see below.
</Callout>
@@ -73,8 +77,7 @@ The daemon runs on your local machine (not inside Docker). It detects installed
### a) Install the CLI and an AI agent
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
You also need at least one AI agent CLI:

View File

@@ -12,6 +12,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
loginPath="/login"
>
{children}
</DashboardLayout>

View File

@@ -22,12 +22,22 @@ function hasLegacyToken(): boolean {
}
}
// Derive WebSocket URL from the page origin so self-hosted / LAN deployments
// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule
// (/ws → backend) handles proxying.
function deriveWsUrl(): string | undefined {
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL;
if (typeof window === "undefined") return undefined;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/ws`;
}
export function WebProviders({ children }: { children: React.ReactNode }) {
const cookieAuth = !hasLegacyToken();
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
wsUrl={deriveWsUrl()}
cookieAuth={cookieAuth}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}

View File

@@ -277,6 +277,32 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI & Agent Env Vars",
changes: [],
features: [
"Google Gemini CLI as a new agent runtime with live log streaming",
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
"Sub-issues inherit parent project automatically",
],
improvements: [
"Editor bubble menu and link preview rewritten for reliability",
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
"Self-hosted WebSocket URL auto-derived for LAN access",
],
fixes: [
"S3 upload keys scoped by workspace (security)",
"Workspace membership validation for subscriptions and uploads (security)",
"Active tasks auto-cancelled when issue status changes to cancelled",
"Agent task stall when process hangs on stdout",
"Daemon trigger prompt now embeds the actual triggering comment content",
"Login and dashboard redirect stability improvements",
],
},
{
version: "0.1.28",
date: "2026-04-13",

View File

@@ -277,6 +277,32 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI 与 Agent 环境变量",
changes: [],
features: [
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
"Agent 自定义环境变量router/proxy 模式),新增专用设置标签页",
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
"CLI `--parent` 更新父 Issue`--content-stdin` 管道输入评论内容",
"子 Issue 自动继承父级项目",
],
improvements: [
"编辑器气泡菜单和链接预览重写",
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析",
"自部署 WebSocket URL 自动适配局域网访问",
],
fixes: [
"S3 上传路径按工作区隔离(安全)",
"订阅和上传新增工作区成员身份校验(安全)",
"Issue 状态改为已取消时自动终止进行中的任务",
"Agent 进程 stdout 挂起导致任务卡住",
"Daemon 触发提示现在嵌入实际的触发评论内容",
"登录和仪表盘跳转稳定性改进",
],
},
{
version: "0.1.28",
date: "2026-04-13",

View File

@@ -1,11 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const loggedIn = request.cookies.has("multica_logged_in");
if (loggedIn) {
return NextResponse.redirect(new URL("/issues", request.url));
}
export function proxy(_request: NextRequest) {
return NextResponse.next();
}

View File

@@ -54,6 +54,7 @@ export const mockAgents: Agent[] = [
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,

View File

@@ -62,6 +62,7 @@ services:
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:

View File

@@ -198,6 +198,10 @@ export class ApiClient {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}

View File

@@ -168,12 +168,21 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
// Invalidate new parent's children cache when parent_issue_id changed
const newParentId = vars.parent_issue_id;
if (newParentId && newParentId !== ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, newParentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
}

View File

@@ -29,16 +29,19 @@ export function onIssueUpdated(
wsId: string,
issue: Partial<Issue> & { id: string },
) {
// Look up the parent before mutating list state, so we can also keep the
// parent's children cache in sync (powers the sub-issues list shown on
// the parent issue page).
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const parentId =
issue.parent_issue_id ??
const oldParentId =
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
@@ -63,10 +66,22 @@ export function onIssueUpdated(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
// Invalidate old parent's children (issue was removed from it)
if (oldParentId) {
if (parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
} else {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
// Invalidate new parent's children (issue was added to it)
if (newParentId && parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
}
if (oldParentId || newParentId) {
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}

View File

@@ -47,6 +47,7 @@ export interface Agent {
avatar_url: string | null;
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
custom_env: Record<string, string>;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -65,6 +66,7 @@ export interface CreateAgentRequest {
avatar_url?: string;
runtime_id: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
}
@@ -76,6 +78,7 @@ export interface UpdateAgentRequest {
avatar_url?: string;
runtime_id?: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

View File

@@ -11,6 +11,7 @@ import {
AlertCircle,
MoreHorizontal,
Settings,
KeyRound,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import {
@@ -34,17 +35,19 @@ import { InstructionsTab } from "./tabs/instructions-tab";
import { SkillsTab } from "./tabs/skills-tab";
import { TasksTab } from "./tabs/tasks-tab";
import { SettingsTab } from "./tabs/settings-tab";
import { EnvTab } from "./tabs/env-tab";
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
}
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "env", label: "Environment", icon: KeyRound },
{ id: "settings", label: "Settings", icon: Settings },
];
@@ -158,6 +161,12 @@ export function AgentDetail({
<SkillsTab agent={agent} />
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "env" && (
<EnvTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
{activeTab === "settings" && (
<SettingsTab
agent={agent}

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import {
Loader2,
Save,
Plus,
Trash2,
Eye,
EyeOff,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
let nextEnvId = 0;
interface EnvEntry {
id: number;
key: string;
value: string;
visible: boolean;
}
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
return Object.entries(env).map(([key, value]) => ({
id: nextEnvId++,
key,
value,
visible: false,
}));
}
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
const map: Record<string, string> = {};
for (const entry of entries) {
const key = entry.key.trim();
if (key) {
map[key] = entry.value;
}
}
return map;
}
export function EnvTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
envMapToEntries(agent.custom_env ?? {}),
);
const [saving, setSaving] = useState(false);
const currentEnvMap = entriesToEnvMap(envEntries);
const originalEnvMap = agent.custom_env ?? {};
const dirty =
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
const addEnvEntry = () => {
setEnvEntries([
...envEntries,
{ id: nextEnvId++, key: "", value: "", visible: true },
]);
};
const removeEnvEntry = (index: number) => {
setEnvEntries(envEntries.filter((_, i) => i !== index));
};
const updateEnvEntry = (
index: number,
field: "key" | "value",
val: string,
) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
};
const toggleEnvVisibility = (index: number) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, visible: !entry.visible } : entry,
),
);
};
const handleSave = async () => {
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
const uniqueKeys = new Set(keys);
if (uniqueKeys.size < keys.length) {
toast.error("Duplicate environment variable keys");
return;
}
setSaving(true);
try {
await onSave({ custom_env: currentEnvMap });
toast.success("Environment variables saved");
} catch {
toast.error("Failed to save environment variables");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-muted-foreground">
Environment Variables
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnvEntry}
className="h-7 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
Add
</Button>
</div>
{envEntries.length > 0 && (
<div className="space-y-2">
{envEntries.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
placeholder="KEY"
className="w-[40%] font-mono text-xs"
/>
<div className="relative flex-1">
<Input
type={entry.visible ? "text" : "password"}
value={entry.value}
onChange={(e) =>
updateEnvEntry(index, "value", e.target.value)
}
placeholder="value"
className="pr-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => toggleEnvVisibility(index)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{entry.visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
<button
type="button"
onClick={() => removeEnvEntry(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save
</Button>
</div>
);
}

View File

@@ -72,6 +72,7 @@ export function SettingsTab({
toast.error("Name is required");
return;
}
setSaving(true);
try {
await onSave({

View File

@@ -13,6 +13,7 @@ const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
const mockApiSetToken = vi.hoisted(() => vi.fn());
const mockApiGetMe = vi.hoisted(() => vi.fn());
const mockApiIssueCliToken = vi.hoisted(() => vi.fn());
const mockSetQueryData = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", async () => {
@@ -58,6 +59,7 @@ vi.mock("@multica/core/api", () => ({
verifyCode: mockApiVerifyCode,
setToken: mockApiSetToken,
getMe: mockApiGetMe,
issueCliToken: mockApiIssueCliToken,
},
}));
@@ -88,7 +90,8 @@ describe("LoginPage", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
// Default: no existing session
// Default: no existing session (getMe rejects when no auth)
mockApiGetMe.mockRejectedValue(new Error("unauthorized"));
localStorage.clear();
// Reset window.location for tests that change it
Object.defineProperty(window, "location", {
@@ -301,7 +304,7 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// After transitioning to code step, cooldown is 10s
// After transitioning to code step, cooldown is 60s
const resendBtn = screen.getByRole("button", { name: /resend in/i });
expect(resendBtn).toBeDisabled();
});
@@ -337,9 +340,9 @@ describe("LoginPage", () => {
// sendCode was called once for the initial send
expect(mockSendCode).toHaveBeenCalledTimes(1);
// Advance past the 10s cooldown one second at a time so React can
// Advance past the 60s cooldown one second at a time so React can
// process each setCooldown state update between ticks.
for (let i = 0; i < 11; i++) {
for (let i = 0; i < 61; i++) {
await act(async () => {
vi.advanceTimersByTime(1_000);
});
@@ -385,11 +388,14 @@ describe("LoginPage", () => {
it("shows cli_confirm step when existing session + cliCallback", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails first, then localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -414,11 +420,14 @@ describe("LoginPage", () => {
it("CLI authorize button redirects to callback URL", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
const onTokenObtained = vi.fn();
render(
@@ -446,11 +455,14 @@ describe("LoginPage", () => {
it("'Use a different account' returns to email step", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -475,6 +487,65 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// CLI callback — cookie-based session (no localStorage token)
// -------------------------------------------------------------------------
it("detects cookie-based session and shows cli_confirm when no localStorage token", async () => {
// No localStorage token — getMe succeeds via HttpOnly cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
render(
<LoginPage
onSuccess={onSuccess}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
expect(screen.getByText(/cookie@example.com/)).toBeInTheDocument();
});
it("CLI authorize with cookie session calls issueCliToken and redirects", async () => {
// No localStorage token — getMe succeeds via cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
mockApiIssueCliToken.mockResolvedValueOnce({ token: "fresh-jwt" });
const onTokenObtained = vi.fn();
render(
<LoginPage
onSuccess={onSuccess}
onTokenObtained={onTokenObtained}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /^authorize$/i }));
await waitFor(() => {
expect(mockApiIssueCliToken).toHaveBeenCalled();
expect(onTokenObtained).toHaveBeenCalled();
expect(window.location.href).toContain(
"http://localhost:9876/callback?token=fresh-jwt&state=abc",
);
});
});
// -------------------------------------------------------------------------
// CLI callback — code verification redirects
// -------------------------------------------------------------------------
@@ -655,12 +726,34 @@ describe("validateCliCallback", () => {
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
});
it("accepts 10.x.x.x private IPs", () => {
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
});
it("accepts 172.16-31.x.x private IPs", () => {
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
});
it("rejects 172.x outside 16-31 range", () => {
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
});
it("accepts 192.168.x.x private IPs", () => {
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
});
it("rejects https:// URLs", () => {
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
});
it("rejects non-localhost hosts", () => {
it("rejects public IPs and domains", () => {
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
});
it("rejects invalid URLs", () => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Card,
@@ -68,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
}
/** Validate that a CLI callback URL points to localhost over HTTP. */
/**
* Validate that a CLI callback URL points to a safe host over HTTP.
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
* on local VMs while blocking arbitrary public hosts.
*/
export function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
const h = cbUrl.hostname;
if (h === "localhost" || h === "127.0.0.1") return true;
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
if (/^10\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
return false;
} catch {
return false;
}
@@ -102,23 +110,43 @@ export function LoginPage({
const [loading, setLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Tracks how the existing session was detected so handleCliAuthorize
// uses the matching token source (cookie → issueCliToken, localStorage → direct).
const authSourceRef = useRef<"cookie" | "localStorage">("cookie");
// Check for existing session when CLI callback is present
// Check for existing session when CLI callback is present.
// Prioritises cookie auth (= current browser session) to avoid authorising
// the CLI with a stale or mismatched localStorage token.
useEffect(() => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
api.setToken(token);
// Ensure no stale bearer token interferes — we want to test the cookie first.
api.setToken(null);
api
.getMe()
.then((user) => {
authSourceRef.current = "cookie";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
api.setToken(null);
localStorage.removeItem("multica_token");
// Cookie auth failed — fall back to localStorage token
const token = localStorage.getItem("multica_token");
if (!token) return;
api.setToken(token);
api
.getMe()
.then((user) => {
authSourceRef.current = "localStorage";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
api.setToken(null);
localStorage.removeItem("multica_token");
});
});
}, [cliCallback]);
@@ -142,7 +170,7 @@ export function LoginPage({
await useAuthStore.getState().sendCode(email);
setStep("code");
setCode("");
setCooldown(10);
setCooldown(60);
} catch (err) {
setError(
err instanceof Error
@@ -195,7 +223,7 @@ export function LoginPage({
setError("");
try {
await useAuthStore.getState().sendCode(email);
setCooldown(10);
setCooldown(60);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code",
@@ -203,13 +231,32 @@ export function LoginPage({
}
};
const handleCliAuthorize = () => {
const handleCliAuthorize = async () => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
setLoading(true);
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
try {
let token: string;
if (authSourceRef.current === "localStorage") {
// Session was detected via localStorage — reuse that token directly.
const stored = localStorage.getItem("multica_token");
if (!stored) throw new Error("token missing");
token = stored;
} else {
// Session was detected via cookie — obtain a bearer token from the server.
const res = await api.issueCliToken();
token = res.token;
}
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
} catch {
setError("Failed to authorize CLI. Please log in again.");
setExistingUser(null);
setStep("email");
setLoading(false);
}
};
const handleGoogleLogin = () => {

View File

@@ -1,5 +1,15 @@
"use client";
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
* focus management (preventHide flag, relatedTarget checks, mousedown
* capture). We only add scroll-container visibility detection on top,
* because the plugin's hide middleware can't detect nested scroll
* container clipping (virtual element has no contextElement).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/core";
@@ -15,11 +25,10 @@ import {
TooltipProvider,
} from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import {
@@ -45,20 +54,9 @@ import {
// Helpers
// ---------------------------------------------------------------------------
/** Force re-render when editor state changes so isActive() returns fresh values */
function useEditorTransactionUpdate(editor: Editor) {
const [, setState] = useState(0);
useEffect(() => {
const handler = () => setState((n) => n + 1);
editor.on("transaction", handler);
return () => {
editor.off("transaction", handler);
};
}, [editor]);
}
function shouldShowBubbleMenu({
editor,
view,
state,
from,
to,
@@ -72,26 +70,28 @@ function shouldShowBubbleMenu({
}) {
if (!editor.isEditable) return false;
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).length) return false;
if (!state.doc.textBetween(from, to).trim().length) return false;
if (state.selection instanceof NodeSelection) return false;
if (!view.hasFocus()) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
/** Detect macOS for keyboard shortcut labels */
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Hoisted to avoid new reference on every render (triggers plugin updateOptions) */
const BUBBLE_MENU_OPTIONS = {
strategy: "fixed" as const,
placement: "top" as const,
offset: 8,
flip: true,
shift: { padding: 8 },
};
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
parent = parent.parentElement;
}
return window;
}
// ---------------------------------------------------------------------------
// Mark Toggle Button
@@ -141,6 +141,36 @@ function MarkButton({
);
}
// ---------------------------------------------------------------------------
// URL normalisation
// ---------------------------------------------------------------------------
/** Protocols that can execute code in the browser — the only ones we block. */
const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i;
const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Normalise a user-entered URL: add protocol, detect mailto, block XSS.
*
* Uses a blocklist (not allowlist) for protocols — only `javascript:`,
* `data:`, and `vbscript:` are blocked. All other protocols pass through
* because they can't execute code in the browser and are legitimate
* deep-link targets in a team tool (slack://, vscode://, figma://).
* Tiptap's `isAllowedUri` in the `setLink` command provides a second
* safety layer.
*/
function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.startsWith("/")) return trimmed;
if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return "";
if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed;
if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return `https://${trimmed}`;
}
// ---------------------------------------------------------------------------
// Link Edit Bar
// ---------------------------------------------------------------------------
@@ -157,25 +187,16 @@ function LinkEditBar({
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// autoFocus workaround — setTimeout to ensure the input is mounted
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}, []);
const apply = useCallback(() => {
let href = url.trim();
const href = normalizeUrl(url);
if (!href) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
if (!/^https?:\/\//.test(href) && !href.startsWith("/")) {
href = `https://${href}`;
}
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run();
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}
onClose();
}, [editor, url, onClose]);
@@ -186,10 +207,7 @@ function LinkEditBar({
}, [editor, onClose]);
return (
<div
className="bubble-menu-link-edit"
onMouseDown={(e) => e.preventDefault()}
>
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
<Input
ref={inputRef}
value={url}
@@ -198,44 +216,19 @@ function LinkEditBar({
aria-label="URL"
className="h-7 flex-1 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
apply();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
editor.commands.focus();
}
if (e.key === "Enter") { e.preventDefault(); apply(); }
if (e.key === "Escape") { e.preventDefault(); onClose(); editor.commands.focus(); }
}}
/>
<Button
size="icon-xs"
variant="ghost"
onClick={apply}
onMouseDown={(e) => e.preventDefault()}
>
<Button size="icon-xs" variant="ghost" onClick={apply} onMouseDown={(e) => e.preventDefault()}>
<Check className="size-3.5" />
</Button>
{existingHref && (
<Button
size="icon-xs"
variant="ghost"
onClick={remove}
onMouseDown={(e) => e.preventDefault()}
>
<Button size="icon-xs" variant="ghost" onClick={remove} onMouseDown={(e) => e.preventDefault()}>
<Unlink className="size-3.5" />
</Button>
)}
<Button
size="icon-xs"
variant="ghost"
onClick={() => {
onClose();
editor.commands.focus();
}}
onMouseDown={(e) => e.preventDefault()}
>
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
<X className="size-3.5" />
</Button>
</div>
@@ -246,74 +239,56 @@ function LinkEditBar({
// Heading Dropdown
// ---------------------------------------------------------------------------
function HeadingDropdown({
editor,
onOpenChange,
}: {
editor: Editor;
onOpenChange: (open: boolean) => void;
}) {
const activeLevel = [1, 2, 3].find((l) =>
editor.isActive("heading", { level: l }),
);
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
const label = activeLevel ? `H${activeLevel}` : "Text";
const items = [
{
label: "Normal Text",
icon: Type,
active: !activeLevel,
action: () => editor.chain().focus().setParagraph().run(),
},
{
label: "Heading 1",
icon: Heading1,
active: activeLevel === 1,
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
label: "Heading 2",
icon: Heading2,
active: activeLevel === 2,
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
label: "Heading 3",
icon: Heading3,
active: activeLevel === 3,
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
{ label: "Heading 1", icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
];
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
>
{label}
<ChevronDown className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
</PopoverTrigger>
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
{items.map((item) => (
<DropdownMenuItem
<button
key={item.label}
onClick={item.action}
className="gap-2 text-xs"
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
item.action();
handleOpenChange(false);
}}
>
<item.icon className="size-3.5" />
{item.label}
{item.active && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
</button>
))}
</DropdownMenuContent>
</DropdownMenu>
</PopoverContent>
</Popover>
);
}
@@ -321,223 +296,173 @@ function HeadingDropdown({
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({
editor,
onOpenChange,
}: {
editor: Editor;
onOpenChange: (open: boolean) => void;
}) {
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<DropdownMenu onOpenChange={onOpenChange}>
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted"
aria-pressed={isBullet || isOrdered}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<TooltipTrigger render={
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
}>
<List className="size-3.5" />
<ChevronDown className="size-3" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
List
</TooltipContent>
<TooltipContent side="top" sideOffset={8}>List</TooltipContent>
</Tooltip>
<DropdownMenuContent
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
<DropdownMenuItem
onClick={() => editor.chain().focus().toggleBulletList().run()}
className="gap-2 text-xs"
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleBulletList().run();
handleOpenChange(false);
}}
>
<List className="size-3.5" />
Bullet List
<List className="size-3.5" /> Bullet List
{isBullet && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className="gap-2 text-xs"
</button>
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleOrderedList().run();
handleOpenChange(false);
}}
>
<ListOrdered className="size-3.5" />
Ordered List
<ListOrdered className="size-3.5" /> Ordered List
{isOrdered && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</button>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu
// Main Bubble Menu — native Tiptap <BubbleMenu>
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [focused, setFocused] = useState(editor.view.hasFocus());
const modeRef = useRef(mode);
modeRef.current = mode;
// Track whether a child dropdown is open — blur during dropdown interaction should not hide
const menuOpenRef = useRef(false);
const handleMenuOpenChange = useCallback((open: boolean) => {
menuOpenRef.current = open;
}, []);
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
useEditorTransactionUpdate(editor);
// Hide bubble menu when editor loses focus (but not when a child dropdown is open)
// Find the real scroll container once on mount
useEffect(() => {
const onFocus = () => setFocused(true);
const onBlur = () => {
setTimeout(() => {
if (!editor.isDestroyed && !editor.view.hasFocus() && !menuOpenRef.current) {
setFocused(false);
setScrollTarget(getScrollParent(editor.view.dom));
}, [editor]);
// Hide when the selection scrolls outside the scroll container's
// visible area. The plugin's hide middleware can't detect this because
// its virtual reference element has no contextElement — Floating UI
// only checks viewport bounds. We use `display` (not managed by the
// plugin) as an additive visibility layer.
const scrollHiddenRef = useRef(false);
const [, forceRender] = useState(0);
useEffect(() => {
if (scrollTarget === window) return;
const el = scrollTarget as HTMLElement;
const onScroll = () => {
if (editor.state.selection.empty) {
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
}, 0);
};
editor.on("focus", onFocus);
editor.on("blur", onBlur);
return () => {
editor.off("focus", onFocus);
editor.off("blur", onBlur);
};
}, [editor]);
// Reset to toolbar mode when selection changes — but not during link editing.
// Also restore focused state (scroll sets it to false, new selection should bring it back).
useEffect(() => {
const handler = () => {
if (modeRef.current !== "link-edit") setMode("toolbar");
if (editor.view.hasFocus()) setFocused(true);
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor]);
// Hide when an ancestor of the editor scrolls (capture phase catches non-bubbling scroll events).
// Scoped to ancestors only — dropdown/sidebar scrolls won't trigger this.
useEffect(() => {
const handler = (e: Event) => {
const target = e.target;
if (
target instanceof HTMLElement &&
target.contains(editor.view.dom)
) {
setFocused(false);
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
scrollHiddenRef.current = !visible;
forceRender((n) => n + 1);
}
};
document.addEventListener("scroll", handler, true);
return () => document.removeEventListener("scroll", handler, true);
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [editor, scrollTarget]);
// Reset scroll-hidden and mode when selection changes
useEffect(() => {
const handler = () => {
setMode("toolbar");
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
};
editor.on("selectionUpdate", handler);
return () => { editor.off("selectionUpdate", handler); };
}, [editor]);
const openLinkEdit = useCallback(() => {
setMode("link-edit");
}, []);
const closeLinkEdit = useCallback(() => {
setMode("toolbar");
}, []);
if (!focused) return null;
// Refocus editor when Base UI dropdown closes
const handleMenuOpenChange = useCallback(
(open: boolean) => { if (!open) editor.commands.focus(); },
[editor],
);
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
style={{ zIndex: 50 }}
options={BUBBLE_MENU_OPTIONS}
style={{
zIndex: 50,
display: scrollHiddenRef.current ? "none" : undefined,
}}
options={{
strategy: "fixed",
placement: "top",
offset: 8,
flip: true,
shift: { padding: 8 },
hide: true,
scrollTarget,
}}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
{/* Group 1: Inline Marks */}
<MarkButton
editor={editor}
mark="bold"
icon={Bold}
label="Bold"
shortcut={`${mod}+B`}
/>
<MarkButton
editor={editor}
mark="italic"
icon={Italic}
label="Italic"
shortcut={`${mod}+I`}
/>
<MarkButton
editor={editor}
mark="strike"
icon={Strikethrough}
label="Strikethrough"
shortcut={`${mod}+Shift+S`}
/>
<MarkButton
editor={editor}
mark="code"
icon={Code}
label="Code"
shortcut={`${mod}+E`}
/>
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
{/* Group 2: Link */}
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive("link")}
onPressedChange={openLinkEdit}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
}>
<Link2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Link
</TooltipContent>
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
{/* Group 3: Block Transforms */}
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive("blockquote")}
onPressedChange={() =>
editor.chain().focus().toggleBlockquote().run()
}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
}>
<Quote className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Quote
</TooltipContent>
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>

View File

@@ -28,6 +28,7 @@
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
min-height: 100%;
}
.rich-text-editor.ProseMirror:focus {
@@ -486,4 +487,22 @@
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
min-width: 300px;
}
}
/* Link hover card — shows URL + actions on link hover */
.link-hover-card {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.25rem 0.5rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
max-width: min(360px, calc(100vw - 2rem));
white-space: nowrap;
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("./extensions", () => ({
createEditorExtensions: () => [],
}));
vi.mock("./extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
}));
vi.mock("./utils/preprocess", () => ({
preprocessMarkdown: (value: string) => value,
}));
vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
vi.mock("@tiptap/react", () => ({
useEditor: () => ({
commands: {
focus: mockFocus,
clearContent: vi.fn(),
},
getMarkdown: () => "",
state: {
doc: {
content: {
size: 0,
},
},
selection: {
empty: true,
},
},
}),
EditorContent: ({ className }: { className?: string }) => (
<div className={className} data-testid="editor-content">
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
</div>
),
}));
import { ContentEditor } from "./content-editor";
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("focuses the editor when clicking the empty container area", () => {
render(<ContentEditor placeholder="Add description..." />);
const shell = screen.getByTestId("editor-content").parentElement;
expect(shell).not.toBeNull();
fireEvent.mouseDown(shell!);
expect(mockFocus).toHaveBeenCalledWith("end");
});
it("does not hijack clicks that land inside the ProseMirror node", () => {
render(<ContentEditor placeholder="Add description..." />);
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
expect(mockFocus).not.toHaveBeenCalled();
});
});

View File

@@ -30,6 +30,7 @@ import {
useEffect,
useImperativeHandle,
useRef,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@multica/ui/lib/utils";
@@ -39,8 +40,21 @@ import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
* from serialised markdown so they never reach the database. */
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
function stripBlobUrls(md: string): string {
return md.replace(BLOB_IMAGE_RE, "");
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -62,6 +76,8 @@ interface ContentEditorRef {
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
}
// ---------------------------------------------------------------------------
@@ -114,7 +130,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
}, debounceMs);
},
onBlur: () => {
@@ -131,33 +147,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
const openLink = () => {
if (href.startsWith("/")) {
// Internal path — dispatch custom event so the app can handle it
// (direct window.open breaks in Electron hash router)
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
};
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
openLink();
return true;
// Open the link. Internal paths use multica:navigate
// (Electron hash-router safe), external open in new tab.
event.preventDefault();
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
event.preventDefault();
openLink();
return true;
}
return false;
return true;
},
},
attributes: {
@@ -192,7 +192,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {
editor?.commands.clearContent();
},
@@ -204,14 +204,44 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
hasActiveUploads: () => {
if (!editor) return false;
let uploading = false;
editor.state.doc.descendants((node) => {
if (node.attrs.uploading) uploading = true;
return !uploading;
});
return uploading;
},
}));
// Link hover card — disabled when BubbleMenu is active (has selection)
const wrapperRef = useRef<HTMLDivElement>(null);
const hoverDisabled = !editor?.state.selection.empty;
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editable || !editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
event.preventDefault();
editor.commands.focus("end");
};
if (!editor) return null;
return (
<div className="relative min-h-full">
<EditorContent editor={editor} />
<div
ref={wrapperRef}
className="relative flex min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
</div>
);
},

View File

@@ -47,9 +47,10 @@ import { ImageView } from "./image-view";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
openOnClick: false,
autolink: true,
linkOnPaste: false,
linkOnPaste: true,
defaultProtocol: "https",
});
const LinkReadonly = Link.configure({
@@ -103,6 +104,9 @@ export function createEditorExtensions(
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
// ⚠️ Link MUST appear before markdownPaste in this array.
// linkOnPaste relies on Link's handlePaste plugin firing first;
// markdownPaste's handlePaste is a catch-all that returns true.
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),

View File

@@ -40,6 +40,9 @@ export function createMarkdownPasteExtension() {
const clipboard = event.clipboardData;
if (!clipboard) return false;
// If clipboard has files, defer to the fileUpload extension.
if (clipboard.files?.length) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;

View File

@@ -21,7 +21,7 @@
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useNavigation } from "../../navigation";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -54,7 +54,14 @@ function IssueMention({
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { push, openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const listIssue = issues.find((i) => i.id === issueId);
const { data: detailIssue } = useQuery({
...issueDetailOptions(wsId, issueId),
enabled: !listIssue,
});
const issue = listIssue ?? detailIssue;
const issuePath = `/issues/${issueId}`;
const tabTitle = issue ? `${issue.identifier}: ${issue.title}` : undefined;

View File

@@ -0,0 +1,236 @@
"use client";
/**
* LinkHoverCard — floating card shown on link hover.
*
* Displays the URL with Copy and Open actions. Portaled to body
* with position:fixed to escape overflow:hidden containers.
* Shows after 300ms hover delay, hides after 150ms mouse-out
* (cancelled if mouse enters the card).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function openLink(href: string) {
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}
function truncateUrl(url: string, max = 48): string {
if (url.length <= max) return url;
try {
const u = new URL(url);
const origin = u.origin;
const rest = url.slice(origin.length);
if (rest.length <= 10) return url;
return `${origin}${rest.slice(0, max - origin.length - 1)}`;
} catch {
return `${url.slice(0, max - 1)}`;
}
}
// ---------------------------------------------------------------------------
// Hook — manages hover state with enter/leave delays
// ---------------------------------------------------------------------------
const SHOW_DELAY = 300;
const HIDE_DELAY = 150;
interface HoverState {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
}
function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disabled?: boolean) {
const [state, setState] = useState<HoverState>({ visible: false, href: "", anchorEl: null });
const showTimer = useRef(0);
const hideTimer = useRef(0);
const cardRef = useRef<HTMLDivElement>(null);
const clearTimers = useCallback(() => {
clearTimeout(showTimer.current);
clearTimeout(hideTimer.current);
}, []);
// Container mouse events — detect <a> hover
useEffect(() => {
const container = containerRef.current;
if (!container || disabled) return;
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest("a") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("mention://")) return;
clearTimeout(hideTimer.current);
showTimer.current = window.setTimeout(() => {
setState({ visible: true, href, anchorEl: link });
}, SHOW_DELAY);
};
const onMouseOut = (e: MouseEvent) => {
const related = e.relatedTarget as HTMLElement | null;
// Don't hide if mouse moved to the hover card
if (related && cardRef.current?.contains(related)) return;
// Don't hide if mouse moved to another part of the same link
const link = (e.target as HTMLElement).closest("a");
if (link && link.contains(related)) return;
clearTimeout(showTimer.current);
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
};
container.addEventListener("mouseover", onMouseOver);
container.addEventListener("mouseout", onMouseOut);
return () => {
container.removeEventListener("mouseover", onMouseOver);
container.removeEventListener("mouseout", onMouseOut);
clearTimers();
};
}, [containerRef, disabled, clearTimers]);
// Card mouse events — keep visible while hovering the card
const onCardEnter = useCallback(() => {
clearTimeout(hideTimer.current);
}, []);
const onCardLeave = useCallback(() => {
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
}, []);
return { ...state, cardRef, onCardEnter, onCardLeave };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function LinkHoverCard({
visible,
href,
anchorEl,
cardRef,
onCardEnter,
onCardLeave,
}: {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
cardRef: React.RefObject<HTMLDivElement | null>;
onCardEnter: () => void;
onCardLeave: () => void;
}) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [positioned, setPositioned] = useState(false);
// Position the card when the portal div is mounted (ref callback).
// Using useEffect would race with portal rendering — the div might
// not be in the DOM yet when the effect runs.
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (!node || !anchorEl) {
setPositioned(false);
return;
}
computePosition(anchorEl, node, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
setPos({ top: y, left: x });
setPositioned(true);
});
},
[anchorEl, cardRef],
);
// Reset positioned when hidden
useEffect(() => {
if (!visible) setPositioned(false);
}, [visible]);
if (!visible || !anchorEl) return null;
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.clipboard.writeText(href);
toast.success("Link copied");
} catch {
toast.error("Failed to copy");
}
};
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
openLink(href);
};
return createPortal(
<div
ref={setCardRef}
className="link-hover-card"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 50,
display: positioned ? undefined : "none",
}}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
>
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
title={href}
>
{truncateUrl(href)}
</span>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleCopy}
title="Copy link"
>
<Copy className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleOpen}
title="Open link"
>
<ExternalLink className="size-3.5" />
</Button>
</div>,
document.body,
);
}
export { useLinkHover, LinkHoverCard };

View File

@@ -16,7 +16,7 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -33,6 +33,7 @@ import { cn } from "@multica/ui/lib/utils";
import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
@@ -109,7 +110,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
}
const components: Partial<Components> = {
// Links — route mention:// to mention components, others open in new tab
// Links — route mention:// to mention components, others show preview card
a: ({ href, children }) => {
if (href?.startsWith("mention://")) {
const match = href.match(
@@ -128,13 +129,20 @@ const components: Partial<Components> = {
return <span className="mention">{children}</span>;
}
// Regular links — open in new tab
// Regular links — open directly on click
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) window.open(href, "_blank", "noopener,noreferrer");
if (!href) return;
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}}
>
{children}
@@ -273,9 +281,11 @@ interface ReadonlyContentProps {
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
return (
<div className={cn("rich-text-editor readonly text-sm", className)}>
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
@@ -284,6 +294,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
>
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
</div>
);
}

View File

@@ -90,7 +90,9 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
const editor = useEditor({
immediatelyRender: false,
content: `<p>${defaultValue}</p>`,
content: defaultValue
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
: "",
extensions: [
SingleLineDocument,
Paragraph,

View File

@@ -32,6 +32,10 @@ export function preprocessMarkdown(markdown: string): string {
*/
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function preprocessFileCards(markdown: string): string {
return markdown
.split("\n")
@@ -42,7 +46,7 @@ function preprocessFileCards(markdown: string): string {
const filename = match[1]!;
const url = match[2]!;
if (!isFileCardUrl(url)) return line;
return `<div data-type="fileCard" data-href="${url}" data-filename="${filename}"></div>`;
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
})
.join("\n");
}

View File

@@ -267,7 +267,7 @@ function CommentRow({
className="relative mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
@@ -484,7 +484,7 @@ function CommentCard({
className="relative pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}

View File

@@ -5,6 +5,8 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
import {
ArrowDown,
ArrowUp,
Calendar,
ChevronDown,
ChevronLeft,
@@ -52,10 +54,10 @@ import {
} from "@multica/ui/components/ui/tooltip";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
import { ActorAvatar } from "../../common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry, Issue } from "@multica/core/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
import { ProjectPicker } from "../../projects/components/project-picker";
@@ -174,6 +176,132 @@ function PropRow({
}
// ---------------------------------------------------------------------------
// Issue Picker Dialog
// ---------------------------------------------------------------------------
function IssuePickerDialog({
open,
onOpenChange,
title,
description,
excludeIds,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
excludeIds: string[];
onSelect: (issue: Issue) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Issue[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortRef = useRef<AbortController>(undefined);
// Reset state when dialog opens/closes
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
setIsLoading(false);
}
}, [open]);
const search = useCallback(
(q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
if (!q.trim()) {
setResults([]);
setIsLoading(false);
return;
}
setIsLoading(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await api.searchIssues({
q: q.trim(),
limit: 20,
include_closed: true,
signal: controller.signal,
});
if (!controller.signal.aborted) {
setResults(
res.issues.filter((i) => !excludeIds.includes(i.id)),
);
setIsLoading(false);
}
} catch {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
}, 300);
},
[excludeIds],
);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search issues..."
value={query}
onValueChange={(v) => {
setQuery(v);
search(v);
}}
/>
<CommandList>
{isLoading && (
<div className="py-6 text-center text-sm text-muted-foreground">
Searching...
</div>
)}
{!isLoading && query.trim() && results.length === 0 && (
<CommandEmpty>No issues found.</CommandEmpty>
)}
{!isLoading && !query.trim() && (
<div className="py-6 text-center text-sm text-muted-foreground">
Type to search issues
</div>
)}
{results.length > 0 && (
<CommandGroup>
{results.map((issue) => (
<CommandItem
key={issue.id}
value={issue.id}
onSelect={() => {
onSelect(issue);
onOpenChange(false);
}}
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{issue.identifier}</span>
<span className="truncate">{issue.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</CommandDialog>
);
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
@@ -221,6 +349,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(null);
const [parentPickerOpen, setParentPickerOpen] = useState(false);
const [childPickerOpen, setChildPickerOpen] = useState(false);
// Issue data from TQ — uses detail query, seeded from list cache if available.
// Only seed when description is present; list API omits it, and ContentEditor
@@ -645,6 +775,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
Create sub-issue
</DropdownMenuItem>
{/* Add as sub-issue of another issue */}
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
<ArrowUp className="h-3.5 w-3.5" />
Set parent issue...
</DropdownMenuItem>
{/* Add another issue as sub-issue */}
<DropdownMenuItem onClick={() => setChildPickerOpen(true)}>
<ArrowDown className="h-3.5 w-3.5" />
Add sub-issue...
</DropdownMenuItem>
{/* Pin / Unpin */}
<DropdownMenuItem onClick={() => {
if (isPinned) {
@@ -724,6 +866,35 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Set parent issue picker */}
<IssuePickerDialog
open={parentPickerOpen}
onOpenChange={setParentPickerOpen}
title="Set parent issue"
description="Search for an issue to set as the parent of this issue"
excludeIds={[id, ...childIssues.map((c) => c.id)]}
onSelect={(selected) => {
handleUpdateField({ parent_issue_id: selected.id });
toast.success(`Set ${selected.identifier} as parent issue`);
}}
/>
{/* Add sub-issue picker */}
<IssuePickerDialog
open={childPickerOpen}
onOpenChange={setChildPickerOpen}
title="Add sub-issue"
description="Search for an issue to add as a sub-issue"
excludeIds={[id, ...(parentIssueId ? [parentIssueId] : []), ...childIssues.map((c) => c.id)]}
onSelect={(selected) => {
updateIssueMutation.mutate(
{ id: selected.id, parent_issue_id: id },
{ onError: () => toast.error("Failed to add sub-issue") },
);
toast.success(`Added ${selected.identifier} as sub-issue`);
}}
/>
</div>
{/* Content — scrollable */}

View File

@@ -2,7 +2,7 @@
import { AppLink } from "../../navigation";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { StatusIcon } from "./status-icon";
@@ -15,7 +15,16 @@ interface IssueMentionCardProps {
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const issue = issues.find((i) => i.id === issueId);
const listIssue = issues.find((i) => i.id === issueId);
// Fetch individual issue when not found in the list (e.g. done issues beyond
// the first page). Only fires when listIssue is undefined.
const { data: detailIssue } = useQuery({
...issueDetailOptions(wsId, issueId),
enabled: !listIssue,
});
const issue = listIssue ?? detailIssue;
if (!issue) {
return (

View File

@@ -14,6 +14,8 @@ interface DashboardLayoutProps {
searchSlot?: ReactNode;
/** Loading indicator */
loadingIndicator?: ReactNode;
/** Path to redirect when user is not authenticated */
loginPath?: string;
/** Path to redirect when user has no workspace */
onboardingPath?: string;
}
@@ -23,11 +25,12 @@ export function DashboardLayout({
extra,
searchSlot,
loadingIndicator,
loginPath,
onboardingPath,
}: DashboardLayoutProps) {
return (
<DashboardGuard
loginPath="/"
loginPath={loginPath}
onboardingPath={onboardingPath}
loadingFallback={
<div className="flex h-svh items-center justify-center">

View File

@@ -7,7 +7,7 @@ import { useWorkspaceStore } from "@multica/core/workspace";
import { useNavigation } from "../navigation";
export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
const { pathname, push } = useNavigation();
const { pathname, replace } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
@@ -15,13 +15,13 @@ export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
useEffect(() => {
if (isLoading) return;
if (!user) {
push(loginPath);
replace(loginPath);
return;
}
if (!workspace && onboardingPath) {
push(onboardingPath);
replace(onboardingPath);
}
}, [user, isLoading, workspace, push, loginPath, onboardingPath]);
}, [user, isLoading, workspace, replace, loginPath, onboardingPath]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);

View File

@@ -40,6 +40,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/dom": "^1.7.6",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@tiptap/core": "^3.22.1",

35
pnpm-lock.yaml generated
View File

@@ -123,6 +123,9 @@ importers:
'@multica/views':
specifier: workspace:*
version: link:../../packages/views
electron-updater:
specifier: ^6.8.3
version: 6.8.3
react-router-dom:
specifier: ^7.6.0
version: 7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -3642,6 +3645,9 @@ packages:
electron-to-chromium@1.5.321:
resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
electron-updater@6.8.3:
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
electron-vite@5.0.0:
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4839,6 +4845,13 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -6352,6 +6365,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -9800,6 +9816,19 @@ snapshots:
electron-to-chromium@1.5.321: {}
electron-updater@6.8.3:
dependencies:
builder-util-runtime: 9.5.1
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.4
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
@@ -11262,6 +11291,10 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.escaperegexp@4.1.2: {}
lodash.isequal@4.5.0: {}
lodash.merge@4.6.2: {}
lodash@4.18.1: {}
@@ -13331,6 +13364,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tiny-typed-emitter@2.1.0: {}
tinybench@2.9.0: {}
tinyexec@1.0.4: {}

View File

@@ -79,7 +79,7 @@ func openBrowser(url string) error {
args = []string{url}
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
args = []string{"/c", "start", "", url}
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
@@ -98,15 +98,31 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
serverURL := resolveServerURL(cmd)
appURL := resolveAppURL(cmd)
// Determine the callback host from the configured app URL.
// For self-hosted setups where the browser is on a different machine
// (e.g. Multica running on a LAN server), use the server's private IP
// so the browser can reach the CLI's local HTTP server.
// For production (public hostnames like multica.ai), keep localhost —
// the browser and CLI are on the same machine.
callbackHost := "localhost"
bindAddr := "127.0.0.1"
if parsed, err := url.Parse(appURL); err == nil {
h := parsed.Hostname()
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
callbackHost = h
bindAddr = "0.0.0.0"
}
}
// Start a local HTTP server on a random port to receive the callback.
listener, err := net.Listen("tcp", "127.0.0.1:0")
listener, err := net.Listen("tcp", bindAddr+":0")
if err != nil {
return fmt.Errorf("failed to start local server: %w", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
callbackURL := fmt.Sprintf("http://localhost:%d/callback", port)
callbackURL := fmt.Sprintf("http://%s:%d/callback", callbackHost, port)
// Generate a random state parameter for CSRF protection.
stateBytes := make([]byte, 16)

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"io"
"net/url"
"os"
"strings"
@@ -161,6 +162,7 @@ func init() {
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)")
issueUpdateCmd.Flags().String("project", "", "Project ID")
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
issueUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// issue status
@@ -185,7 +187,8 @@ func init() {
issueRunMessagesCmd.Flags().Int("since", 0, "Only return messages after this sequence number")
// issue comment add
issueCommentAddCmd.Flags().String("content", "", "Comment content (required)")
issueCommentAddCmd.Flags().String("content", "", "Comment content (required unless --content-stdin)")
issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (avoids shell escaping issues)")
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
@@ -442,6 +445,14 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
body["assignee_type"] = aType
body["assignee_id"] = aID
}
if cmd.Flags().Changed("parent") {
v, _ := cmd.Flags().GetString("parent")
if v == "" {
body["parent_issue_id"] = nil
} else {
body["parent_issue_id"] = v
}
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use flags like --title, --status, --priority, --assignee, etc.")
@@ -637,8 +648,25 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
content, _ := cmd.Flags().GetString("content")
useStdin, _ := cmd.Flags().GetBool("content-stdin")
if content != "" && useStdin {
return fmt.Errorf("--content and --content-stdin are mutually exclusive")
}
if useStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
content = strings.TrimSuffix(string(data), "\n")
if content == "" {
return fmt.Errorf("stdin content is empty")
}
}
if content == "" {
return fmt.Errorf("--content is required")
return fmt.Errorf("--content or --content-stdin is required")
}
client, err := newAPIClient(cmd)

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -11,6 +12,22 @@ import (
"github.com/multica-ai/multica/server/internal/cli"
)
// tryResolveAppURL returns the app URL if configured, or "" if not available.
// Unlike resolveAppURL, it never calls os.Exit.
func tryResolveAppURL(cmd *cobra.Command) string {
for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} {
if val := strings.TrimSpace(os.Getenv(key)); val != "" {
return strings.TrimRight(val, "/")
}
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err == nil && cfg.AppURL != "" {
return strings.TrimRight(cfg.AppURL, "/")
}
return ""
}
var loginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate and set up workspaces",
@@ -59,8 +76,15 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
}
if len(workspaces) == 0 {
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
return nil
var err error
workspaces, err = waitForOnboarding(cmd, client)
if err != nil {
return err
}
if len(workspaces) == 0 {
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
return nil
}
}
profile := resolveProfile(cmd)
@@ -96,3 +120,54 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
return nil
}
// waitForOnboarding opens the web onboarding page and polls until the user
// creates a workspace, returning the new workspace list.
func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
ID string `json:"id"`
Name string `json:"name"`
}, error) {
appURL := tryResolveAppURL(cmd)
if appURL == "" {
// No app URL available (e.g. token login without prior setup).
// Can't open the browser — tell the user to create a workspace manually.
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
fmt.Fprintln(os.Stderr, "Create a workspace in the web dashboard, then run 'multica login' again.")
return nil, nil
}
onboardingURL := appURL + "/onboarding"
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening onboarding in your browser...")
if err := openBrowser(onboardingURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
}
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", onboardingURL)
fmt.Fprintln(os.Stderr, "\nWaiting for workspace creation...")
// Poll until a workspace appears or timeout (5 minutes).
const pollInterval = 2 * time.Second
const pollTimeout = 5 * time.Minute
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
var workspaces []struct {
ID string `json:"id"`
Name string `json:"name"`
}
err := client.GetJSON(ctx, "/api/workspaces", &workspaces)
cancel()
if err != nil {
continue // transient error, keep polling
}
if len(workspaces) > 0 {
return workspaces, nil
}
}
return nil, fmt.Errorf("timed out waiting for workspace creation")
}

View File

@@ -135,13 +135,18 @@ func runSetupCloud(cmd *cobra.Command, args []string) error {
return err
}
// Start daemon in background.
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
// Start daemon only if we have workspaces to watch.
if hasWatchedWorkspaces(resolveProfile(cmd)) {
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
} else {
fmt.Fprintln(os.Stderr, "\n⚠ Setup incomplete: no workspaces configured.")
fmt.Fprintln(os.Stderr, "Create a workspace at the web dashboard, then run 'multica login' and 'multica daemon start'.")
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
return nil
}
@@ -195,16 +200,30 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
return err
}
// Start daemon in background.
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
// Start daemon only if we have workspaces to watch.
if hasWatchedWorkspaces(resolveProfile(cmd)) {
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
} else {
fmt.Fprintln(os.Stderr, "\n⚠ Setup incomplete: no workspaces configured.")
fmt.Fprintln(os.Stderr, "Create a workspace at the web dashboard, then run 'multica login' and 'multica daemon start'.")
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
return nil
}
// hasWatchedWorkspaces returns true if the CLI config has at least one watched workspace.
func hasWatchedWorkspaces(profile string) bool {
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return false
}
return len(cfg.WatchedWorkspaces) > 0
}
// probeServer checks whether a Multica backend is reachable at the given URL.
func probeServer(baseURL string) bool {
url := strings.TrimRight(baseURL, "/") + "/health"

View File

@@ -153,6 +153,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// --- User-scoped routes (no workspace context required) ---
r.Get("/api/me", h.GetMe)
r.Patch("/api/me", h.UpdateMe)
r.Post("/api/cli-token", h.IssueCliToken)
r.Post("/api/upload-file", h.UploadFile)
r.Route("/api/workspaces", func(r chi.Router) {

View File

@@ -18,6 +18,9 @@ const (
// staleThresholdSeconds marks runtimes offline if no heartbeat for this long.
// The daemon heartbeat interval is 15s, so 45s = 3 missed heartbeats.
staleThresholdSeconds = 45.0
// offlineRuntimeTTLSeconds deletes offline runtimes with no active agents
// after this duration. 7 days gives users plenty of time to restart daemons.
offlineRuntimeTTLSeconds = 7 * 24 * 3600.0
// dispatchTimeoutSeconds fails tasks stuck in 'dispatched' beyond this.
// The dispatched→running transition should be near-instant, so 5 minutes
// means something went wrong (e.g. StartTask API call failed silently).
@@ -42,6 +45,7 @@ func runRuntimeSweeper(ctx context.Context, queries *db.Queries, bus *events.Bus
case <-ticker.C:
sweepStaleRuntimes(ctx, queries, bus)
sweepStaleTasks(ctx, queries, bus)
gcRuntimes(ctx, queries, bus)
}
}
}
@@ -89,6 +93,38 @@ func sweepStaleRuntimes(ctx context.Context, queries *db.Queries, bus *events.Bu
}
}
// gcRuntimes deletes offline runtimes that have exceeded the TTL and have
// no active (non-archived) agents. Before deleting, it cleans up any
// archived agents so the FK constraint (ON DELETE RESTRICT) doesn't block.
func gcRuntimes(ctx context.Context, queries *db.Queries, bus *events.Bus) {
deleted, err := queries.DeleteStaleOfflineRuntimes(ctx, offlineRuntimeTTLSeconds)
if err != nil {
slog.Warn("runtime GC: failed to delete stale offline runtimes", "error", err)
return
}
if len(deleted) == 0 {
return
}
gcWorkspaces := make(map[string]bool)
for _, row := range deleted {
gcWorkspaces[util.UUIDToString(row.WorkspaceID)] = true
}
slog.Info("runtime GC: deleted stale offline runtimes", "count", len(deleted), "workspaces", len(gcWorkspaces))
for wsID := range gcWorkspaces {
bus.Publish(events.Event{
Type: protocol.EventDaemonRegister,
WorkspaceID: wsID,
ActorType: "system",
Payload: map[string]any{
"action": "runtime_gc",
},
})
}
}
// sweepStaleTasks fails tasks stuck in dispatched/running for too long,
// even when the runtime is still online. This handles cases where:
// - The agent process hangs and the daemon is still heartbeating

View File

@@ -14,11 +14,8 @@ import (
)
// APIClient is a REST client for the Multica server API.
// Used by ctrl subcommands (agent, runtime, status, etc.).
//
// TODO: Add Authorization header support. Agent routes (/api/agents/...)
// require JWT auth via middleware.Auth, but this client currently sends
// no auth token. CLI agent commands will fail with 401 until this is added.
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
// automatically include auth and execution context headers when configured.
type APIClient struct {
BaseURL string
WorkspaceID string

View File

@@ -84,17 +84,25 @@ func TestPostJSON(t *testing.T) {
}
})
t.Run("workspace header", func(t *testing.T) {
t.Run("workspace and agent context headers", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ws := r.Header.Get("X-Workspace-ID"); ws != "ws-abc" {
t.Errorf("expected X-Workspace-ID ws-abc, got %s", ws)
}
if agent := r.Header.Get("X-Agent-ID"); agent != "agent-123" {
t.Errorf("expected X-Agent-ID agent-123, got %s", agent)
}
if task := r.Header.Get("X-Task-ID"); task != "task-456" {
t.Errorf("expected X-Task-ID task-456, got %s", task)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody{ID: "456"})
}))
defer srv.Close()
client := NewAPIClient(srv.URL, "ws-abc", "test-token")
client.AgentID = "agent-123"
client.TaskID = "task-456"
var out respBody
err := client.PostJSON(context.Background(), "/test", reqBody{}, &out)
if err != nil {

View File

@@ -33,7 +33,7 @@ type Config struct {
RuntimeName string
CLIVersion string // multica CLI version (e.g. "0.1.13")
Profile string // profile name (empty = default)
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry, "openclaw" -> entry, "hermes" -> entry
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
KeepEnvAfterTask bool // preserve env after task for debugging
HealthPort int // local HTTP port for health checks (default: 19514)
@@ -113,8 +113,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_HERMES_MODEL")),
}
}
geminiPath := envOrDefault("MULTICA_GEMINI_PATH", "gemini")
if _, err := exec.LookPath(geminiPath); err == nil {
agents["gemini"] = AgentEntry{
Path: geminiPath,
Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")),
}
}
if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, or hermes and ensure it is on PATH")
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, or gemini and ensure it is on PATH")
}
// Host info
@@ -164,11 +171,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
if overrides.DaemonID != "" {
daemonID = overrides.DaemonID
}
// Suffix daemon ID with profile name to avoid collisions when multiple
// daemons register against the same server.
if profile != "" && !strings.HasSuffix(daemonID, "-"+profile) {
daemonID = daemonID + "-" + profile
}
// NOTE: daemon_id is intentionally stable (hostname or explicit override).
// The unique constraint (workspace_id, daemon_id, provider) already prevents
// collisions within the same workspace. Appending the profile name caused
// duplicate runtimes when users switched profiles.
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
if overrides.DeviceName != "" {

View File

@@ -525,7 +525,18 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
}
}()
result := <-session.Result
var result agent.Result
select {
case result = <-session.Result:
case <-pingCtx.Done():
d.logger.Warn("ping timed out waiting for result", "runtime_id", rt.ID, "ping_id", pingID)
d.client.ReportPingResult(ctx, rt.ID, pingID, map[string]any{
"status": "failed",
"error": "ping context cancelled while waiting for result",
"duration_ms": time.Since(start).Milliseconds(),
})
return
}
durationMs := time.Since(start).Milliseconds()
if result.Status == "completed" {
@@ -972,6 +983,20 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
if env.CodexHome != "" {
agentEnv["CODEX_HOME"] = env.CodexHome
}
// Inject user-configured custom environment variables (e.g. ANTHROPIC_API_KEY,
// ANTHROPIC_BASE_URL for router/proxy mode, or CLAUDE_CODE_USE_BEDROCK for
// Bedrock). These are set per-agent via the agent settings UI.
// Critical internal variables are blocklisted to prevent accidental or
// malicious override of daemon-set values.
if task.Agent != nil {
for k, v := range task.Agent.CustomEnv {
if isBlockedEnvKey(k) {
d.logger.Warn("custom_env: blocked key skipped", "key", k)
continue
}
agentEnv[k] = v
}
}
backend, err := agent.New(provider, agent.Config{
ExecutablePath: entry.Path,
Env: agentEnv,
@@ -1078,6 +1103,17 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
return agent.Result{}, 0, err
}
// Create an independent drain deadline so we don't block forever if the
// backend's internal timeout fails to produce a Result (e.g. scanner
// stuck on a hung stdout pipe). The extra 30 s gives the backend time
// to clean up after its own timeout fires.
drainTimeout := opts.Timeout + 30*time.Second
if opts.Timeout == 0 {
drainTimeout = 21 * time.Minute
}
drainCtx, drainCancel := context.WithTimeout(ctx, drainTimeout)
defer drainCancel()
var toolCount atomic.Int32
go func() {
var seq atomic.Int32
@@ -1135,77 +1171,92 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
}
}()
for msg := range session.Messages {
switch msg.Type {
case agent.MessageToolUse:
n := toolCount.Add(1)
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
if msg.CallID != "" {
for {
select {
case msg, ok := <-session.Messages:
if !ok {
goto drainDone
}
switch msg.Type {
case agent.MessageToolUse:
n := toolCount.Add(1)
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
if msg.CallID != "" {
mu.Lock()
callIDToTool[msg.CallID] = msg.Tool
mu.Unlock()
}
s := seq.Add(1)
mu.Lock()
callIDToTool[msg.CallID] = msg.Tool
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_use",
Tool: msg.Tool,
Input: msg.Input,
})
mu.Unlock()
case agent.MessageToolResult:
s := seq.Add(1)
output := msg.Output
if len(output) > 8192 {
output = output[:8192]
}
toolName := msg.Tool
if toolName == "" && msg.CallID != "" {
mu.Lock()
toolName = callIDToTool[msg.CallID]
mu.Unlock()
}
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_result",
Tool: toolName,
Output: output,
})
mu.Unlock()
case agent.MessageThinking:
if msg.Content != "" {
mu.Lock()
pendingThinking.WriteString(msg.Content)
mu.Unlock()
}
case agent.MessageText:
if msg.Content != "" {
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
mu.Lock()
pendingText.WriteString(msg.Content)
mu.Unlock()
}
case agent.MessageError:
taskLog.Error("agent error", "content", msg.Content)
s := seq.Add(1)
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "error",
Content: msg.Content,
})
mu.Unlock()
}
s := seq.Add(1)
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_use",
Tool: msg.Tool,
Input: msg.Input,
})
mu.Unlock()
case agent.MessageToolResult:
s := seq.Add(1)
output := msg.Output
if len(output) > 8192 {
output = output[:8192]
}
toolName := msg.Tool
if toolName == "" && msg.CallID != "" {
mu.Lock()
toolName = callIDToTool[msg.CallID]
mu.Unlock()
}
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_result",
Tool: toolName,
Output: output,
})
mu.Unlock()
case agent.MessageThinking:
if msg.Content != "" {
mu.Lock()
pendingThinking.WriteString(msg.Content)
mu.Unlock()
}
case agent.MessageText:
if msg.Content != "" {
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
mu.Lock()
pendingText.WriteString(msg.Content)
mu.Unlock()
}
case agent.MessageError:
taskLog.Error("agent error", "content", msg.Content)
s := seq.Add(1)
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "error",
Content: msg.Content,
})
mu.Unlock()
case <-drainCtx.Done():
goto drainDone
}
}
drainDone:
close(done)
flush()
}()
result := <-session.Result
return result, toolCount.Load(), nil
select {
case result := <-session.Result:
return result, toolCount.Load(), nil
case <-drainCtx.Done():
return agent.Result{
Status: "timeout",
Error: "agent did not produce result within drain timeout",
}, toolCount.Load(), nil
}
}
func mergeUsage(a, b map[string]agent.TokenUsage) map[string]agent.TokenUsage {
@@ -1288,3 +1339,18 @@ func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
}
return result
}
// isBlockedEnvKey returns true if the key must not be overridden by user-
// configured custom_env. This prevents accidental or malicious override of
// daemon-internal variables and critical system paths.
func isBlockedEnvKey(key string) bool {
upper := strings.ToUpper(key)
if strings.HasPrefix(upper, "MULTICA_") {
return true
}
switch upper {
case "HOME", "PATH", "USER", "SHELL", "TERM", "CODEX_HOME":
return true
}
return false
}

View File

@@ -390,6 +390,44 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
}
}
func TestInjectRuntimeConfigGemini(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
}
if err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "GEMINI.md"))
if err != nil {
t.Fatalf("failed to read GEMINI.md: %v", err)
}
s := string(content)
for _, want := range []string{
"Multica Agent Runtime",
"multica issue get",
"Writing",
} {
if !strings.Contains(s, want) {
t.Errorf("GEMINI.md missing %q", want)
}
}
// Should not write CLAUDE.md or AGENTS.md for gemini provider.
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create CLAUDE.md")
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create AGENTS.md")
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
t.Parallel()
dir := t.TempDir()

View File

@@ -14,6 +14,7 @@ import (
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)
@@ -22,6 +23,8 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
case "codex", "opencode", "openclaw":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
default:
// Unknown provider — skip config injection, prompt-only mode.
return nil
@@ -75,6 +78,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent <issue-id>] [--status X]` — Create a new issue\n")
b.WriteString("- `multica issue assign <id> --to <name>` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n")
b.WriteString("- `multica issue comment add <issue-id> --content \"...\" [--parent <comment-id>]` — Post a comment (use --parent to reply to a specific comment)\n")
b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add <issue-id> --content-stdin`\n")
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
@@ -110,11 +114,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- Keep responses concise and direct\n\n")
} else if ctx.TriggerCommentID != "" {
// Comment-triggered: focus on reading and replying
b.WriteString("**This task was triggered by a comment.** Your primary job is to respond.\n\n")
b.WriteString("**This task was triggered by a NEW comment.** Your primary job is to respond to THIS specific comment, even if you have handled similar requests before in this session.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked\n", ctx.TriggerCommentID)
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")
b.WriteString("6. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
@@ -139,6 +143,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
case "codex", "opencode", "openclaw":
// Codex, OpenCode, and OpenClaw discover skills natively from their respective paths — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini":
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
}

View File

@@ -30,7 +30,7 @@ func buildCommentPrompt(task Task) string {
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
if task.TriggerCommentContent != "" {
b.WriteString("A user left a comment that triggered this task. Here is their message:\n\n")
b.WriteString("[NEW COMMENT] A user just left a new comment that triggered this task. You MUST respond to THIS comment, not any previous ones:\n\n")
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)

View File

@@ -40,10 +40,11 @@ type Task struct {
// AgentData holds agent details returned by the claim endpoint.
type AgentData struct {
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []SkillData `json:"skills"`
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []SkillData `json:"skills"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
}
// SkillData represents a structured skill for task execution.

View File

@@ -14,24 +14,25 @@ import (
)
type AgentResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RuntimeID string `json:"runtime_id"`
Name string `json:"name"`
Description string `json:"description"`
Instructions string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID *string `json:"owner_id"`
Skills []SkillResponse `json:"skills"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ArchivedAt *string `json:"archived_at"`
ArchivedBy *string `json:"archived_by"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RuntimeID string `json:"runtime_id"`
Name string `json:"name"`
Description string `json:"description"`
Instructions string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
CustomEnv map[string]string `json:"custom_env"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID *string `json:"owner_id"`
Skills []SkillResponse `json:"skills"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ArchivedAt *string `json:"archived_at"`
ArchivedBy *string `json:"archived_by"`
}
func agentToResponse(a db.Agent) AgentResponse {
@@ -43,6 +44,16 @@ func agentToResponse(a db.Agent) AgentResponse {
rc = map[string]any{}
}
var customEnv map[string]string
if a.CustomEnv != nil {
if err := json.Unmarshal(a.CustomEnv, &customEnv); err != nil {
slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(a.ID), "error", err)
}
}
if customEnv == nil {
customEnv = map[string]string{}
}
return AgentResponse{
ID: uuidToString(a.ID),
WorkspaceID: uuidToString(a.WorkspaceID),
@@ -53,6 +64,7 @@ func agentToResponse(a db.Agent) AgentResponse {
AvatarURL: textToPtr(a.AvatarUrl),
RuntimeMode: a.RuntimeMode,
RuntimeConfig: rc,
CustomEnv: customEnv,
Visibility: a.Visibility,
Status: a.Status,
MaxConcurrentTasks: a.MaxConcurrentTasks,
@@ -103,6 +115,7 @@ type TaskAgentData struct {
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []service.AgentSkillData `json:"skills,omitempty"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
}
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
@@ -196,14 +209,15 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
}
type CreateAgentRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Instructions string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeID string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Name string `json:"name"`
Description string `json:"description"`
Instructions string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeID string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
CustomEnv map[string]string `json:"custom_env"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
}
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
@@ -249,6 +263,11 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
rc = []byte("{}")
}
ce, _ := json.Marshal(req.CustomEnv)
if req.CustomEnv == nil {
ce = []byte("{}")
}
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
@@ -261,6 +280,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
Visibility: req.Visibility,
MaxConcurrentTasks: req.MaxConcurrentTasks,
OwnerID: parseUUID(ownerID),
CustomEnv: ce,
})
if err != nil {
slog.Warn("create agent failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@@ -283,15 +303,16 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
type UpdateAgentRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Instructions *string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeID *string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
Visibility *string `json:"visibility"`
Status *string `json:"status"`
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
Name *string `json:"name"`
Description *string `json:"description"`
Instructions *string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
RuntimeID *string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
CustomEnv *map[string]string `json:"custom_env"`
Visibility *string `json:"visibility"`
Status *string `json:"status"`
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
}
// canManageAgent checks whether the current user can update or archive an agent.
@@ -347,6 +368,10 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
rc, _ := json.Marshal(req.RuntimeConfig)
params.RuntimeConfig = rc
}
if req.CustomEnv != nil {
ce, _ := json.Marshal(*req.CustomEnv)
params.CustomEnv = ce
}
if req.RuntimeID != nil {
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
ID: parseUUID(*req.RuntimeID),

View File

@@ -110,9 +110,9 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
return
}
// Rate limit: max 1 code per 10 seconds per email
// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 10*time.Second {
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
return
}
@@ -390,6 +390,31 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
})
}
// IssueCliToken returns a fresh JWT for the authenticated user.
// This allows cookie-authenticated browser sessions to obtain a bearer token
// that can be handed off to the CLI via the cli_callback redirect.
func (h *Handler) IssueCliToken(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
slog.Warn("cli-token: failed to issue JWT", append(logger.RequestAttrs(r), "error", err, "user_id", userID)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": tokenString})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
auth.ClearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})

View File

@@ -217,6 +217,28 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
return
}
// Migrate agents from old offline runtimes on the same machine to the
// newly registered runtime. Uses the runtime's owner_id (preserved via
// COALESCE on upsert) so migration works with both PAT and daemon tokens.
// Scoped by daemon_id prefix so that only old profile-suffixed runtimes
// (e.g. "hostname-staging") from this machine are affected.
effectiveOwnerID := registered.OwnerID
if effectiveOwnerID.Valid {
migrated, err := h.Queries.MigrateAgentsToRuntime(r.Context(), db.MigrateAgentsToRuntimeParams{
NewRuntimeID: registered.ID,
WorkspaceID: parseUUID(req.WorkspaceID),
Provider: provider,
OwnerID: effectiveOwnerID,
DaemonIDPrefix: strToText(req.DaemonID),
})
if err != nil {
slog.Warn("failed to migrate agents to new runtime", "runtime_id", uuidToString(registered.ID), "error", err)
} else if migrated > 0 {
slog.Info("migrated agents to new runtime", "runtime_id", uuidToString(registered.ID), "provider", provider, "migrated_count", migrated)
}
}
resp = append(resp, runtimeToResponse(registered))
}
@@ -358,15 +380,22 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
return
}
// Build response with fresh agent data (name + skills).
// Build response with fresh agent data (name + skills + custom_env).
resp := taskToResponse(*task)
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
var customEnv map[string]string
if agent.CustomEnv != nil {
if err := json.Unmarshal(agent.CustomEnv, &customEnv); err != nil {
slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(agent.ID), "error", err)
}
}
resp.Agent = &TaskAgentData{
ID: uuidToString(agent.ID),
Name: agent.Name,
Instructions: agent.Instructions,
Skills: skills,
CustomEnv: customEnv,
}
}

View File

@@ -160,17 +160,21 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
key := id.String() + path.Ext(header.Filename)
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
return
filename := id.String() + path.Ext(header.Filename)
var key string
if workspaceID != "" {
key = "workspaces/" + workspaceID + "/" + filename
} else {
key = "users/" + userID + "/" + filename
}
// If workspace context is available, create an attachment record.
// If workspace context is available, validate membership before uploading.
if workspaceID != "" {
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
writeError(w, http.StatusForbidden, "not a member of this workspace")
return
}
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
params := db.CreateAttachmentParams{
@@ -179,12 +183,10 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
UploaderType: uploaderType,
UploaderID: parseUUID(uploaderID),
Filename: header.Filename,
Url: link,
ContentType: contentType,
SizeBytes: int64(len(data)),
}
// Optional issue_id / comment_id from form fields — validate ownership.
if issueID := r.FormValue("issue_id"); issueID != "" {
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: parseUUID(issueID),
@@ -205,6 +207,14 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
params.CommentID = comment.ID
}
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
return
}
params.Url = link
att, err := h.Queries.CreateAttachment(r.Context(), params)
if err != nil {
slog.Error("failed to create attachment record", "error", err)
@@ -214,9 +224,21 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"filename": header.Filename,
"link": link,
})
return
}
// Fallback response (no workspace context, e.g. avatar upload)
// No workspace context (e.g. avatar upload) — upload directly.
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"filename": header.Filename,
"link": link,

View File

@@ -0,0 +1,48 @@
package handler
import (
"bytes"
"context"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
type mockStorage struct{}
func (m *mockStorage) Upload(_ context.Context, key string, _ []byte, _ string, _ string) (string, error) {
return fmt.Sprintf("https://cdn.example.com/%s", key), nil
}
func (m *mockStorage) Delete(_ context.Context, _ string) {}
func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {}
func (m *mockStorage) KeyFromURL(rawURL string) string { return rawURL }
func TestUploadFileForeignWorkspace(t *testing.T) {
origStorage := testHandler.Storage
testHandler.Storage = &mockStorage{}
defer func() { testHandler.Storage = origStorage }()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "test.txt")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("hello world"))
writer.Close()
foreignWorkspaceID := "00000000-0000-0000-0000-000000000099"
req := httptest.NewRequest("POST", "/api/upload-file", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", foreignWorkspaceID)
w := httptest.NewRecorder()
testHandler.UploadFile(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UploadFile with foreign workspace: expected 403, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -246,6 +246,24 @@ func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, w
return member, true
}
// isWorkspaceEntity checks whether a user_id belongs to the given workspace,
// as either a member or an agent depending on userType.
func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, workspaceID string) bool {
switch userType {
case "member":
_, err := h.getWorkspaceMember(ctx, userID, workspaceID)
return err == nil
case "agent":
_, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: parseUUID(userID),
WorkspaceID: parseUUID(workspaceID),
})
return err == nil
default:
return false
}
}
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
if _, ok := requireUserID(w, r); !ok {
return db.Issue{}, false

View File

@@ -302,6 +302,156 @@ func TestCreateIssueExplicitBacklogPreserved(t *testing.T) {
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
func TestCreateSubIssueInheritsParentProject(t *testing.T) {
var projectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
if projectID != "" {
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Sub-issue inheritance project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
}
var project ProjectResponse
json.NewDecoder(w.Body).Decode(&project)
projectID = project.ID
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": projectID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != projectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", projectID, parent.ProjectID)
}
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child without explicit project",
"parent_issue_id": parentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
}
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != projectID {
t.Fatalf("CreateIssue child: expected inherited project_id %q, got %v", projectID, child.ProjectID)
}
}
func TestCreateSubIssueUsesExplicitProjectOverParentProject(t *testing.T) {
var parentProjectID, childProjectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
for _, projectID := range []string{childProjectID, parentProjectID} {
if projectID == "" {
continue
}
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject parent: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parentProject ProjectResponse
json.NewDecoder(w.Body).Decode(&parentProject)
parentProjectID = parentProject.ID
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child explicit project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject child: expected 201, got %d: %s", w.Code, w.Body.String())
}
var childProject ProjectResponse
json.NewDecoder(w.Body).Decode(&childProject)
childProjectID = childProject.ID
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": parentProjectID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != parentProjectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", parentProjectID, parent.ProjectID)
}
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child with explicit project",
"parent_issue_id": parentID,
"project_id": childProjectID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
}
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != childProjectID {
t.Fatalf("CreateIssue child: expected explicit project_id %q, got %v", childProjectID, child.ProjectID)
}
}
func TestCommentCRUD(t *testing.T) {
// Create an issue first
w := httptest.NewRecorder()

View File

@@ -815,6 +815,10 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
var parentIssueID pgtype.UUID
var projectID pgtype.UUID
if req.ProjectID != nil {
projectID = parseUUID(*req.ProjectID)
}
if req.ParentIssueID != nil {
parentIssueID = parseUUID(*req.ParentIssueID)
// Validate parent exists in the same workspace.
@@ -826,6 +830,9 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
return
}
if req.ProjectID == nil {
projectID = parent.ProjectID
}
}
var dueDate pgtype.Timestamptz
@@ -872,7 +879,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
Position: 0,
DueDate: dueDate,
Number: issueNumber,
ProjectID: func() pgtype.UUID { if req.ProjectID != nil { return parseUUID(*req.ProjectID) }; return pgtype.UUID{} }(),
ProjectID: projectID,
})
if err != nil {
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@@ -1115,6 +1122,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
// Cancel active tasks when the issue is cancelled by a user.
// This is distinct from agent-managed status transitions — cancellation
// is a user-initiated terminal action that should stop execution.
if statusChanged && issue.Status == "cancelled" {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
}
writeJSON(w, http.StatusOK, resp)
}
@@ -1404,6 +1418,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
// Cancel active tasks when the issue is cancelled by a user.
if statusChanged && issue.Status == "cancelled" {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
}
updated++
}

View File

@@ -145,7 +145,7 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
RuntimeID: parseUUID(runtimeID),
Since: since,
Date: since,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage")

View File

@@ -76,6 +76,12 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.AddIssueSubscriber(r.Context(), db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
@@ -87,7 +93,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
callerID := requestUserID(r)
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
@@ -125,6 +130,12 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.RemoveIssueSubscriber(r.Context(), db.RemoveIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
@@ -135,7 +146,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
callerID := requestUserID(r)
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{

View File

@@ -174,6 +174,52 @@ func TestSubscriberAPI(t *testing.T) {
}
})
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(foreignUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
}
if subscribed {
t.Fatal("cross-workspace user should NOT be subscribed in DB")
}
})
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
})
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)

View File

@@ -147,6 +147,13 @@ func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UU
// so frontends can update immediately.
func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) {
task, err := s.Queries.CancelAgentTask(ctx, taskID)
if errors.Is(err, pgx.ErrNoRows) {
existing, err := s.Queries.GetAgentTask(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("cancel task: %w", err)
}
return &existing, nil
}
if err != nil {
return nil, fmt.Errorf("cancel task: %w", err)
}

View File

@@ -48,12 +48,8 @@ func (s *LocalStorage) KeyFromURL(rawURL string) string {
}
prefix := "/uploads/"
if strings.HasPrefix(rawURL, prefix) {
filename := strings.TrimPrefix(rawURL, prefix)
if i := strings.LastIndex(filename, "/"); i >= 0 {
return filename[i+1:]
}
return filename
if idx := strings.Index(rawURL, prefix); idx >= 0 {
return rawURL[idx+len(prefix):]
}
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
return rawURL[i+1:]
@@ -81,6 +77,9 @@ func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
func (s *LocalStorage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
dest := filepath.Join(s.uploadDir, key)
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return "", fmt.Errorf("local storage MkdirAll: %w", err)
}
if err := os.WriteFile(dest, data, 0644); err != nil {
return "", fmt.Errorf("local storage WriteFile: %w", err)
}

View File

@@ -124,7 +124,8 @@ func TestLocalStorage_KeyFromURL(t *testing.T) {
expected string
}{
{"local URL format", "/uploads/abc123.png", "abc123.png"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
{"local URL with workspace prefix", "/uploads/workspaces/ws-123/abc.png", "workspaces/ws-123/abc.png"},
{"just filename", "abc123.png", "abc123.png"},
{"full path", "/some/path/to/file.pdf", "file.pdf"},
}
@@ -155,7 +156,7 @@ func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
expected string
}{
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "image.jpg"},
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
}

View File

@@ -0,0 +1 @@
ALTER TABLE agent DROP COLUMN custom_env;

View File

@@ -0,0 +1,5 @@
-- Add custom_env column to agent table for user-configurable environment
-- variables that get injected into the agent subprocess at launch time.
-- Supports router/proxy (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL),
-- Bedrock (CLAUDE_CODE_USE_BEDROCK + AWS creds), and Vertex AI modes.
ALTER TABLE agent ADD COLUMN custom_env JSONB NOT NULL DEFAULT '{}';

View File

@@ -82,13 +82,13 @@ type Result struct {
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, or hermes)
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, hermes, or gemini)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes".
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@@ -105,8 +105,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &openclawBackend{cfg: cfg}, nil
case "hermes":
return &hermesBackend{cfg: cfg}, nil
case "gemini":
return &geminiBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes)", agentType)
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini)", agentType)
}
}

View File

@@ -37,6 +37,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
args := buildClaudeArgs(opts)
cmd := exec.CommandContext(runCtx, execPath, args...)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
@@ -90,6 +91,12 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
var finalError string
usage := make(map[string]TokenUsage)
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)

255
server/pkg/agent/gemini.go Normal file
View File

@@ -0,0 +1,255 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// geminiBackend implements Backend by spawning the Google Gemini CLI
// with `--output-format stream-json` and parsing its NDJSON event stream.
type geminiBackend struct {
cfg Config
}
func (b *geminiBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "gemini"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("gemini executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := buildGeminiArgs(prompt, opts)
cmd := exec.CommandContext(runCtx, execPath, args...)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("gemini stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[gemini:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start gemini: %w", err)
}
b.cfg.Logger.Info("gemini started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
usage := make(map[string]TokenUsage)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt geminiStreamEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
switch evt.Type {
case "init":
sessionID = evt.SessionID
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "message":
if evt.Role == "assistant" && evt.Content != "" {
output.WriteString(evt.Content)
trySend(msgCh, Message{Type: MessageText, Content: evt.Content})
}
case "tool_use":
var params map[string]any
if evt.Parameters != nil {
_ = json.Unmarshal(evt.Parameters, &params)
}
trySend(msgCh, Message{
Type: MessageToolUse,
Tool: evt.ToolName,
CallID: evt.ToolID,
Input: params,
})
case "tool_result":
trySend(msgCh, Message{
Type: MessageToolResult,
CallID: evt.ToolID,
Output: evt.Output,
})
case "error":
trySend(msgCh, Message{
Type: MessageError,
Content: evt.Message,
})
case "result":
if evt.Status == "error" && evt.Error != nil {
finalStatus = "failed"
finalError = evt.Error.Message
}
if evt.Stats != nil {
b.accumulateUsage(usage, evt.Stats)
}
}
}
waitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("gemini timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if waitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("gemini exited with error: %v", waitErr)
}
b.cfg.Logger.Info("gemini finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
Usage: usage,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// accumulateUsage extracts per-model token usage from Gemini's result stats.
func (b *geminiBackend) accumulateUsage(usage map[string]TokenUsage, stats *geminiStreamStats) {
for model, m := range stats.Models {
u := usage[model]
u.InputTokens += int64(m.InputTokens)
u.OutputTokens += int64(m.OutputTokens)
u.CacheReadTokens += int64(m.Cached)
usage[model] = u
}
}
// ── Gemini stream-json event types ──
type geminiStreamEvent struct {
Type string `json:"type"`
Timestamp string `json:"timestamp,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// message fields
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta bool `json:"delta,omitempty"`
// tool_use fields
ToolName string `json:"tool_name,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
// tool_result fields
Status string `json:"status,omitempty"`
Output string `json:"output,omitempty"`
// error fields
Severity string `json:"severity,omitempty"`
Message string `json:"message,omitempty"`
// result fields
Error *geminiStreamError `json:"error,omitempty"`
Stats *geminiStreamStats `json:"stats,omitempty"`
}
type geminiStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
type geminiStreamStats struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
DurationMs int `json:"duration_ms"`
ToolCalls int `json:"tool_calls"`
Models map[string]geminiModelStats `json:"models,omitempty"`
}
type geminiModelStats struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
Cached int `json:"cached"`
}
// ── Arg builder ──
// buildGeminiArgs assembles the argv for a one-shot gemini invocation.
//
// Flags:
//
// -p / --prompt non-interactive prompt (the user's task)
// --yolo auto-approve all tool executions
// -o stream-json streaming NDJSON output for live events
// -m <model> optional model override
// -r <session> resume a previous session (if provided)
func buildGeminiArgs(prompt string, opts ExecOptions) []string {
args := []string{
"-p", prompt,
"--yolo",
"-o", "stream-json",
}
if opts.Model != "" {
args = append(args, "-m", opts.Model)
}
if opts.ResumeSessionID != "" {
args = append(args, "-r", opts.ResumeSessionID)
}
return args
}

View File

@@ -0,0 +1,79 @@
package agent
import (
"testing"
)
func TestBuildGeminiArgsBaseline(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("write a haiku", ExecOptions{})
expected := []string{
"-p", "write a haiku",
"--yolo",
"-o", "stream-json",
}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Fatalf("expected args[%d] = %q, got %q", i, want, args[i])
}
}
}
func TestBuildGeminiArgsWithModel(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{Model: "gemini-2.5-pro"})
var foundModel bool
for i, a := range args {
if a == "-m" {
if i+1 >= len(args) || args[i+1] != "gemini-2.5-pro" {
t.Fatalf("expected -m followed by gemini-2.5-pro, got %v", args)
}
foundModel = true
break
}
}
if !foundModel {
t.Fatalf("expected -m flag when Model is set, got args=%v", args)
}
}
func TestBuildGeminiArgsWithResume(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{ResumeSessionID: "3"})
var foundResume bool
for i, a := range args {
if a == "-r" {
if i+1 >= len(args) || args[i+1] != "3" {
t.Fatalf("expected -r followed by session id, got %v", args)
}
foundResume = true
break
}
}
if !foundResume {
t.Fatalf("expected -r flag when ResumeSessionID is set, got args=%v", args)
}
}
func TestBuildGeminiArgsOmitsModelWhenEmpty(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{})
for _, a := range args {
if a == "-m" {
t.Fatalf("expected no -m flag when Model is empty, got args=%v", args)
}
if a == "-r" {
t.Fatalf("expected no -r flag when ResumeSessionID is empty, got args=%v", args)
}
}
}

View File

@@ -38,12 +38,19 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano())
}
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.SystemPrompt != "" {
args = append(args, "--system-prompt", opts.SystemPrompt)
}
if opts.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
}
args = append(args, "--message", prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
@@ -67,6 +74,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stderr when the context is cancelled so the scanner unblocks.
go func() {
<-runCtx.Done()
_ = stderr.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
@@ -129,22 +142,108 @@ type openclawEventResult struct {
}
// processOutput reads the JSON output from openclaw --json stderr and returns
// the parsed result. OpenClaw writes its JSON result to stderr, which may also
// contain non-JSON log lines. We scan line-by-line so a final result line can
// be recognized without waiting for the entire stderr stream to be buffered.
// the parsed result. OpenClaw writes its JSON output to stderr, which may also
// contain non-JSON log lines. The stream may contain:
//
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
// "step_start", "step_finish") — emitted in real time as the agent works
// - A final result JSON (with payloads + meta) — the legacy single-blob format
//
// We scan line-by-line, emitting messages as events arrive so streaming
// consumers get real-time feedback instead of waiting for the final blob.
func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclawEventResult {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
var output strings.Builder
var sessionID string
var usage TokenUsage
finalStatus := "completed"
var finalError string
gotEvents := false // true if we parsed at least one streaming event or result
var rawLines []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if result, ok := tryParseOpenclawResult(line); ok {
return b.buildOpenclawEventResult(result, ch)
// Try parsing as a streaming NDJSON event first.
if event, ok := tryParseOpenclawEvent(line); ok {
gotEvents = true
if event.SessionID != "" {
sessionID = event.SessionID
}
switch event.Type {
case "text":
if event.Text != "" {
output.WriteString(event.Text)
trySend(ch, Message{Type: MessageText, Content: event.Text})
}
case "tool_use":
var input map[string]any
if event.Input != nil {
_ = json.Unmarshal(event.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: event.Tool,
CallID: event.CallID,
Input: input,
})
case "tool_result":
trySend(ch, Message{
Type: MessageToolResult,
Tool: event.Tool,
CallID: event.CallID,
Output: event.Text,
})
case "error":
errMsg := event.errorMessage()
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
finalStatus = "failed"
finalError = errMsg
case "lifecycle":
phase := event.Phase
if phase == "error" || phase == "failed" || phase == "cancelled" {
errMsg := event.errorMessage()
b.cfg.Logger.Warn("openclaw lifecycle failure", "phase", phase, "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
finalStatus = "failed"
finalError = errMsg
}
case "step_start":
trySend(ch, Message{Type: MessageStatus, Status: "running"})
case "step_finish":
if event.Usage != nil {
u := parseOpenclawUsage(event.Usage)
usage.InputTokens += u.InputTokens
usage.OutputTokens += u.OutputTokens
usage.CacheReadTokens += u.CacheReadTokens
usage.CacheWriteTokens += u.CacheWriteTokens
}
}
continue
}
// Try parsing as a final result blob (legacy format).
if result, ok := tryParseOpenclawResult(line); ok {
gotEvents = true
res := b.buildOpenclawEventResult(result, ch, &output)
if res.sessionID != "" {
sessionID = res.sessionID
}
// Prefer usage from the final result if no streaming events reported it.
u := res.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
usage = u
}
continue
}
// Not JSON — treat as log line.
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
rawLines = append(rawLines, line)
}
@@ -153,36 +252,82 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)}
}
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
if trimmed != "" {
return openclawEventResult{status: "completed", output: trimmed}
// If we got no events at all, fall back to raw output.
if !gotEvents {
// OpenClaw may output pretty-printed (multi-line) JSON. No single line
// would parse, so try parsing the accumulated output as a whole.
// Log lines may precede the JSON, so find the first '{' at line start.
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
if trimmed != "" {
if result, ok := tryParseOpenclawResult(trimmed); ok {
return b.buildOpenclawEventResult(result, ch, &output)
}
// Log lines may precede the JSON blob. Find the first line that
// starts with '{' and try parsing from there.
for i, line := range rawLines {
if len(line) > 0 && line[0] == '{' {
candidate := strings.TrimSpace(strings.Join(rawLines[i:], "\n"))
if result, ok := tryParseOpenclawResult(candidate); ok {
return b.buildOpenclawEventResult(result, ch, &output)
}
break
}
}
return openclawEventResult{status: "completed", output: trimmed}
}
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
}
return openclawEventResult{
status: finalStatus,
errMsg: finalError,
output: output.String(),
sessionID: sessionID,
usage: usage,
}
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
}
// tryParseOpenclawEvent attempts to parse a line as a streaming NDJSON event.
// Returns the event and true if the line is a valid event with a known type.
func tryParseOpenclawEvent(line string) (openclawEvent, bool) {
if len(line) == 0 || line[0] != '{' {
return openclawEvent{}, false
}
var event openclawEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
return openclawEvent{}, false
}
if event.Type == "" {
return openclawEvent{}, false
}
return event, true
}
// tryParseOpenclawResult attempts to parse a line as a final result blob
// (the legacy format with payloads + meta). Lines must start with '{' to be
// considered — we no longer scan for braces at arbitrary positions, which
// avoids false matches on log lines containing JSON fragments.
func tryParseOpenclawResult(raw string) (openclawResult, bool) {
// Try each '{' position until we find valid openclawResult JSON.
// Earlier '{' chars may appear in log/error lines (e.g. raw_params={...}).
var result openclawResult
for i := 0; i < len(raw); i++ {
if raw[i] != '{' {
continue
}
if err := json.Unmarshal([]byte(raw[i:]), &result); err == nil && (result.Payloads != nil || result.Meta.DurationMs > 0) {
return result, true
}
if len(raw) == 0 || raw[0] != '{' {
return openclawResult{}, false
}
return openclawResult{}, false
var result openclawResult
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return openclawResult{}, false
}
if result.Payloads == nil && result.Meta.DurationMs == 0 {
return openclawResult{}, false
}
return result, true
}
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message) openclawEventResult {
var output strings.Builder
// buildOpenclawEventResult extracts text and metadata from a final result blob.
// Text payloads are appended to the shared output builder and emitted to ch.
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message, output *strings.Builder) openclawEventResult {
for _, p := range result.Payloads {
if p.Text != "" {
if output.Len() > 0 {
output.WriteString("\n")
}
output.WriteString(p.Text)
trySend(ch, Message{Type: MessageText, Content: p.Text})
}
}
@@ -193,17 +338,10 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
sessionID = sid
}
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
usage.InputTokens = openclawInt64(u, "input")
usage.OutputTokens = openclawInt64(u, "output")
usage.CacheReadTokens = openclawInt64(u, "cacheRead")
usage.CacheWriteTokens = openclawInt64(u, "cacheWrite")
usage = parseOpenclawUsage(u)
}
}
if output.Len() > 0 {
trySend(ch, Message{Type: MessageText, Content: output.String()})
}
return openclawEventResult{
status: "completed",
output: output.String(),
@@ -212,6 +350,33 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
}
}
// parseOpenclawUsage extracts token usage from a map, supporting multiple
// field name conventions used by different OpenClaw versions and PaperClip:
//
// input / inputTokens / input_tokens
// output / outputTokens / output_tokens
// cacheRead / cachedInputTokens / cached_input_tokens / cache_read
// cacheWrite / cacheCreationInputTokens / cache_creation_input_tokens / cache_write
func parseOpenclawUsage(data map[string]any) TokenUsage {
return TokenUsage{
InputTokens: openclawInt64FirstOf(data, "input", "inputTokens", "input_tokens"),
OutputTokens: openclawInt64FirstOf(data, "output", "outputTokens", "output_tokens"),
CacheReadTokens: openclawInt64FirstOf(data, "cacheRead", "cachedInputTokens", "cached_input_tokens", "cache_read", "cache_read_input_tokens"),
CacheWriteTokens: openclawInt64FirstOf(data, "cacheWrite", "cacheCreationInputTokens", "cache_creation_input_tokens", "cache_write"),
}
}
// openclawInt64FirstOf returns the first non-zero int64 value found under any
// of the given keys. This supports field name variants across protocol versions.
func openclawInt64FirstOf(data map[string]any, keys ...string) int64 {
for _, key := range keys {
if v := openclawInt64(data, key); v != 0 {
return v
}
}
return 0
}
// openclawInt64 safely extracts an int64 from a JSON-decoded map value (which
// may be float64 due to Go's JSON number handling).
func openclawInt64(data map[string]any, key string) int64 {
@@ -231,7 +396,73 @@ func openclawInt64(data map[string]any, key string) int64 {
// ── JSON types for `openclaw agent --json` output ──
// openclawResult represents the JSON output from `openclaw agent --json`.
// openclawEvent represents a single streaming NDJSON event from openclaw --json.
//
// Event types:
// - "text" — text output (text field)
// - "tool_use" — tool invocation (tool, callId, input)
// - "tool_result" — tool output (tool, callId, text)
// - "error" — error (text, or structured error object)
// - "lifecycle" — phase changes (phase: "error"/"failed"/"cancelled")
// - "step_start" — agent step begins
// - "step_finish" — agent step ends (usage)
type openclawEvent struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
CallID string `json:"callId,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Usage map[string]any `json:"usage,omitempty"`
Phase string `json:"phase,omitempty"` // lifecycle event phase
Error *openclawError `json:"error,omitempty"` // structured error object
Message string `json:"message,omitempty"` // alternative error message field
}
// errorMessage extracts a human-readable error message from the event,
// checking multiple fields: structured error object, text, message, or fallback.
func (e openclawEvent) errorMessage() string {
if e.Error != nil {
if msg := e.Error.message(); msg != "" {
return msg
}
}
if e.Text != "" {
return e.Text
}
if e.Message != "" {
return e.Message
}
return "unknown openclaw error"
}
// openclawError represents a structured error in an openclaw event,
// compatible with PaperClip's error format (name + data.message).
type openclawError struct {
Name string `json:"name,omitempty"`
Data *openclawErrorData `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
func (e *openclawError) message() string {
if e.Data != nil && e.Data.Message != "" {
return e.Data.Message
}
if e.Message != "" {
return e.Message
}
if e.Name != "" {
return e.Name
}
return ""
}
type openclawErrorData struct {
Message string `json:"message,omitempty"`
}
// openclawResult represents the final JSON output from `openclaw agent --json`
// (the legacy single-blob format with payloads + meta).
type openclawResult struct {
Payloads []openclawPayload `json:"payloads"`
Meta openclawMeta `json:"meta"`

View File

@@ -18,7 +18,7 @@ func TestNewReturnsOpenclawBackend(t *testing.T) {
}
}
// ── processOutput tests ──
// ── Legacy result format tests (processOutput with final JSON blob) ──
func TestOpenclawProcessOutputHappyPath(t *testing.T) {
t.Parallel()
@@ -90,11 +90,24 @@ func TestOpenclawProcessOutputMultiplePayloads(t *testing.T) {
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.output != "First\nSecond" {
t.Errorf("output: got %q, want %q", res.output, "First\nSecond")
if res.output != "FirstSecond" {
t.Errorf("output: got %q, want %q", res.output, "FirstSecond")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 text messages, got %d", len(msgs))
}
if msgs[0].Content != "First" {
t.Errorf("msg[0]: got %q, want %q", msgs[0].Content, "First")
}
if msgs[1].Content != "Second" {
t.Errorf("msg[1]: got %q, want %q", msgs[1].Content, "Second")
}
}
func TestOpenclawProcessOutputEmptyPayloads(t *testing.T) {
@@ -238,7 +251,8 @@ func TestOpenclawProcessOutputWithBracesInLogLines(t *testing.T) {
Meta: openclawMeta{DurationMs: 500},
}
data, _ := json.Marshal(result)
// Simulate error line containing braces before the real JSON (the exact bug scenario)
// Log line with braces should NOT be parsed as JSON — only lines starting
// with '{' are considered. The result blob on its own line is still parsed.
input := `[tools] exec failed: complex interpreter invocation detected. raw_params={"command":"echo hello"}` + "\n" + string(data)
res := b.processOutput(strings.NewReader(input), ch)
@@ -253,6 +267,627 @@ func TestOpenclawProcessOutputWithBracesInLogLines(t *testing.T) {
close(ch)
}
func TestOpenclawResultBlobWithLeadingPrefixRejected(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// A line with a prefix before the JSON should NOT be parsed as a result.
// This tests that the hardened parser rejects non-'{'-starting lines.
result := openclawResult{
Payloads: []openclawPayload{{Text: "Should not match"}},
Meta: openclawMeta{DurationMs: 500},
}
data, _ := json.Marshal(result)
input := "some prefix " + string(data)
res := b.processOutput(strings.NewReader(input), ch)
// Should fall back to raw output since the JSON has a prefix.
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != input {
t.Errorf("output: got %q, want raw input back", res.output)
}
close(ch)
}
// ── Streaming NDJSON event tests ──
func TestOpenclawStreamingTextEvents(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"text","text":"Hello "}`,
`{"type":"text","text":"world"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Hello world" {
t.Errorf("output: got %q, want %q", res.output, "Hello world")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Type != MessageText || msgs[0].Content != "Hello " {
t.Errorf("msg[0]: type=%s content=%q", msgs[0].Type, msgs[0].Content)
}
if msgs[1].Type != MessageText || msgs[1].Content != "world" {
t.Errorf("msg[1]: type=%s content=%q", msgs[1].Type, msgs[1].Content)
}
}
func TestOpenclawStreamingToolUseEvents(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"tool_use","tool":"bash","callId":"call_1","input":{"command":"ls -la"}}`,
`{"type":"tool_result","tool":"bash","callId":"call_1","text":"total 42\ndrwxr-xr-x"}`,
`{"type":"text","text":"Listed files."}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
// tool_use
if msgs[0].Type != MessageToolUse {
t.Errorf("msg[0] type: got %s, want tool-use", msgs[0].Type)
}
if msgs[0].Tool != "bash" {
t.Errorf("msg[0] tool: got %q, want %q", msgs[0].Tool, "bash")
}
if msgs[0].CallID != "call_1" {
t.Errorf("msg[0] callID: got %q, want %q", msgs[0].CallID, "call_1")
}
if msgs[0].Input["command"] != "ls -la" {
t.Errorf("msg[0] input: got %v", msgs[0].Input)
}
// tool_result
if msgs[1].Type != MessageToolResult {
t.Errorf("msg[1] type: got %s, want tool-result", msgs[1].Type)
}
if msgs[1].CallID != "call_1" {
t.Errorf("msg[1] callID: got %q", msgs[1].CallID)
}
if msgs[1].Output != "total 42\ndrwxr-xr-x" {
t.Errorf("msg[1] output: got %q", msgs[1].Output)
}
// text
if msgs[2].Type != MessageText || msgs[2].Content != "Listed files." {
t.Errorf("msg[2]: type=%s content=%q", msgs[2].Type, msgs[2].Content)
}
}
func TestOpenclawStreamingErrorEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"text","text":"Starting..."}`,
`{"type":"error","text":"model not found: gpt-99"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "model not found: gpt-99" {
t.Errorf("errMsg: got %q", res.errMsg)
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[1].Type != MessageError {
t.Errorf("msg[1] type: got %s, want error", msgs[1].Type)
}
}
func TestOpenclawStreamingStepFinishUsage(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"step_start"}`,
`{"type":"text","text":"Done"}`,
`{"type":"step_finish","usage":{"input":200,"output":100,"cacheRead":50,"cacheWrite":25}}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.usage.InputTokens != 200 {
t.Errorf("input tokens: got %d, want 200", res.usage.InputTokens)
}
if res.usage.OutputTokens != 100 {
t.Errorf("output tokens: got %d, want 100", res.usage.OutputTokens)
}
if res.usage.CacheReadTokens != 50 {
t.Errorf("cache read: got %d, want 50", res.usage.CacheReadTokens)
}
if res.usage.CacheWriteTokens != 25 {
t.Errorf("cache write: got %d, want 25", res.usage.CacheWriteTokens)
}
close(ch)
}
func TestOpenclawStreamingSessionID(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"text","text":"Hi","sessionId":"ses_stream_123"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.sessionID != "ses_stream_123" {
t.Errorf("sessionID: got %q, want %q", res.sessionID, "ses_stream_123")
}
close(ch)
}
func TestOpenclawStreamingMixedWithLogLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
"[info] initializing agent...",
`{"type":"text","text":"Hello"}`,
"[debug] tool exec completed",
`{"type":"text","text":" world"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Hello world" {
t.Errorf("output: got %q, want %q", res.output, "Hello world")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 text messages, got %d", len(msgs))
}
}
// ── Lifecycle event tests ──
func TestOpenclawLifecycleErrorPhase(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"text","text":"Working..."}`,
`{"type":"lifecycle","phase":"error","text":"agent crashed unexpectedly"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "agent crashed unexpectedly" {
t.Errorf("errMsg: got %q", res.errMsg)
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[1].Type != MessageError {
t.Errorf("msg[1] type: got %s, want error", msgs[1].Type)
}
}
func TestOpenclawLifecycleFailedPhase(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"lifecycle","phase":"failed","message":"timeout exceeded"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "timeout exceeded" {
t.Errorf("errMsg: got %q, want %q", res.errMsg, "timeout exceeded")
}
close(ch)
}
func TestOpenclawLifecycleCancelledPhase(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"lifecycle","phase":"cancelled"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
// With no text/message/error, should get the default.
if res.errMsg != "unknown openclaw error" {
t.Errorf("errMsg: got %q", res.errMsg)
}
close(ch)
}
func TestOpenclawLifecycleRunningPhaseIgnored(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"lifecycle","phase":"running"}`,
`{"type":"text","text":"Hello"}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
close(ch)
}
// ── Structured error tests ──
func TestOpenclawStructuredErrorObject(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"error","error":{"name":"ModelNotFoundError","data":{"message":"model gpt-99 not available"}}}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "model gpt-99 not available" {
t.Errorf("errMsg: got %q, want %q", res.errMsg, "model gpt-99 not available")
}
close(ch)
}
func TestOpenclawStructuredErrorNameOnly(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"error","error":{"name":"AuthenticationError"}}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.errMsg != "AuthenticationError" {
t.Errorf("errMsg: got %q, want %q", res.errMsg, "AuthenticationError")
}
close(ch)
}
func TestOpenclawStructuredErrorMessageField(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"error","error":{"message":"rate limit exceeded"}}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.errMsg != "rate limit exceeded" {
t.Errorf("errMsg: got %q, want %q", res.errMsg, "rate limit exceeded")
}
close(ch)
}
// ── Usage field name variant tests ──
func TestOpenclawUsageAlternativeFieldNames(t *testing.T) {
t.Parallel()
// Test PaperClip-style field names (inputTokens, outputTokens, etc.)
data := map[string]any{
"inputTokens": float64(500),
"outputTokens": float64(200),
"cachedInputTokens": float64(100),
}
usage := parseOpenclawUsage(data)
if usage.InputTokens != 500 {
t.Errorf("InputTokens: got %d, want 500", usage.InputTokens)
}
if usage.OutputTokens != 200 {
t.Errorf("OutputTokens: got %d, want 200", usage.OutputTokens)
}
if usage.CacheReadTokens != 100 {
t.Errorf("CacheReadTokens: got %d, want 100", usage.CacheReadTokens)
}
}
func TestOpenclawUsageSnakeCaseFieldNames(t *testing.T) {
t.Parallel()
// Test snake_case field names (Anthropic API style)
data := map[string]any{
"input_tokens": float64(300),
"output_tokens": float64(150),
"cache_read_input_tokens": float64(80),
"cache_creation_input_tokens": float64(40),
}
usage := parseOpenclawUsage(data)
if usage.InputTokens != 300 {
t.Errorf("InputTokens: got %d, want 300", usage.InputTokens)
}
if usage.OutputTokens != 150 {
t.Errorf("OutputTokens: got %d, want 150", usage.OutputTokens)
}
if usage.CacheReadTokens != 80 {
t.Errorf("CacheReadTokens: got %d, want 80", usage.CacheReadTokens)
}
if usage.CacheWriteTokens != 40 {
t.Errorf("CacheWriteTokens: got %d, want 40", usage.CacheWriteTokens)
}
}
func TestOpenclawUsageOriginalFieldNames(t *testing.T) {
t.Parallel()
// Test the original short field names (input, output, cacheRead, cacheWrite)
data := map[string]any{
"input": float64(100),
"output": float64(50),
"cacheRead": float64(10),
"cacheWrite": float64(5),
}
usage := parseOpenclawUsage(data)
if usage.InputTokens != 100 {
t.Errorf("InputTokens: got %d, want 100", usage.InputTokens)
}
if usage.OutputTokens != 50 {
t.Errorf("OutputTokens: got %d, want 50", usage.OutputTokens)
}
if usage.CacheReadTokens != 10 {
t.Errorf("CacheReadTokens: got %d, want 10", usage.CacheReadTokens)
}
if usage.CacheWriteTokens != 5 {
t.Errorf("CacheWriteTokens: got %d, want 5", usage.CacheWriteTokens)
}
}
func TestOpenclawUsageAccumulationAcrossSteps(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := []string{
`{"type":"step_finish","usage":{"inputTokens":100,"outputTokens":50}}`,
`{"type":"step_finish","usage":{"inputTokens":200,"outputTokens":80,"cachedInputTokens":60}}`,
}
input := strings.Join(lines, "\n")
res := b.processOutput(strings.NewReader(input), ch)
if res.usage.InputTokens != 300 {
t.Errorf("InputTokens: got %d, want 300", res.usage.InputTokens)
}
if res.usage.OutputTokens != 130 {
t.Errorf("OutputTokens: got %d, want 130", res.usage.OutputTokens)
}
if res.usage.CacheReadTokens != 60 {
t.Errorf("CacheReadTokens: got %d, want 60", res.usage.CacheReadTokens)
}
close(ch)
}
func TestOpenclawUsageFinalResultAlternativeFields(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Done"}},
Meta: openclawMeta{
DurationMs: 1000,
AgentMeta: map[string]any{
"usage": map[string]any{
"inputTokens": float64(400),
"outputTokens": float64(180),
"cachedInputTokens": float64(90),
},
},
},
}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.usage.InputTokens != 400 {
t.Errorf("InputTokens: got %d, want 400", res.usage.InputTokens)
}
if res.usage.OutputTokens != 180 {
t.Errorf("OutputTokens: got %d, want 180", res.usage.OutputTokens)
}
if res.usage.CacheReadTokens != 90 {
t.Errorf("CacheReadTokens: got %d, want 90", res.usage.CacheReadTokens)
}
close(ch)
}
func TestOpenclawProcessOutputMultilineJSON(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Pretty printed response"}},
Meta: openclawMeta{
DurationMs: 4764,
AgentMeta: map[string]any{
"sessionId": "test-session",
"usage": map[string]any{
"input": float64(100),
"output": float64(34),
},
},
},
}
// Marshal with indentation to simulate openclaw's pretty-printed output.
data, _ := json.MarshalIndent(result, "", " ")
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Pretty printed response" {
t.Errorf("output: got %q, want %q", res.output, "Pretty printed response")
}
if res.sessionID != "test-session" {
t.Errorf("sessionID: got %q, want %q", res.sessionID, "test-session")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 1 || msgs[0].Content != "Pretty printed response" {
t.Errorf("expected 1 text message with content, got %d msgs", len(msgs))
}
}
func TestOpenclawProcessOutputMultilineJSONWithLeadingLogs(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Answer after logs"}},
Meta: openclawMeta{DurationMs: 100},
}
data, _ := json.MarshalIndent(result, "", " ")
input := "some startup log\nanother log line\n" + string(data)
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Answer after logs" {
t.Errorf("output: got %q, want %q", res.output, "Answer after logs")
}
close(ch)
}
// ── openclawInt64 tests ──
func TestOpenclawInt64Float(t *testing.T) {

View File

@@ -48,6 +48,7 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
args = append(args, prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
@@ -74,6 +75,12 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stdout when the context is cancelled so the scanner unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)

View File

@@ -14,7 +14,7 @@ import (
const archiveAgent = `-- name: ArchiveAgent :one
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env
`
type ArchiveAgentParams struct {
@@ -43,6 +43,7 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
@@ -213,9 +214,9 @@ const createAgent = `-- name: CreateAgent :one
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
instructions, custom_env
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env
`
type CreateAgentParams struct {
@@ -230,6 +231,7 @@ type CreateAgentParams struct {
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID pgtype.UUID `json:"owner_id"`
Instructions string `json:"instructions"`
CustomEnv []byte `json:"custom_env"`
}
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
@@ -245,6 +247,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
arg.MaxConcurrentTasks,
arg.OwnerID,
arg.Instructions,
arg.CustomEnv,
)
var i Agent
err := row.Scan(
@@ -265,6 +268,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
@@ -394,7 +398,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
}
const getAgent = `-- name: GetAgent :one
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env FROM agent
WHERE id = $1
`
@@ -419,12 +423,13 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env FROM agent
WHERE id = $1 AND workspace_id = $2
`
@@ -454,6 +459,7 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
@@ -651,7 +657,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
}
const listAgents = `-- name: ListAgents :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env FROM agent
WHERE workspace_id = $1 AND archived_at IS NULL
ORDER BY created_at ASC
`
@@ -683,6 +689,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
); err != nil {
return nil, err
}
@@ -695,7 +702,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
}
const listAllAgents = `-- name: ListAllAgents :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by FROM agent
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env FROM agent
WHERE workspace_id = $1
ORDER BY created_at ASC
`
@@ -727,6 +734,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
); err != nil {
return nil, err
}
@@ -829,7 +837,7 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
const restoreAgent = `-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env
`
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
@@ -853,6 +861,7 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
@@ -901,9 +910,10 @@ UPDATE agent SET
status = COALESCE($9, status),
max_concurrent_tasks = COALESCE($10, max_concurrent_tasks),
instructions = COALESCE($11, instructions),
custom_env = COALESCE($12, custom_env),
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env
`
type UpdateAgentParams struct {
@@ -918,6 +928,7 @@ type UpdateAgentParams struct {
Status pgtype.Text `json:"status"`
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
Instructions pgtype.Text `json:"instructions"`
CustomEnv []byte `json:"custom_env"`
}
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
@@ -933,6 +944,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
arg.Status,
arg.MaxConcurrentTasks,
arg.Instructions,
arg.CustomEnv,
)
var i Agent
err := row.Scan(
@@ -953,6 +965,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}
@@ -960,7 +973,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
const updateAgentStatus = `-- name: UpdateAgentStatus :one
UPDATE agent SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env
`
type UpdateAgentStatusParams struct {
@@ -989,6 +1002,7 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
&i.Instructions,
&i.ArchivedAt,
&i.ArchivedBy,
&i.CustomEnv,
)
return i, err
}

View File

@@ -37,6 +37,7 @@ type Agent struct {
Instructions string `json:"instructions"`
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
ArchivedBy pgtype.UUID `json:"archived_by"`
CustomEnv []byte `json:"custom_env"`
}
type AgentRuntime struct {

View File

@@ -40,6 +40,42 @@ func (q *Queries) DeleteArchivedAgentsByRuntime(ctx context.Context, runtimeID p
return err
}
const deleteStaleOfflineRuntimes = `-- name: DeleteStaleOfflineRuntimes :many
DELETE FROM agent_runtime
WHERE status = 'offline'
AND last_seen_at < now() - make_interval(secs => $1::double precision)
AND id NOT IN (SELECT DISTINCT runtime_id FROM agent)
RETURNING id, workspace_id
`
type DeleteStaleOfflineRuntimesRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Deletes runtimes that have been offline for longer than the TTL and have
// no agents bound (active or archived). The FK constraint on agent.runtime_id
// is ON DELETE RESTRICT, so we must exclude all agent references.
func (q *Queries) DeleteStaleOfflineRuntimes(ctx context.Context, staleSeconds float64) ([]DeleteStaleOfflineRuntimesRow, error) {
rows, err := q.db.Query(ctx, deleteStaleOfflineRuntimes, staleSeconds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DeleteStaleOfflineRuntimesRow{}
for rows.Next() {
var i DeleteStaleOfflineRuntimesRow
if err := rows.Scan(&i.ID, &i.WorkspaceID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const failTasksForOfflineRuntimes = `-- name: FailTasksForOfflineRuntimes :many
UPDATE agent_task_queue
SET status = 'failed', completed_at = now(), error = 'runtime went offline'
@@ -253,6 +289,48 @@ func (q *Queries) MarkStaleRuntimesOffline(ctx context.Context, staleSeconds flo
return items, nil
}
const migrateAgentsToRuntime = `-- name: MigrateAgentsToRuntime :execrows
UPDATE agent
SET runtime_id = $1
WHERE runtime_id IN (
SELECT ar.id FROM agent_runtime ar
WHERE ar.workspace_id = $2
AND ar.provider = $3
AND ar.owner_id = $4
AND ar.id != $1
AND ar.status = 'offline'
AND ar.daemon_id LIKE $5 || '-%'
)
`
type MigrateAgentsToRuntimeParams struct {
NewRuntimeID pgtype.UUID `json:"new_runtime_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Provider string `json:"provider"`
OwnerID pgtype.UUID `json:"owner_id"`
DaemonIDPrefix pgtype.Text `json:"daemon_id_prefix"`
}
// Migrates agents from stale offline runtimes to the newly registered runtime.
// Only migrates from runtimes that match the same workspace, provider, owner,
// AND whose daemon_id starts with the current daemon_id followed by '-'.
// This scopes migration to old profile-suffixed runtimes from the same machine
// (e.g. "MacBook-staging" matches daemon_id_prefix "MacBook") without touching
// runtimes from other machines belonging to the same user.
func (q *Queries) MigrateAgentsToRuntime(ctx context.Context, arg MigrateAgentsToRuntimeParams) (int64, error) {
result, err := q.db.Exec(ctx, migrateAgentsToRuntime,
arg.NewRuntimeID,
arg.WorkspaceID,
arg.Provider,
arg.OwnerID,
arg.DaemonIDPrefix,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setAgentRuntimeOffline = `-- name: SetAgentRuntimeOffline :exec
UPDATE agent_runtime
SET status = 'offline', updated_at = now()

View File

@@ -101,11 +101,11 @@ ORDER BY date DESC
type ListRuntimeUsageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Since pgtype.Date `json:"since"`
Date pgtype.Date `json:"date"`
}
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Date)
if err != nil {
return nil, err
}

View File

@@ -20,8 +20,8 @@ WHERE id = $1 AND workspace_id = $2;
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
instructions, custom_env
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *;
-- name: UpdateAgent :one
@@ -36,6 +36,7 @@ UPDATE agent SET
status = COALESCE(sqlc.narg('status'), status),
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
instructions = COALESCE(sqlc.narg('instructions'), instructions),
custom_env = COALESCE(sqlc.narg('custom_env'), custom_env),
updated_at = now()
WHERE id = $1
RETURNING *;

View File

@@ -78,3 +78,32 @@ SELECT count(*) FROM agent WHERE runtime_id = $1 AND archived_at IS NULL;
-- name: DeleteArchivedAgentsByRuntime :exec
DELETE FROM agent WHERE runtime_id = $1 AND archived_at IS NOT NULL;
-- name: MigrateAgentsToRuntime :execrows
-- Migrates agents from stale offline runtimes to the newly registered runtime.
-- Only migrates from runtimes that match the same workspace, provider, owner,
-- AND whose daemon_id starts with the current daemon_id followed by '-'.
-- This scopes migration to old profile-suffixed runtimes from the same machine
-- (e.g. "MacBook-staging" matches daemon_id_prefix "MacBook") without touching
-- runtimes from other machines belonging to the same user.
UPDATE agent
SET runtime_id = @new_runtime_id
WHERE runtime_id IN (
SELECT ar.id FROM agent_runtime ar
WHERE ar.workspace_id = @workspace_id
AND ar.provider = @provider
AND ar.owner_id = @owner_id
AND ar.id != @new_runtime_id
AND ar.status = 'offline'
AND ar.daemon_id LIKE @daemon_id_prefix || '-%'
);
-- name: DeleteStaleOfflineRuntimes :many
-- Deletes runtimes that have been offline for longer than the TTL and have
-- no agents bound (active or archived). The FK constraint on agent.runtime_id
-- is ON DELETE RESTRICT, so we must exclude all agent references.
DELETE FROM agent_runtime
WHERE status = 'offline'
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision)
AND id NOT IN (SELECT DISTINCT runtime_id FROM agent)
RETURNING id, workspace_id;