mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 22:29:04 +02:00
v0.2.10
521 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
637bdc8eb3 |
feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping Introduces server/internal/analytics, the shipping layer for the product funnel defined in docs/analytics.md. Capture is non-blocking — events are enqueued into a bounded channel and a background worker batches them to PostHog's /batch/ endpoint. A broken backend drops events rather than blocking request handlers. Local dev and self-hosted instances run a noop client until the operator sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created emission land in the follow-up commit so this change is independently reviewable. * feat(server): emit signup and workspace_created analytics events Wires analytics.Client through handler.New and main, then emits the first two funnel events: - signup fires from findOrCreateUser (which now reports isNew), covering both the verification-code and Google OAuth entry points — a single emission site guarantees Google signups aren't missed. - workspace_created fires after the CreateWorkspace transaction commits, with is_first_workspace computed from a post-commit ListWorkspaces count so we can distinguish fresh-user activation from returning-user expansion. Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of MUL-1122; runtime_registered and issue_executed follow in later PRs per the plan. * refactor(analytics): drop is_first_workspace from workspace_created Stamping "is this the user's first workspace?" at emit time races under concurrent CreateWorkspace requests: two transactions committing close together can both read a post-commit count greater than one and both emit false. Fixing it at the SQL layer requires a schema change we don't want in PR 1. PostHog answers the same question exactly from the event stream (funnel on "first time user does X" / cohort on $initial_event), so removing the property loses no information and makes the emit side race-free. * docs(analytics): document self-host safety defaults Spell out why self-hosted instances never ship events upstream by default (empty POSTHOG_API_KEY → noop client) and explain how operators can point at their own PostHog project without any code change. * feat(analytics): emit runtime_registered, issue_executed, team_invite_* Three server-side funnel events, all gated on first-time state transitions so retries and re-runs don't inflate the WAW buckets: - runtime_registered fires from DaemonRegister when UpsertAgentRuntime reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats and re-registrations stay silent. - issue_executed fires from CompleteTask after an atomic UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL flips the column for the first time. Retries, re-assignments, and comment-triggered follow-up tasks hit the WHERE clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10 buckets filter without extra queries. - team_invite_sent fires from CreateInvitation and team_invite_accepted from AcceptInvitation, closing the expansion funnel. Adds a 050 migration for issue.first_executed_at plus a partial index so the workspace-scoped executed-count query doesn't scan the never-executed tail. * feat(config): surface PostHog key via /api/config Extends AppConfig with posthog_key / posthog_host sourced from env on every request (so operators can rotate the key via secret refresh without a restart). Reading the key off the server — rather than baking it into the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances inherit the blank key automatically and never ship events upstream. * feat(analytics): wire posthog-js identify + UTM capture on the client Adds @multica/core/analytics — a thin wrapper around posthog-js that owns attribution capture and identity merge. Posthog-js config comes from /api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server returns an empty key automatically run the SDK inert. captureSignupSource stamps a multica_signup_source cookie with UTM params and the referrer's origin (never the full referrer — that can leak OAuth code/state in the callback URL). The backend signup event reads this cookie on new-user creation. Identity flows: - auth-initializer fires identify() right after getMe() resolves, on both cookie and token paths. A getConfig/getMe race is handled by buffering a pending identify inside the analytics module and flushing it once initAnalytics finishes. - auth store calls identify() on verifyCode / loginWithGoogle / loginWithToken and resetAnalytics() on logout so the next login merges cleanly without bleeding events. * docs(analytics): describe runtime_registered, issue_executed, invite events Fills in the schema for the remaining funnel events. Captures the design commentary that belongs next to the contract rather than in a PR description — in particular why issue_executed uses the atomic first_executed_at flip instead of counting task-terminal events, and why runtime_registered relies on xmax = 0 rather than a query-then-write. * fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed Computing the workspace's Nth-issue ordinal at emit time is not atomic under concurrent first-completions — two transactions can both run MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and both observe count=1 before either has committed, so both events go out stamped as n=1. Serialising it would mean a per-workspace advisory lock or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly at query time via row_number() partitioned by workspace_id, so the emit-time property adds risk without adding information. Removes the property from analytics.IssueExecuted, deletes the unused CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial index stays — any future workspace-scoped executed-issue query will want it. * fix(analytics): wire $pageview and harden signup_source cookie payload Two frontend fixes from the PR review: - PageviewTracker, mounted under WebProviders, fires capturePageview on every Next.js App Router path / query-string change. Without this the capturePageview helper in @multica/core/analytics was never called and the acquisition funnel's / → signup step was empty. - captureSignupSource now caps each UTM / referrer value at 96 chars *before* JSON.stringify, and drops the whole cookie when the serialised payload still exceeds 512 chars. Previously the overall slice(0, 256) could leave a half-JSON string on the wire that neither the backend nor PostHog could parse. Both capturePageview and identify now buffer a single pending call when fired before initAnalytics resolves — otherwise the initial "/" pageview and same-turn login identify race the /api/config fetch and get dropped. resetAnalytics clears both buffers so a logout→login cycle stays clean. * fix(analytics): URL-decode signup_source cookie on read Go does not URL-decode Cookie.Value automatically, so the frontend's JSON-then-encodeURIComponent payload was landing in PostHog as percent-encoded garbage (%7B%22utm_source...). Unescape on read so the backend receives the original JSON string the frontend intended, and drop values that fail to decode or exceed the server-side cap — sending truncated garbage is worse than sending nothing. Oversized-cookie guard matches the frontend's SIGNUP_SOURCE_MAX_LEN. * docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding Pulls the schema doc back in line with the code: issue_executed no longer advertises nth_issue_for_workspace (with a note about why PostHog derives it at query time instead), the frontend $pageview section names the actual PageviewTracker component that fires it, and the signup_source section documents the per-value cap / overall drop rule and the encode-on-write / decode-on-read contract. --------- Co-authored-by: Jiang Bohan <bhjiang@outlook.com> |
||
|
|
6f63fae41a |
feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging * fix(desktop): use releaseType instead of publishingType in electron-builder publish config publishingType is not a valid electron-builder key; the correct GitHub provider option is releaseType. The previous value was silently ignored, causing uploads to be skipped and breaking auto-update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): standardize artifact naming across desktop and CLI Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a filename alone reveals kind, version, platform, and CPU arch. Desktop (apps/desktop/electron-builder.yml): mac → multica-desktop-<v>-mac-<arch>.{dmg,zip} linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage} (fixes `\${name}` expanding the scoped `@multica/desktop` into a broken `@multica/desktop-*` filename path) windows → multica-desktop-<v>-windows-<arch>.exe CLI (.goreleaser.yml): multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz (adds `-cli` marker + version; switches `_` to `-` for consistency) Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`: - drop mac x64 (Intel not a target yet) - add linux arm64 Final: mac arm64, win x64/arm64, linux x64/arm64. Downstream updates so install paths match the new CLI names: - scripts/install.sh - scripts/install.ps1 (URL + checksum regex) - CLI_INSTALL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): use multica_{os}_{arch} CLI archive naming Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}' asset names. Install scripts and the desktop CLI bootstrap now resolve assets via checksums.txt so they work without hardcoding versions. The Go self-update path queries the GitHub release API and accepts either the new or legacy 'multica-cli-<version>-...' names so existing releases keep updating cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): ship both legacy and versioned CLI archive names GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and 'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every release. The legacy name keeps already-released CLIs self-updating; the versioned name is what new clients should use going forward. Self-update / install paths flipped to prefer the versioned name and fall back to legacy: - server/internal/cli/update.go (multica update) - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap) - scripts/install.sh, scripts/install.ps1 (fresh install) Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(desktop): also build Linux .rpm packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1 Address review feedback on PR #1262: - .github/workflows/release.yml: add a 'desktop' job that runs after the CLI 'release' job and packages the Desktop installers for Linux (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes them to the same GitHub Release via electron-builder. macOS Desktop continues to ship through the manual release-desktop skill so it can be signed and notarized with Apple Developer credentials. - scripts/install.ps1: detect Windows ARM64 hosts via RuntimeInformation::OSArchitecture so the new windows-arm64 CLI archive is downloaded on ARM64 machines instead of always falling back to amd64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision electron-builder's update metadata file is hardcoded to `latest.yml` for Windows regardless of arch (only Linux gets an arch-suffixed name; see app-builder-lib's getArchPrefixForUpdateFile). With two separate electron-builder invocations for Windows x64 and arm64, both publish `latest.yml` to the same GitHub Release and the second upload silently overwrites the first — leaving one of the two architectures with auto- update metadata pointing at the other arch's installer. Route Windows arm64 to its own `latest-arm64` channel: * scripts/package.mjs appends `-c.publish.channel=latest-arm64` only for the Windows arm64 invocation, so x64 keeps producing `latest.yml` and arm64 produces `latest-arm64.yml` alongside it. * updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows arm64 clients so they fetch the matching metadata file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
c5a00d8b8c |
fix(agent/openclaw): extract real model from meta.agentMeta.model (#1426)
OpenClaw's `--json` result blob carries the actual LLM identifier in `meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`), alongside `provider` and the usage breakdown. The backend was reading the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping the `model` field entirely, then attributing every run's tokens to `opts.Model` — which for openclaw is the *agent name* passed via `--agent`, not a real model identifier — falling all the way through to "unknown" when no agent.model was configured. Surface the runtime-reported model: - `openclawEventResult` gains a `model` string. - `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty string when absent for forward-compat with older runtimes / partial outputs). - `processOutput` propagates it through the result-blob branch. - `Execute`'s usage map prefers `scanResult.model`, falling back to `opts.Model` then `"unknown"` — preserving the prior behavior path for any runtime that doesn't surface its own model yet. Two unit tests cover both the populated and missing cases. Refs: #1395 |
||
|
|
4ac43e9e49 |
feat(daemon): log agent invocation at info level (#1428)
Surface the actual exec path + argv for every agent backend at INFO so operators can see the exact command without flipping to debug. Also add the missing log line in pi.go for consistency with the other nine backends. |
||
|
|
03e21aee80 |
Fix skills.sh nested directory imports (#1423)
Co-authored-by: Eve <eve@multica.ai> |
||
|
|
632fdde700 |
fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the parent console, so closing the launching cmd/PowerShell window fires CTRL_CLOSE_EVENT down the inherited console and takes the daemon with it. Add DETACHED_PROCESS so the child has no console at all; stdout/stderr are already redirected to the log file before spawn. * fix(cli): make `multica update` work while the binary is running on Windows On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the previous os.Rename(tmp, exe) always failed with "Access is denied" — every `multica update` on Windows hit this, because the CLI is updating its own running binary. Windows does allow renaming the running .exe (just not overwriting it), so the new Windows-only replaceBinary moves the running binary to `.old` first, installs the new one, and restores the original if installation fails. A best-effort CleanupStaleUpdateArtifacts runs at CLI/daemon startup to reclaim the leftover `.old` file once the old process has exited. Unix keeps the plain rename-over semantics (the old inode stays valid for the running process). * fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events With DETACHED_PROCESS the Windows daemon shares no console with the stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)` silently never reaches it — the old code would report "stop sent" while the daemon kept running. Replace the platform-specific stopDaemonProcess with a cross-platform POST to the daemon's HTTP /shutdown endpoint, which cancels the same top-level context the self-restart path already uses. Fall back to `process.Kill()` if the HTTP call fails. Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT wiring, adds handler tests, and updates the DETACHED_PROCESS comment. |
||
|
|
cc1ccedaf3 |
test(storage): lock S3 upload URL behavior across all env combos (#1421)
Extract the URL assembly at the end of S3Storage.Upload into a helper (uploadedURL) so the four env-var combinations can be covered by a table-driven test without mocking s3.PutObject. This locks in the fix from #1300 — cdn > endpoint > bucket — so future refactors can't silently regress the CDN-wins-over-custom-endpoint case. No behavior change. |
||
|
|
8eb81aa396 |
fix(daemon): enforce workspace isolation for agent execution (#1235) (#1260)
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027 / #1235: an agent running for workspace A ended up commenting on (and renaming) a two-day-old issue in workspace B. #1249/#1259 fixed resolution for autopilot tasks and consolidated the task-workspace resolver, and #1294 populated workspace_id in the claim response for run_only autopilot tasks. Those closed the known fallthroughs but the failure mode is still broader: whenever the daemon or server fails to supply a workspace, the CLI silently falls back to `~/.multica/config.json`, which is user-global, not workspace-scoped. On a host running daemons for multiple workspaces, a single gap in workspace propagation is enough to leak writes across workspaces. This PR adds three coordinated guards so no single layer's bug can cause a cross-workspace write: 1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env, both daemon-only markers) and in that context refuses to fall back to the user-global CLI config. Human / script usage (no agent env) is unchanged: flag → env → config fallback chain still applies. 2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now captures the runtime's workspace from `requireDaemonRuntimeAccess` and enforces `resolved_task_workspace == runtime_workspace` after the existing issue/chat/autopilot branches. On mismatch or empty, the handler explicitly cancels the just-dispatched task (via `TaskService.CancelTask`, which also reconciles agent status) and returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had already transitioned the task to 'dispatched' and the agent status to 'working', so a plain 500 would leave both stuck for the ~5 min stale-task sweep window. 3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the agent when `task.WorkspaceID` is empty (defense-in-depth against server bugs and reused workdirs). Tests: - `cmd/multica/cmd_agent_test.go`: `TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests covering the full fallback matrix (outside agent context still reads config; agent context uses env; agent context with empty env returns empty; task-id-only marker also counts; requireWorkspaceID surfaces the agent-context error message). - `internal/handler/daemon_test.go`: `TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` — constructs a data-inconsistent task (runtime_id in workspace A, issue_id in workspace B) and asserts the handler returns 500 AND leaves the task in 'cancelled' state (not 'dispatched'). Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup workspace filter, cross-workspace audit of agent-facing endpoints, observability) are out of scope for this PR and tracked separately. |
||
|
|
965bf731ab |
Prefer CDN domain over raw endpoint URL in attachment links (#1300)
When both AWS_ENDPOINT_URL and CLOUDFRONT_DOMAIN are configured, the uploaded file URL returned by S3Storage.Upload now uses the CDN domain instead of the raw S3-compatible endpoint. This enables S3-compatible backends (MinIO, R2, B2, Wasabi, etc.) to be paired with a separate public-read domain — previously the CDN domain was silently ignored whenever a custom endpoint was set, forcing clients to hit the raw S3 API endpoint which typically requires signed requests. No behavior change for deployments that set only one of the two vars: pure AWS S3 with CloudFront, AWS S3 without a CDN, and MinIO/R2 without a CDN all continue to return the same URLs as before. |
||
|
|
0db7d2fb64 |
fix(issues): include description in list queries for board card display (#1375) (#1377)
The ListIssues and ListOpenIssues SQL queries omitted the description column, so the API response never included description data. Board cards checked issue.description (always null) and never rendered it, even when the Description card property was enabled. Add description to both SQL queries, the generated Go structs/scan calls, and the response mapping functions. |
||
|
|
bb31afbbce |
Revert "fix(server/comment): remove HTML sanitizer that was corrupting Markdo…" (#1413)
This reverts commit
|
||
|
|
4a25b91590 |
fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387)
The bluemonday HTML sanitizer applied to comment content (added in #679) treats Markdown source as HTML, entity-encoding syntactically meaningful characters and normalizing whitespace. This corrupts user input: - "> quote" -> "> quote" (blockquote lost, see #1303) - '"foo"' -> '"foo"' (literal entities visible) - "\n\n2." -> " 2." (ordered list items merged into prose) Comment content is stored as Markdown source. XSS is already handled at two layers: - Render: rehype-sanitize in packages/ui/markdown and packages/views/editor/readonly-content (mention:// allowlist, data-href restricted to http(s), class restricted to code/div/span/pre). - Edit: @tiptap/markdown is configured with html:false, so Markdown source containing raw HTML tags is treated as plain text. Removing the server-side sanitizer therefore does not lower the security boundary, and restores faithful Markdown round-tripping. The PR #1342 workaround in the editor serializer can be dropped once this lands. Co-authored-by: Eve <eve@multica.ai> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
9e47b83f02 |
feat(agent): add Kimi CLI as agent runtime (#1400)
* feat(agent): add Kimi CLI as agent runtime
Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.
Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.
Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
file (Kimi reads AGENTS.md natively via /init), and writes skills under
`.kimi/skills/` so they are auto-discovered by the project-level skill
loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(agent/kimi): support per-agent model selection via ACP set_model
Wire Kimi into the model dropdown introduced in #1399:
- ListModels gets a 'kimi' case that drives the same ACP
initialize + session/new handshake as Hermes; both share a new
discoverACPModels helper and parseACPSessionNewModels parser
so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
opts.Model is non-empty, mirroring the Hermes flow. Failures
fail the task instead of silently falling back to Kimi's
default model — silent fallback would hide that the dropdown
pick wasn't honoured.
Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(agent): address code review feedback on Kimi runtime
- Share ACP provider-error sniffer between hermes and kimi. Previously
only hermes promoted stderr-observed 4xx/5xx into a failed task;
kimi would report "completed + empty output" when the Moonshot
upstream rejected a request (expired token, rate limit, …). Rename
hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
Message struct has only one relevant field (Tool), so passing it
twice was a dead fallback. Document that the function normalises
residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
that returns a JSON-RPC error for session/set_model, asserts that
the task result surfaces status=failed with the model name + upstream
message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
(missing cursor, gemini, copilot).
All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.
* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval
Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.
The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.
While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.
Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
handler test packages.
* fix(agent/acp): auto-approve session/request_permission from agent
The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.
The real fix is on our side: respond to session/request_permission.
ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.
Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.
Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
(reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
kimiBlockedArgs gives the wrong impression that it does something.
Comment in kimi.go now points at handleAgentRequest as the real fix.
Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
session/request_permission, assert the reply echoes the id and
carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
with a negative assertion: no dead --yolo / --auto-approve / -y on
argv, since they'd pretend to do something they can't.
All: go build ./..., go vet ./..., go test ./pkg/agent/... green.
* fix(agent/acp): surface kimi tool input/output via content blocks
Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.
Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).
Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
(single/multiple text blocks, terminal-block skip, empty input).
* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI
kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.
Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix
|
||
|
|
b291db11c2 |
feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
|
||
|
|
824d943848 |
fix(auth): derive cookie Secure flag from FRONTEND_ORIGIN scheme (#1390)
The session cookie's Secure flag was tied to APP_ENV, and the docker-compose self-host stack defaults APP_ENV to "production". On plain-HTTP self-host deployments (LAN IP, private network) the browser silently drops Secure cookies, leaving every subsequent /api/* call anonymous and surfacing as 401 "auth: no token found" right after a successful login. Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get Secure cookies and plain-HTTP origins get non-secure cookies the browser will actually store. Also harden cookieDomain() against the other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and browsers reject. Log a one-shot warning and fall back to host-only. Docs: correct the COOKIE_DOMAIN description (it was labelled as CloudFront-only but applies to session cookies too) and call out the IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and .env.example. Refs #1321 |
||
|
|
c76c790b32 |
fix(daemon/execenv): make posting result comment an explicit workflow step (#1372)
Agents were silently finishing tasks without ever posting results to the issue — their final reply stayed in terminal/log output only. See MUL-1124. Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with results" inside the body of step 4 (a nested clause in the default workflow description), so skill-driven flows jumped straight from "do the work" to `status in_review`. - Hoist posting the result comment into its own explicit, numbered step in both assignment-triggered and comment-triggered workflows, with the exact `multica issue comment add` invocation inlined. - Add a hard warning at the top of the Output section that terminal / chat text is never delivered to the user. - Add regression test covering both workflow branches. |
||
|
|
07034f4455 |
feat(server): configurable pgxpool size with sane defaults (#1381)
* feat(server): configurable pgxpool size with sane defaults pgxpool.New(ctx, url) silently sets MaxConns = max(4, NumCPU). On the prod pods that resolved to 4, which got fully saturated by daemon claim/heartbeat traffic (~3800 acquires/s) and showed up as ~900ms acquire waits on every query — the actual root cause of the 3s+ /tasks/claim tail latency. The db pool stats logging from #1378 confirmed this with empty_acquire_delta == acquire_count_delta. Switch to pgxpool.ParseConfig + NewWithConfig and apply per-pod defaults of MaxConns=25 / MinConns=5, both overridable via env vars (DATABASE_MAX_CONNS / DATABASE_MIN_CONNS) so the size can be tuned in prod without a redeploy. The defaults follow the standard 'small pool, lots of waiters' guidance for Postgres (PG community / HikariCP formula `(core_count * 2) + effective_spindle_count`); 25 leaves headroom for bursts and occasional long queries while staying safely under typical managed-Postgres max_connections ceilings when multiplied across pods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(server): respect DATABASE_URL pool_* params; add precedence tests Address review feedback on #1381: - Configuration precedence is now explicit: DATABASE_MAX_CONNS env > pool_max_conns query param on DATABASE_URL > built-in default. Same for min_conns. Previously the env-empty path unconditionally overwrote whatever ParseConfig had read from the URL — a silent regression for deployments that already tuned pool size via the connection string. - Add unit tests in dbstats_test.go covering each precedence branch (defaults, URL-only, env-over-URL, partial URL, invalid env, min>max clamp). - Move pool tuning vars out of 'Required Variables' into a new 'Database Pool Tuning (Optional)' section in SELF_HOSTING_ADVANCED.md so self-hosters don't think they need to set them. - Add commented entries in .env.example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(server): invalid pool env falls back to URL/code default, never pgx 4 Address second round of review on #1381: Previous code passed cfg.MaxConns / cfg.MinConns as the envInt32 fallback, which meant an invalid DATABASE_MAX_CONNS value silently fell back to ParseConfig's value — i.e. pgx's built-in default of 4/0 when the URL had no pool_* params. That's exactly the bad value this PR exists to remove, and the previous test (TestPoolSizing_InvalidEnvFallsBack) accidentally locked it in. Compute the non-env fallback first (URL pool_* if present, else code default 25/5) and pass that to envInt32. Misconfigured env now lands on the same value as if the env were unset — never on the pgx default. Replace the loose 'max > 0' assertion with two precise tests: - invalid env + no URL param → code default (25/5) - invalid env + URL param → URL value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
9fa08fb16a |
chore(server): log pgxpool config + periodic stats to confirm pool exhaustion (#1378)
After merging the per-phase claim slow-logs (#1376), the prod data showed the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces, ping) all completed at the *same wall-clock instant* with durations clustered at ~1.4s and ~2.88s — and within the claim breakdown, list_pending_ms was 713ms even when list_pending_count=0. A 0-row indexed scan can't take 713ms, and unrelated endpoints don't synchronize their completion by accident. The only explanation that fits is requests blocking on a shared resource and being released together. The most likely culprit is pgxpool connection-acquire wait: pgxpool.New is called with no config, so MaxConns defaults to max(4, NumCPU) — under the daemon poll fan-in this is trivially exhausted. This change adds the observability needed to confirm/refute that without changing pool sizing yet (pool sizing is a follow-up once we have data): - logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime / MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the effective limit is critical because the default is surprisingly small and easy to mistake for 'database is slow'. - runDBStatsLogger: samples pool.Stat() every 15s (matches daemon heartbeat cadence for easy correlation). Emits INFO with TotalConns / AcquiredConns / IdleConns and per-window deltas (acquire_count, empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0 — those are the direct symptom of a request having to wait because no idle connection was available. If on prod we see 'db pool pressure' WARN lines coincident with the claim_endpoint slow lines, the hypothesis is confirmed and the fix becomes a one-liner (pool config tuning + the existing N+1 reduction ideas to lower demand). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
cf74327aa6 |
chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim
We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.
Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:
- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
build_ms so we can tell whether the slowness is in the actual claim
query or the post-claim response assembly (GetAgent, LoadAgentSkills,
GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
branch's 4 queries).
- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
list_pending_count, agents_tried, claim_loop_ms — directly validates
the suspected N+1 amplification (one ListPendingTasksByRuntime + one
ClaimTask per unique agent).
- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
LOCKED cost from the surrounding metadata reads.
Pure observability change. No behavior change in the request path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): widen claim slow-log to cover post-claim DB work and error paths
Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.
Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
and dispatch_ms phase fields. Error paths now also emit a slow log
with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
error paths now also carry phase timings (outcome=unauth / error_claim).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
951f51408a |
fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard Resumed Claude sessions keep prior turns' tool calls in context, so a comment-triggered task could reuse the PREVIOUS turn's --parent UUID instead of the current trigger's. The reply landed in the wrong thread (MUL-1125): backend stored exactly what the agent sent, but the agent pulled a stale UUID from its own conversation memory. Two layers of defense: 1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt and execenv.InjectRuntimeConfig emit the same "use this exact --parent, do not reuse values from previous turns" block. The per-turn prompt now carries the current TriggerCommentID, which it previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read mid-session). 2. Handler-side guard in CreateComment: when an agent posts from inside a comment-triggered task (X-Agent-ID + X-Task-ID, task has TriggerCommentID), require parent_id == task.TriggerCommentID or return 409. Assignment-triggered tasks are untouched. * fix(agent/comments): scope parent_id guard to the task's own issue Two issues from CI + GPT-Boy's review: 1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an agent legitimately commenting on a different issue while its current task was comment-triggered would get 409'd with the wrong issue's trigger comment id. Narrow the guard to fire only when the request's issue matches the task's own issue — cross-issue agent activity stays unblocked. 2. The integration test tried to insert a second queued task for the same (agent, issue), which hits the idx_one_pending_task_per_issue_agent unique index. Replace the assignment-triggered-task sub-case with a cross-issue regression test (the scenario we now need to cover anyway): post on issue B while X-Task-ID points at a comment-triggered task on issue A, expect 201. |
||
|
|
ec73710dd2 |
fix(agent/codex): surface stderr tail in initialize / turn startup errors (#1314)
* fix(agent/codex): surface stderr tail in initialize / turn startup errors When codex app-server exits before the JSON-RPC handshake completes — e.g. because the user put a flag in custom_args that the subcommand rejects — the Result.Error users see is `codex initialize failed: codex process exited`, while codex's actual complaint (typically something like `error: unexpected argument '-m' found`) only lives in daemon logs. Wrap the stderr writer with a bounded stderrTail that still forwards to the slog logWriter but also retains the last 2 KiB of bytes written. Include that tail on the three startup failure paths (initialize, startOrResumeThread, turn/start). Runtime cancellation paths are left untouched — they're our own abort and the stderr context isn't a clear signal there. Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail loudly" actually be workable by giving the failure a real message. * fix(agent/codex): join cmd.Wait() before sampling stderr tail Addressing review of #1314: reading stderrBuf.Tail() right after c.request returns "codex process exited" was racy. Nothing in that path synchronizes with os/exec's internal stderr copy goroutine — cmd.Wait() is the only documented join point. The original defer ran cmd.Wait() later, but by then we had already built Result.Error from a potentially-empty Tail(). Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a sync.Once-wrapped drainAndWait closure. Call it explicitly on the three startup failure paths before sampling the tail; keep it as the cleanup defer so the success path behaves identically. Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a real subprocess that prints to stderr and exits before responding to initialize, runs it through Execute, and asserts Result.Error contains the stderr hint. This covers the full timing path the reviewer flagged, which the helper-level tests in this PR did not. |
||
|
|
c0be1b7ce9 |
fix(slugs): audit admin/multica/new/www + reserve in slug list (MUL-972) (#1359)
Follow-up to PR #1188 / migration 047, which intentionally omitted the five historical conflict slugs (admin / multica / new / setup / www) from the reserved-slug audit because each had one production workspace using it at the time and we did not want to block deploy on owner outreach. MUL-972 closed that loop on prd for four of the five: * admin (99cd10e4-…) → renamed to legacy-admin-99cd10e4 * multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa * new (e391e3ed-…) → renamed to legacy-new-e391e3ed * www (5e8d38b2-…) → workspace deleted (was empty: 0 issues / projects / agents, owner-only member; 18 workspace-FK relations all CASCADE) This PR: 1. Adds migration 049_audit_legacy_reserved_slugs which audits those four slugs against workspace.slug at startup. If any future workspace slips in with one of them, startup fails loudly via RAISE EXCEPTION instead of being silently shadowed by a global route. Mirrors the structure of 047. 2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list in both the Go handler and the shared TS list (admin was already in both). Keeps the two lists in lockstep per the convention enforced in workspace_reserved_slugs.go header. setup is STILL exempt from the audit and is intentionally NOT added to the reserved list. The setup workspace (b43f0bc2-…) is a real production user (owner: Roberto Betancourth, building a chants/Alabanzas app) and is being handled out-of-band via owner outreach. A separate follow-up migration will fold setup into the audit once that workspace's slug has been migrated. Migration is intentionally shipped AFTER the prd data fix (not before): 049 will RAISE EXCEPTION on any remaining conflict, so we want the data state clean first. Rollout order: prd data fix (done by db-boy on 2026-04-20) → this PR. Tested: - go test ./server/internal/handler/ -run TestReserved → pass - pnpm --filter @multica/core test consistency → pass (4/4 in consistency.test.ts; global-prefix↔reserved invariant holds) Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
bd445782d5 |
fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)
Fixes #1332. Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1 improvements") that together block all openclaw users: 1. `openclaw agent` does not accept `--model` or `--system-prompt`, so any agent configured with a Model field crashed in ~700ms with `exit status 1`. Remove both forwards, and add them to openclawBlockedArgs so custom_args can't reintroduce the crash. Model is bound at registration time via `openclaw agents add/update --model`. 2. AgentInstructions were written to `{workDir}/AGENTS.md` by execenv.InjectRuntimeConfig, but openclaw loads bootstrap files from its own workspace dir — the file was never read, so every agent's Instructions field was silently discarded. Populate opts.SystemPrompt for the openclaw provider in runTask and prepend it to the `--message` payload in the backend so the model actually receives the instructions. Other providers surface instructions through their native runtime config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally left unchanged to avoid double injection. Extract buildOpenclawArgs so arg construction is directly testable; add unit tests covering the removed flags, the SystemPrompt prepend, and custom_args filtering. |
||
|
|
5fa1da448f |
fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures The chat 'forgets earlier messages' bug came from PriorSessionID being silently lost in several edge cases: - UpdateChatSessionSession unconditionally overwrote chat_session.session_id, so any task that completed without a session_id (early agent crash, missing result) wiped the resume pointer to NULL. - CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A follow-up chat message claimed in between resumed against a stale (or NULL) session and started over. - FailAgentTask never wrote session_id back, so a task that established a real session before failing lost its resume pointer. - ClaimTaskByRuntime only trusted chat_session.session_id and never fell back to the existing GetLastChatTaskSession query, so a single bad turn could permanently drop the conversation memory. This change: - Use COALESCE in UpdateChatSessionSession so empty inputs preserve the existing pointer; surface DB errors instead of swallowing them. - Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside the same transaction (TaskService now takes a TxStarter). - Extend FailAgentTask + the daemon FailTask path (client, handler, service) to forward session_id/work_dir, so failed/blocked tasks that built a real session still record it. - Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the chat_session pointer is missing, and include failed tasks in that lookup so a single failure can't lose the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(daemon): forward session_id/work_dir on blocked + timeout paths runTask previously dropped result.SessionID and env.WorkDir on the non-completed return paths: - timeout returned a naked error, so handleTask called FailTask with empty session info and the chat resume pointer was either left stale or eventually overwritten with NULL. - blocked / failed (default branch) returned a TaskResult without SessionID / WorkDir, so even though FailTask now COALESCEs into chat_session, there was no value to write through. - the empty-output completion path was the same: it raised an error even when a real session_id had been built. All three paths now return a TaskResult that carries the SessionID / WorkDir the backend produced. Combined with the COALESCE-based update in UpdateChatSessionSession and the FailTask plumbing introduced in PR #1360, the next chat turn can always resume from the latest agent session — even when the previous turn timed out, was rate-limited, or returned an empty completion — instead of starting over with no memory of the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): capture session id from session.start as fallback The Copilot backend only read sessionId from the synthetic 'result' event, ignoring the one already present on session.start. When the CLI was killed before result arrived (timeout, cancel, crash, or a session.error mid-turn), the daemon reported SessionID="" and the chat-session resume pointer could not advance — causing the chat to silently drop conversation memory on the next turn. Capture session.start.sessionId into state up front, and only let 'result' overwrite it when it actually carries one. result still wins when present (it is the authoritative end-of-turn record). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): parse premiumRequests as float to preserve session id Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5), not an integer. Our copilotResultUsage struct typed it as int, which made the entire 'result' line fail json.Unmarshal — silently dropping sessionId on every turn. This was the real cause of chat memory loss: the daemon reported SessionID="" to the server, chat_session.session_id stayed NULL, and the next chat turn never received --resume <id>, so each turn started a fresh Copilot session with no prior context. Add a regression test using the real JSON line from CLI v1.0.32 that asserts sessionId is preserved when premiumRequests is fractional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Eve <eve@multica.ai> Co-authored-by: yushen <ldnvnbl@gmail.com> |
||
|
|
556c68292f |
fix(cli): use rundll32 instead of cmd start on Windows (#1202)
Windows 下 CLI 登录用 cmd /c start 打开浏览器,cmd.exe 会把 URL 中的 & 解释为命令分隔符,导致 OAuth 回调 URL 中 &state=... 和 &cli_state=... 参数被截断。 改用 rundll32 url.dll,FileProtocolHandler,将 URL 原样传递给操作系统 shell 处理程序,不对特殊字符做任何解释。 Authored-by: xushi-mike <xushi_1983@hotmail.com> |
||
|
|
b428f36ca6 |
feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930 - Added environment variables to control signups - Updated frontend to hide signup text when disabled - Added backend check to block new user creation via magic link - Updated .env.example |
||
|
|
163f34f918 |
feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type Each backend in server/pkg/agent/ hardcodes a stable command skeleton (e.g. `codex app-server --listen stdio://`, `hermes acp`) before appending opts.CustomArgs. Surfacing that skeleton lets the UI tell users which command their custom_args are being appended to, so a Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to reach the CLI when the subcommand is actually `app-server`. Expose only the minimum that aids judgment — binary + subcommand, or a short mode label when there is no subcommand — and deliberately omit transport values, internal flags, and env to keep the surface small and renaming-safe. Refs #1308. * feat(handler/runtime): surface launch_header on runtime response runtimeToResponse now derives launch_header from agent.LaunchHeader, piggybacking on the runtime's existing provider field so the frontend's RuntimeDevice gains the skeleton without a new endpoint or DB query. Client gets the header for free whenever it lists agents' runtimes — which the custom-args tab already does. Refs #1308. * feat(ui/agents): show launch mode preview in custom args tab Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab and render its launch_header as a one-line preview above the args list, so users see `codex app-server <your args>` (or equivalent per provider) and can tell whether a CLI-style flag like `--model` will actually reach the invoked subcommand. Source of truth stays in the Go backend; the TS type just carries the string. Refs #1308. |
||
|
|
d81e6a14a6 |
fix(comment): assignee on_comment path should use reply id, not thread root (#1302)
* fix(comment): assignee on_comment path should use reply id, not thread root Symmetric fix to #871 — that PR fixed the @mention path but missed the assignee on_comment path in the same file. Replies on agent-assigned issues were still getting trigger_comment_id = parent_id, so the daemon fed the parent comment's content to the resumed claude session, which then either exited with 'Already replied to comment <parent>' or silently misrouted its answer depending on model / session state. Reply placement (flat-thread grouping) is already decoupled from trigger_comment_id by TaskService.createAgentComment's parent normalization (added alongside #871), so passing comment.ID directly is safe and matches the mention path's post-#871 behavior. Fixes #1301 Made-with: Cursor * test(comment): assert assignee on_comment records reply id as trigger_comment_id Integration regression guard for #1301. Asserts that after a member posts a reply under an agent-authored thread, the enqueued agent task's trigger_comment_id matches the new reply, not the thread root. Without the companion fix in comment.go the old parent-override would store the root id and the daemon would feed stale content (via prompt.go BuildPrompt) to the agent. Made-with: Cursor --------- Co-authored-by: fuxiao <fuxiao@zyql.com> |
||
|
|
e198a67f8f |
docs(prompt): warn agents that mention syntax is an action, not a text reference (#1306)
Agent mentions enqueue a new task; member mentions send a notification. Without this warning, agents have used `[@Name](mention://agent/<id>)` in prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent. Adds a caveat under `## Mentions` in the prompt injected into agent runtimes, plus tightens the Agent bullet to make the side-effect explicit. |
||
|
|
746f33a38b |
fix(claude): clear fresh session_id on resume failure so daemon fallback fires (#1285)
When --resume targets a dead session, claude prints "No conversation found with session ID: ..." to stderr, emits a stream-json system init with a fresh session_id, then exits with code 1. The backend was treating that fresh id as the authoritative session, so daemon.go's retry-with-fresh-session fallback (SessionID == "" guard) never triggered. Every subsequent task for the same (issue, agent) pair stayed permanently broken until the server-side session_id was cleared by hand. Fix: when --resume was requested but the emitted session_id differs AND the run failed, drop the fresh id from Result so the daemon's existing fallback can do its job. Factored into a pure helper and unit-tested. Fixes #1284 Co-authored-by: fuxiao <fuxiao@zyql.com> |
||
|
|
aa9305f7e4 |
fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1294)
* fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1276) * test: add regression test for #1276 — ClaimTaskByRuntime autopilot workspace_id |
||
|
|
63800f05ff |
fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access Closes #1111 The --strict-mcp-config flag was added defensively in #592 to prevent Claude agents from inheriting MCP state from the outer Claude Code session. It was meant to be paired with --mcp-config <path> to inject a controlled set of MCPs, but that path was never implemented, which silently stripped all user-scope MCPs from spawned agents. This PR completes the original design by: - Adding a nullable mcp_config jsonb column to the agents table - Wiring mcp_config through AgentResponse, Create/Update requests - Piping it into ExecOptions.McpConfig in the daemon - Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs - Blocklisting --mcp-config in claudeBlockedArgs to prevent override via custom_args Does not touch Codex provider (tracked separately in #674). Does not implement Multica MCP auto-injection (out of scope). * fix: disambiguate JSON null vs absent for mcp_config |
||
|
|
b2307a5ee9 |
fix(execenv): write Copilot skills to .github/skills/ for native discovery (#1270)
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md (per the official cli-config-dir-reference docs), not from .agent_context/skills/. Previously, skills injected for the copilot provider were placed under .agent_context/skills/ and only referenced by name in AGENTS.md, meaning Copilot would not actually pick them up. - resolveSkillsDir: add a dedicated copilot case writing to .github/skills/ - Update doc comments in context.go and runtime_config.go - Add TestWriteContextFilesCopilotNativeSkills covering the new path and ensuring .agent_context/skills/ is not created for copilot Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
9b45e0d4a6 |
feat(cli): add issue subscriber commands (#1265)
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
|
||
|
|
4bd8533269 |
fix(daemon): machine-scoped daemon.id so CLI + desktop share one identity (#1263)
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/ <profile>/daemon.id — meaning the same physical machine minted a different UUID per profile. On any host running both the CLI-spawned daemon (default profile) and the desktop-spawned daemon (profile derived from API host), that produced two runtime rows per provider per workspace. The server-side `legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the rows just piled up. Profile boundaries are about which backend/account the daemon is talking to, not about the physical machine. Identity should be per-machine, token should be per-profile. Changes: - `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless of the `profile` argument. The argument is retained for migration-only use (see promotion below). - Migration path: when the canonical file is missing and the requested profile has a pre-change per-profile daemon.id, promote that UUID in place so a user who only ever ran under a named profile keeps the same identity instead of minting a fresh UUID and round-tripping a merge. - New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and returns every UUID that survives parsing. `config.go` now appends those to the daemon's `legacy_daemon_ids` payload, so any runtime rows previously registered under a per-profile UUID (on any backend) get merged into the canonical machine UUID at register time. Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles` and add coverage for promotion, UUID scanning (including skipping corrupt files), and the empty-profiles-dir fast path. |
||
|
|
a73336dcf8 |
feat(daemon): persistent UUID identity + legacy-id merge at register-time (#1220)
* feat(daemon): persistent UUID identity + legacy-id merge at register-time daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on first start, replacing the hostname-derived id that drifted whenever `.local` appeared/disappeared, a system was renamed, or a profile switched — each of which used to mint a fresh `agent_runtime` row and strand agents on the old one. To migrate existing installs without operator intervention, the daemon reports every legacy id it may have registered under previously (`host`, `host` with `.local` stripped, and `host[-profile]` variants for both). At register-time the server looks up each candidate row scoped to (workspace, provider), re-points its agents and tasks onto the new UUID-keyed row, records which legacy id was subsumed in the new `legacy_daemon_id` column for audit, and deletes the stale row. Result: users running `xxx.local`-keyed runtimes today transparently land on the new UUID row on next daemon restart. The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'` compatibility shim is no longer needed and has been removed along with the handler call that invoked it. * fix(daemon): handle bidirectional .local drift and case drift in legacy merge Review on #1220 flagged two gaps in the legacy-id migration candidate set: 1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the current hostname ended in `.local`. The opposite direction — DB has `foo.local`, current host is `foo` — was missed, so runtimes registered under the `.local` variant stayed orphaned after upgrade. Now both variants (`foo` and `foo.local`) are always emitted, regardless of what `os.Hostname()` currently returns, plus their `-<profile>` suffix forms. 2. Case drift: os.Hostname() has been observed returning different casings on the same machine across mDNS/reboot state. A case-sensitive `=` comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing differences merge rather than orphan. The (workspace_id, provider) prefix still bounds the scan to a tiny set of rows so the non-indexed LOWER() comparison has negligible cost. Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases; daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift. * fix(daemon): consolidate every case-duplicate legacy runtime, not just the first Follow-up review on #1220: after switching to `LOWER(daemon_id) = LOWER(@daemon_id)`, the single-row lookup still only merged one legacy row per candidate. If a machine already had two rows in the DB that differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND `jiayuans-macbook-pro.local` coexisting because earlier hostname drift already minted a duplicate), only one of them got consolidated and the other stayed orphaned — violating the "no duplicate runtime per machine after backfill" acceptance. - FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many) - mergeLegacyRuntimes iterates every returned row and dedupes across overlapping legacy candidates so `foo` and `foo.local` both resolving to the same stored row don't double-process Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two case-duplicate rows with one agent each and confirms both rows are deleted and both agents end up on the new UUID-keyed row. |
||
|
|
ce610a6414 |
refactor(cli): drop webhook/api from autopilot trigger-add (#1261)
These trigger kinds exist in the DB schema but nothing on the server
fires them:
- autopilot_scheduler.ClaimDueScheduleTriggers filters kind='schedule'
(pkg/db/queries/autopilot.sql:150)
- DispatchAutopilot is reached only from the scheduler (source:schedule)
or POST /api/autopilots/{id}/trigger (source:manual); no inbound
webhook or api endpoint exists
- The UI only surfaces schedule creation
Exposing them in the CLI lets users create triggers that sit in the DB
doing nothing. Drop --kind from trigger-add, require --cron, always
send kind=schedule. Re-add the flag when the server grows a dispatch
path for the other kinds.
|
||
|
|
5a6a44a69e |
refactor(daemon): consolidate task workspace resolver + regression test (#1259)
Follow-up to #1249. Two small follow-ups requested in review: 1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and `service/task.go`. #1249 fixed the handler copy but left both in place, meaning any future branch (e.g. a fourth task link type) still needs to be added in two files. Promote the service method to the exported `TaskService.ResolveTaskWorkspaceID` and delete the handler copy. Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now call through `h.TaskService`. 2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace` covering the exact scenario from #1224: a task linked only via `AutopilotRunID` must resolve to the autopilot's workspace. The test asserts 404 for a cross-workspace daemon token and 200 (with status transitioning to `running`) for the correct-workspace token. |
||
|
|
423ceaf8f4 |
test(agent): regression tests for codex subagent threadId filter (#1257)
Follow-up to #1192. Document the v2 protocol contract that the dispatch-level threadId guard relies on, and lock down the two leakage paths the guard closes: - turn/completed from a subagent thread must not call onTurnDone - item/completed (agentMessage, final_answer) from a subagent thread must neither leak text into the output builder nor terminate the turn Without these tests a future refactor that drops or relocates the guard would not be caught by CI, since existing notification tests omit the top-level threadId field and pass through unfiltered. |
||
|
|
9e15b17c92 |
feat(cli): add autopilot commands (#1234)
* feat(cli): add autopilot commands Expose the existing autopilot REST API through the multica CLI so users and agents can list, get, create, update, delete, trigger, and inspect autopilots, plus manage their triggers (schedule/webhook/api). Also surface the read + core write commands in the agent meta skill prompt so agents discover them without needing --help. - new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints - add APIClient.PatchJSON (autopilot update uses PATCH) - expose autopilot in CORE COMMANDS group - extend runtime_config.go meta skill with autopilot entries - document autopilot command group in CLI_AND_DAEMON.md * fix(autopilot): address code review — restrict run_only, validate workspace on update Code review caught two issues with the initial CLI PR: 1. run_only mode is broken end-to-end. The daemon-side resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves workspace from issue/chat, so run_only tasks (which have neither) return 404 from /start. BuildPrompt() would also emit an empty issue ID. The service-level resolver in internal/service/task.go already handles AutopilotRunID, but the daemon endpoint uses the handler copy. Fixing that path is out of scope for the CLI PR; drop run_only from the CLI and docs so we don't recommend a mode that cannot complete. Server continues to accept it for the existing UI. 2. UpdateAutopilot did not verify that a new assignee_id belongs to the workspace, unlike CreateAutopilot. This let a PATCH swap in an agent from a different workspace. Mirror the same GetAgentInWorkspace check. |
||
|
|
462ff88df5 |
fix(codex): dispatch-level threadId filter for subagent notifications (#1192)
* fix(daemon): filter thread/status/changed by threadId to prevent subagent interference When Codex CLI has memories enabled, the app-server spawns a memory consolidation subagent as a separate thread within the same stdio connection. When that subagent thread finishes and transitions to idle, the daemon's codex backend mistakenly interprets the idle signal as the main turn completing, causing it to close stdin and cancel the context before the real turn produces any output. Add a threadId check to the thread/status/changed handler so only status changes from the tracked thread trigger turn completion. Signals from subagent threads (threadId != c.threadID) are now ignored. Fixes #1181 * fix(codex): dispatch-level threadId filter for subagent notifications Codex multiplexes subagent threads (e.g. memory consolidation) on the same stdio pipe. Previously only thread/status/changed had a threadId guard, but item/completed (agentMessage + final_answer), turn/completed, and turn/started from subagent threads could still trigger onTurnDone or contaminate output. Move the threadId check to the top of handleRawNotification so all notification handlers are protected. Remove the now-redundant per-handler check on thread/status/changed. Fixes multica-ai/multica#1181 --------- Co-authored-by: fuxiao <fuxiao@zyql.com> |
||
|
|
ea02a394dc |
fix(daemon): resolve workspace ID for autopilot run_only tasks (#1224) (#1249)
resolveTaskWorkspaceID only handled tasks linked via IssueID or ChatSessionID. Tasks created by run_only autopilots (introduced in #1028) have only AutopilotRunID set, so the resolver returned an empty workspace ID, causing requireDaemonTaskAccess to respond with 404. Add an AutopilotRunID branch that looks up the autopilot run, then its parent autopilot, to obtain the workspace ID. |
||
|
|
b5de04da59 |
fix(daemon): platform-aware Codex sandbox config to unbreak macOS network (MUL-963) (#1246)
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network On macOS, Codex's Seatbelt sandbox in workspace-write mode silently ignores '[sandbox_workspace_write] network_access = true' (see openai/codex#10390). That blocks DNS inside the sandbox, so 'multica issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such host' — this is what caused MUL-963. Changes: - New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox policy based on runtime.GOOS and the detected Codex CLI version. Non-darwin or darwin with a known-fixed version keeps workspace-write + network_access=true; older darwin falls back to danger-full-access and logs a warn with upgrade hint. The fix-version threshold is a single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy to bump once upstream ships. - Per-task config.toml now gets a 'multica-managed' marker block (BEGIN/END comments) rewritten idempotently; user-owned keys outside the markers are preserved. Legacy inline sandbox directives from earlier daemon versions are stripped on migration. - execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a codexVersion arg; daemon.go caches detected versions at registration and threads them through to Prepare/Reuse. - Replaces the old ensureCodexNetworkAccess tests with platform-parameterised coverage (linux vs darwin, idempotency, legacy-migration, policy matrix). - docs/codex-sandbox-troubleshooting.md: symptom fingerprint table, decision matrix, self-check commands, trade-offs. Refs: MUL-963 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(daemon): hoist managed sandbox block above user tables (MUL-963) Review on #1246 flagged that upsertMulticaManagedBlock appended the managed block to EOF. If the user's config.toml ends inside a TOML table (e.g. [permissions.multica] or [profiles.foo]), a trailing bare sandbox_mode = "..." is parsed as a key of that preceding table, so Codex silently ignores the policy the daemon meant to apply. Two changes make the block position-independent: - renderMulticaManagedBlock now emits only top-level key=value lines and uses TOML dotted-key form (sandbox_workspace_write.network_access = true) instead of opening a [sandbox_workspace_write] header. The block therefore neither inherits from nor leaks into any surrounding table. - upsertMulticaManagedBlock always hoists the block to the top of the file (stripping any previously written managed block first), so the sandbox_mode line is always at the TOML root regardless of what the user put below it. This also migrates configs written by the original PR #1246 logic where the block was trapped behind a user table. Added tests for the regression scenario (pre-existing [permissions.*] table) and the legacy-trailing-block migration; updated the existing Linux default test and the troubleshooting runbook to reflect the dotted-key form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: CC-Girl <cc-girl@multica.ai> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
131fee36d7 |
fix(autopilot): use readable UTC timestamp in issue description (#1250)
Autopilot was formatting the triggered-at timestamp with time.RFC3339
(e.g. "2026-04-16T14:54:32Z"), which is hard to read and confusing for
users in non-UTC timezones because the "Z" suffix looks like an error
instead of a timezone indicator.
Switch to a human-readable format ("2026-04-16 14:54 UTC") so only the
hour differs from local time; minutes match across timezones, making
the value easy to reconcile at a glance.
Fixes multica-ai/multica#1197.
|
||
|
|
3ea6b5c7b8 |
fix(agent): return 409 on duplicate agent name (#1182)
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated) - CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique - Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying - Down migration drops the constraint only; deleted rows and cascaded data are not restored Co-authored-by: Anup Joy <joyanup@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
7dc37e87df |
fix(autopilot): subscribe creator to autopilot-created issues (#1229)
The issue:created subscriber listener type-asserted payload["issue"] to handler.IssueResponse, but autopilot publishes the issue as map[string]any (via service.issueToMap). The assertion failed silently, so no subscribers (including the creator) were ever added to autopilot issues — meaning creators received no notifications when their autopilot run produced comments or status changes. Add an extractIssueFields helper that accepts either format and use it in both the issue:created and issue:updated listeners. Mirrors the dual-format pattern already used by the comment:created listener. |
||
|
|
209300c86f |
fix(server): trigger agent on comments regardless of issue status (#1209)
Previously shouldEnqueueOnComment suppressed agent triggers on done/ cancelled issues, requiring an explicit @mention to resume the conversation. The gate was non-obvious and confused users who expected a regular reply to wake the agent up. Drop the status check — comments are conversational and should wake the agent up at any status. @mention already bypasses all gates, so behavior for mentions is unchanged. Refs multica-ai/multica#1205 |
||
|
|
3d98f64ea1 |
Revert "fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)" (#1207)
This reverts commit
|
||
|
|
6428a10046 |
fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)
* fix(daemon): normalize hostname by stripping .local mDNS suffix Daemons started via different methods (standalone CLI vs desktop app bundled binary) resolve the hostname differently on macOS — one gets 'computer' and the other 'computer.local'. This caused duplicate runtime registrations for the same machine. Stripping the .local suffix at the point of hostname resolution ensures both always register under the same identifier. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(daemon): move empty-host fallback to after .local trim; fix Makefile @ prefix - Reorder: TrimSuffix runs first, then empty-check, so a hostname of just ".local" doesn't propagate as an empty daemon_id/device_name - Add missing @ prefix on migrate command in Makefile so it isn't echoed twice at startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6d6bc5a6f2 |
fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list (#1188)
* fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list
Two related changes:
1. Rename the global workspace-creation route from /new-workspace to
/workspaces/new. The hyphenated word-group `new-workspace` is a
common user workspace name (last deploy was blocked by a real user
with exactly this slug). Industry consensus from auditing Linear,
Vercel, Notion, Slack, GitHub: zero major SaaS uses hyphenated
word-group root routes — they all use single words or `/{noun}/{verb}`
pairs. Reserving the noun `workspaces` automatically protects the
entire `/workspaces/*` subtree, so future workspace-related routes
(`/workspaces/{id}/edit`, `/workspaces/{id}/billing`, etc.) need no
additional reserved slugs or audit migrations.
2. Extend the reserved slug list to cover the minimal set recommended by
the URL-design audit: full auth flow vocab, RFC 2142 mailbox names
(postmaster, abuse, noreply...), hostname confusables (mail, ftp,
static, cdn...), and likely-future platform routes (docs, support,
status, legal, privacy, terms, security, etc.). Production data
audit confirmed zero conflicts for every newly added slug, so
migration 047 (the safety net) passes cleanly.
Slugs intentionally NOT added despite being in scope of the audit:
admin, multica, new, setup, www. Each has one production workspace
already using it; adding them now would block deploy. They will be
handled in a follow-up PR via owner outreach + targeted rename.
Also adds a CLAUDE.md convention rule: new global routes MUST use a
single word or `/{noun}/{verb}` pair, never hyphenated word groups.
This prevents the pattern from regenerating itself.
This PR does NOT resolve the currently-blocked prd deploy — that requires
the existing `slug='new-workspace'` workspace (owner: Dhruv Raina) to be
renamed by ops. After that workspace is renamed and migration 046 passes,
this PR's migration 047 will also pass on its first run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: drop migration 046, sweep stale comments, drive reserved test from map
Address code review on PR #1188:
1. Delete migration 046 (audit_new_workspace_slug). It audits "new-workspace"
which is no longer a reserved slug after this PR's rename. Removing 046
has an unexpected upside: it directly unblocks the currently-stuck prd
deploy. Migration 046 had never successfully applied (it was the source
of the deploy block); the audit-only nature means down-rollback is a
no-op. The user workspace previously caught by 046 (slug='new-workspace',
owner: Dhruv Raina) is now safe — `new-workspace` is no longer reserved,
so the slug correctly resolves to that workspace and the global route
`/workspaces/new` doesn't shadow it.
2. Refactor workspace_test.go to drive its reserved-slug list from the
reservedSlugs map directly via `for slug := range reservedSlugs`. The
previous hand-copied list was already drifting (40-ish entries vs 58 in
the map). Now drift is impossible.
3. Sweep ~10 stale `/new-workspace` references in code comments to
`/workspaces/new`. Comments only — runtime unchanged. The references
in reserved-slugs.ts/workspace_reserved_slugs.go and CLAUDE.md are
intentionally kept as anti-pattern examples ("don't add hyphenated
word-group root routes like /new-workspace").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|