Skip to content
Open
47 changes: 47 additions & 0 deletions CLI_AND_DAEMON.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,53 @@ availability stays independent of a third party's release cadence.
Desktop-managed daemons ignore both, because the Desktop app owns its bundled
CLI's lifecycle.

### Boot autostart

Opt-in: only `multica daemon autostart enable` registers the profile's
daemon with the OS, so the machine brings it back after a reboot or re-login
— without that, every reboot takes the runtime offline and queued runs sit
unclaimed. `daemon start` never registers on its own: it prints a one-line
hint when nothing is registered, and otherwise stays out of the way.

| Platform | Mechanism | Where |
| --- | --- | --- |
| Windows | Per-user Run key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, value `Multica` |
| macOS | launchd LaunchAgent | `~/Library/LaunchAgents/ai.multica.daemon.plist` (runs at login) |
| Linux | systemd user unit | `~/.config/systemd/user/multica-daemon.service`; without systemd, an XDG autostart entry under `~/.config/autostart/` |

Each entry runs `multica daemon start --foreground` for that profile — named
profiles get their own entry, so several daemons on one machine never collide.
Daemon settings are read from the profile's config (`multica config set ...`)
at start. Shell environment variables do **not** travel into a login session:
only `PATH` is snapshotted at registration (and refreshed while Multica owns
the entry), which is what keeps agent CLIs installed via Homebrew, nvm, or a
user bin directory discoverable — persist everything else with
`multica config set` or your user environment. On Linux, `enable` also tells
you (it does not run it for you) how to `loginctl enable-linger $USER` so a
headless machine starts the unit at boot rather than at first login.

```bash
multica daemon autostart enable # the only thing that registers
multica daemon autostart status # what is registered, and where
multica daemon autostart status --output json
multica daemon autostart disable # remove the registration
```

Refresh: while an entry exists and carries Multica's ownership marker,
`daemon start` silently rewrites it so a moved executable (a Homebrew
upgrade, a self-update) heals. An entry at the same path that Multica did
not create — your own systemd unit, a hand-written LaunchAgent — is never
refreshed and never overwritten: `enable`/`disable` refuse it and say which
file is in the way, and the refresh is additionally skipped when the daemon
was launched by an external supervisor (systemd `INVOCATION_ID` for a unit
that is not ours).

`daemon stop` stops the daemon but keeps the registration — it says nothing
about the next boot; `autostart disable` is what turns that off. Daemons
started by the Multica Desktop app are never registered or refreshed here:
the app owns that daemon's lifecycle through its own app-start daemon
preference.

### Stop

```bash
Expand Down
14 changes: 7 additions & 7 deletions server/cmd/multica/cmd_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func TestMissingServerConfigMessageExplainsPortOnlyContext(t *testing.T) {
// and asserts the CLI refuses the config-PAT fallback from the escaped cwd.
func TestNewAPIClient_WorkdirParentEscapeFailsClosed(t *testing.T) {
// Seed a user config with a mul_ PAT that must never be picked up.
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
if err := cli.SaveCLIConfig(cli.CLIConfig{Token: "mul_owner_pat"}); err != nil {
t.Fatalf("seed config: %v", err)
}
Expand Down Expand Up @@ -333,7 +333,7 @@ func TestNewAPIClient_LeftoverMarkerActionableError(t *testing.T) {
// Outside agent context, the three-level fallback (flag → env → config) is
// unchanged.
func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())

// Seed the global CLI config with a workspace_id that must NOT be
// picked up while running inside an agent task.
Expand Down Expand Up @@ -428,7 +428,7 @@ func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) {
}

func TestResolveToken_AgentContextSkipsConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())

if err := cli.SaveCLIConfig(cli.CLIConfig{Token: "mul_profile_token"}); err != nil {
t.Fatalf("seed config: %v", err)
Expand Down Expand Up @@ -642,7 +642,7 @@ func TestNewAPIClient_AgentContextRequiresTaskToken(t *testing.T) {

func TestNewAPIClient_DaemonPortRequiresTaskToken(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:8080")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
t.Setenv("MULTICA_AGENT_ID", "")
Expand All @@ -667,7 +667,7 @@ func TestNewAPIClient_DaemonPortRequiresTaskToken(t *testing.T) {
}

func TestNewAPIClient_WorkdirMarkerRequiresTaskToken(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:8080")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
Expand Down Expand Up @@ -775,7 +775,7 @@ func TestParseCustomEnv(t *testing.T) {
// --custom-env* flags are gone from `agent update`; the hint must
// surface their replacement so users discover the new audited path.
func TestAgentUpdateNoFieldsErrorPointsAtEnvCommand(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "test-ws")
t.Setenv("MULTICA_TOKEN", "test-token")
Expand Down Expand Up @@ -831,7 +831,7 @@ func TestAgentMaxConcurrentTasksFlagValidation(t *testing.T) {
}))
defer srv.Close()

t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
Expand Down
40 changes: 36 additions & 4 deletions server/cmd/multica/cmd_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,39 @@ func TestMain(m *testing.M) {
} {
os.Unsetenv(key)
}
os.Exit(m.Run())

// Redirect both home variables to one scratch directory for the whole
// binary. On Windows os.UserHomeDir reads USERPROFILE, not HOME, so a
// test that redirects only HOME still resolves ~/.multica against the
// real home — which is how a full-suite run once wrote SaveCLIConfig
// fixtures over a real default-profile config.json. Process-wide
// redirection isolates tests that forget their own redirect on every
// platform; per-test t.Setenv overrides still take precedence.
var scratchHome string
if home, err := os.MkdirTemp("", "multica-cli-tests-home-"); err == nil {
scratchHome = home
os.Setenv("HOME", home)
os.Setenv("USERPROFILE", home)
}

code := m.Run()
if scratchHome != "" {
os.RemoveAll(scratchHome)
}
os.Exit(code)
}

// redirectTestHome points BOTH home environment variables at dir. Production
// resolves the config directory through os.UserHomeDir, which reads HOME on
// unix and USERPROFILE on Windows: redirecting only HOME splits the write
// path (tests creating fixtures under HOME) from the read path (code
// resolving ~/.multica through USERPROFILE), so on Windows the fixture lands
// where the code never looks — or, before the TestMain scratch home existed,
// in the real ~/.multica.
func redirectTestHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}

// testCmd returns a minimal cobra.Command with the --profile persistent flag
Expand Down Expand Up @@ -333,7 +365,7 @@ func TestLoginTokenFlagParsing(t *testing.T) {

func TestRunAuthStatusTaskContextDoesNotPrintCredential(t *testing.T) {
const fakeTaskToken = "mat_task_status_sentinel"
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
t.Setenv("MULTICA_AGENT_ID", "agent-test")
t.Setenv("MULTICA_TASK_ID", "task-test")
t.Setenv("MULTICA_TOKEN", fakeTaskToken)
Expand Down Expand Up @@ -372,7 +404,7 @@ func TestRunAuthStatusTaskContextDoesNotPrintCredential(t *testing.T) {

func TestRunAuthStatusTaskContextRequiresTaskToken(t *testing.T) {
ownerHome := t.TempDir()
t.Setenv("HOME", ownerHome)
redirectTestHome(t, ownerHome)
t.Setenv("MULTICA_AGENT_ID", "agent-test")
t.Setenv("MULTICA_TASK_ID", "task-test")
t.Setenv("MULTICA_TASK_CONFIG_ROOT", filepath.Join(t.TempDir(), "task-multica"))
Expand Down Expand Up @@ -433,7 +465,7 @@ func TestRunAuthStatusTaskContextRequiresTaskToken(t *testing.T) {

func TestHumanAuthCommandsFailClosedInTaskContext(t *testing.T) {
ownerHome := t.TempDir()
t.Setenv("HOME", ownerHome)
redirectTestHome(t, ownerHome)
t.Setenv("MULTICA_AGENT_ID", "agent-test")
t.Setenv("MULTICA_TASK_ID", "task-test")
t.Setenv("MULTICA_TOKEN", "mat_task_sentinel")
Expand Down
2 changes: 1 addition & 1 deletion server/cmd/multica/cmd_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func TestRunConfigSetPersistsValues(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
cmd := testCmd()

if err := runConfigSet(cmd, []string{"server_url", "http://example.com"}); err != nil {
Expand Down
10 changes: 5 additions & 5 deletions server/cmd/multica/cmd_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func newConfigTestCmd() *cobra.Command {
}

func TestRunConfigSetPersistsSupportedKeysInProfile(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())
workspacesRoot := filepath.Join(t.TempDir(), "multica-dev")

cmd := newConfigTestCmd()
Expand Down Expand Up @@ -51,7 +51,7 @@ func TestRunConfigSetPersistsSupportedKeysInProfile(t *testing.T) {
}

func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())

cmd := newConfigTestCmd()
_ = cmd.Flags().Set("profile", "empty")
Expand Down Expand Up @@ -98,7 +98,7 @@ func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) {
func TestRunConfigCommandsUseTaskLocalConfigWithoutTouchingOwner(t *testing.T) {
ownerHome := t.TempDir()
taskRoot := filepath.Join(t.TempDir(), "task-multica")
t.Setenv("HOME", ownerHome)
redirectTestHome(t, ownerHome)
t.Setenv("MULTICA_AGENT_ID", "agent-test")
t.Setenv("MULTICA_TASK_ID", "task-test")
t.Setenv("MULTICA_TASK_CONFIG_ROOT", taskRoot)
Expand Down Expand Up @@ -151,7 +151,7 @@ func TestRunConfigCommandsUseTaskLocalConfigWithoutTouchingOwner(t *testing.T) {

func TestRunConfigCommandsFailClosedWithoutTaskRoot(t *testing.T) {
ownerHome := t.TempDir()
t.Setenv("HOME", ownerHome)
redirectTestHome(t, ownerHome)
t.Setenv("MULTICA_AGENT_ID", "agent-test")
t.Setenv("MULTICA_TASK_ID", "task-test")
t.Setenv("MULTICA_TASK_CONFIG_ROOT", "")
Expand Down Expand Up @@ -182,7 +182,7 @@ func TestRunConfigCommandsFailClosedWithoutTaskRoot(t *testing.T) {
}

func TestRunConfigSetRejectsUnknownKey(t *testing.T) {
t.Setenv("HOME", t.TempDir())
redirectTestHome(t, t.TempDir())

cmd := newConfigTestCmd()
err := runConfigSet(cmd, []string{"token", "secret"})
Expand Down
38 changes: 36 additions & 2 deletions server/cmd/multica/cmd_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ var daemonCmd = &cobra.Command{
var daemonStartCmd = &cobra.Command{
Use: "start",
Short: "Start the local agent runtime daemon",
Long: "Start the daemon process that polls for runs and executes them using local agent CLIs (Claude, Codex).\nRuns in the background by default. Use --foreground to run in the current terminal.",
RunE: runDaemonStart,
Long: "Start the daemon process that polls for runs and executes them using local agent CLIs (Claude, Codex).\n" +
"Runs in the background by default. Use --foreground to run in the current terminal.\n" +
"Boot autostart is opt-in: when this profile's daemon has none, a hint points at " +
"'multica daemon autostart enable'; an existing Multica-created entry is refreshed in place.",
RunE: runDaemonStart,
}

var daemonStopCmd = &cobra.Command{
Expand Down Expand Up @@ -568,6 +571,9 @@ func runDaemonBackground(cmd *cobra.Command) error {
if err := daemonIdentityMismatch(health, profile, healthPort); err != nil {
return err
}
// Even a no-op start keeps the autostart contract: hint when nothing
// is registered, silently refresh an entry Multica owns.
syncDaemonAutostart(profile, true)
label := "daemon"
if profile != "" {
label = fmt.Sprintf("daemon [%s]", profile)
Expand All @@ -580,6 +586,10 @@ func runDaemonBackground(cmd *cobra.Command) error {
return err
}

// Hint about boot autostart / refresh an owned entry. Never creates a
// registration — only 'multica daemon autostart enable' does that.
syncDaemonAutostart(profile, true)

// Resolve current executable so the foreground child reuses this binary.
exePath, err := daemonExecutable()
if err != nil {
Expand Down Expand Up @@ -918,6 +928,14 @@ func runDaemonForeground(cmd *cobra.Command) error {

profile := resolveProfile(cmd)

// Keep an entry this process may itself have been launched from fresh:
// rewriting is idempotent, and it heals a stale executable path after a
// self-update or an in-place upgrade moved the binary. Never creates an
// entry, never touches one without our marker, and never runs under an
// external supervisor (see syncDaemonAutostartDefault). The hint is
// announced only to a watching human (stderr is a terminal).
syncDaemonAutostart(profile, logger_pkg.StderrIsTerminal())

// Load the profile config once — several daemon knobs fall back to
// values persisted here when both the CLI flag and the env var are
// unset. Errors reading the config are non-fatal for anything other
Expand Down Expand Up @@ -1106,6 +1124,22 @@ func runDaemonForeground(cmd *cobra.Command) error {
_ = logRotator.Close()
}

// Under OUR OWN systemd unit, spawning a successor and exiting 0
// loses it: systemd sees a clean stop of Type=simple and kills
// everything left in the cgroup — successor included (Setsid escapes
// a session, not a cgroup) — while Restart=on-failure never fires
// for exit 0. The first auto-update after a boot-autostarted start
// would leave the runtime offline until the next reboot. Exit with
// the dedicated handoff status instead; the generated unit carries
// RestartForceExitStatus for it, so systemd restarts the new binary.
// Any other supervisor (launchd's process-group kill, no supervisor
// at all) keeps the portable spawn handoff below.
if daemonUnderOwnSystemdUnit(profile) {
logger.Info("handing off to systemd for the updated binary",
"path", restartBin, "exit_code", daemonSystemdHandoffExitStatus)
os.Exit(daemonSystemdHandoffExitStatus)
}

args := buildDaemonStartArgs(cmd)
child := exec.Command(restartBin, args...)

Expand Down
Loading
Loading