Skip to content

feat(desktop+config): AO_KEEP_DAEMON keep-alive + per-role worker env profile (claude-code)#2621

Closed
axisrow wants to merge 3 commits into
AgentWrapper:mainfrom
axisrow:feat/desktop-keep-daemon-and-per-role-env
Closed

feat(desktop+config): AO_KEEP_DAEMON keep-alive + per-role worker env profile (claude-code)#2621
axisrow wants to merge 3 commits into
AgentWrapper:mainfrom
axisrow:feat/desktop-keep-daemon-and-per-role-env

Conversation

@axisrow

@axisrow axisrow commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Combines two independent, non-overlapping changes into one PR (frontend desktop layer + backend config layer; no shared files). Each was previously open separately (#2231 and #2611) and closed by accident when their head branches were removed during a fork cleanup — this re-opens both, with all maintainer review comments addressed.

Closes #2230. Closes #2195.


Part 1 — AO_KEEP_DAEMON (desktop keep-alive) — closes #2230

Opt-in env var: when set, the desktop app spawns its daemon without the OS-native supervisor link and skips the orphan-cleanup kill on exit, so the daemon persists across app quit and stops only on an explicit ao stop. Default (unset) is byte-for-byte unchanged — the daemon self-stops shortly after the app quits.

Beyond "power users with long sessions," the persistent-daemon mode is a prerequisite for a detached / remote topology (a headless daemon in a container, attached from the desktop over an SSH tunnel): without it, closing the desktop window arms-then-fires the supervisor watchdog and kills every running agent session in the container.

Both maintainer (illegalcall) review comments are addressed in the included commits:

  • Cross-launch regression (persist the keep decision with the daemon): the keep decision is read from the daemon's durable running.json owner record (\"persistent\" vs \"app\"), never the current Electron process env. A daemon spawned keep-alive stays persistent even when the app is later reopened without AO_KEEP_DAEMON. shouldLinkOnAttach(\"persistent\") === false is encoded as a regression test.
  • fd leak: the parent's keepDaemonLogFd is closed immediately after spawn() (the child holds its own inherited copy via stdio) and on the spawn-throw path, so each keep-alive start/stop cycle no longer accumulates descriptors.
  • stdio lifetime: under the flag the daemon's stdio is redirected to ~/.ao/daemon.log (stdin ignored) and the child is unref()'d, so Electron-owned pipes no longer kill the kept-alive daemon on the next stderr log write.
  • truthy parsing: keepDaemonAlive uses an explicit allowlist (1/true/yes/on); off/no and any unrecognized string keep the default self-stop behavior.

Files: frontend/src/main.ts, frontend/src/main/daemon-owner.ts(.test.ts), frontend/src/shared/daemon-discovery.ts, backend/internal/runfile/runfile.go, docs/cli/README.md.

Part 2 — per-role worker environment profile (claude-code) — closes #2195

Widens the per-role RoleOverride beyond {agent, model, permissions} into a full worker environment profile: system prompt, env, MCP servers, plugins — scoped per role (worker/orchestrator), defaulting to inherit-everything so nothing breaks. claude-code ships a CLI flag for each aspect (--append-system-prompt, --mcp-config, --strict-mcp-config, --plugin-dir/--plugin-url).

  • domain (agentconfig.go): AgentConfig gains SystemPrompt, Env, MCP *MCPConfig, PluginDirs; MCPConfig{Configs, Strict} (Strict alone = isolation, valid).
  • session manager: effectiveAgentConfig merges the new fields — Env per-key via deep-copying mergeEnv (a regression test pins that it never mutates the project config), and the MCP pointer / PluginDirs slice are copied rather than aliased (same defense-in-depth). buildSystemPrompt appends the per-role prompt; role Env layers onto project.Env at spawn + restore.
  • adapter claudecode: appendMCPFlags / appendPluginFlags emit in both GetLaunchCommand and GetRestoreCommand (resume rebuilds from flags); both paths call cfg.Config.Validate() for symmetry. nil/empty → inherit global, unchanged.
  • CLI: --worker-mcp-config (repeatable), --worker-strict-mcp, --worker-plugin-dir (repeatable); full surface also via --config-json. UI deferred to a follow-up.
  • Regen: openapi.yaml + frontend/src/api/schema.ts.

Scope of Part 2: claude-code only this pass. codex/other adapters inherit as today (follow-up).

Verification

  • gofmt + go vet clean; affected Go suites pass (session_manager, claudecode, domain, cli, runfile).
  • tsc --noEmit clean; 26 daemon-owner tests pass (incl. the cross-launch and allowlist cases).
  • Local review (Claude subagent + Codex adversarial, both approve) on each part independently: no critical issues, all high-risk areas pass (env-merge mutation leak fixed; restore re-applies MCP/plugin flags; nil/empty preserves default behavior; cross-launch keep decision persisted on the daemon record).

Note

claude-code requires --mcp-config values in the {\"mcpServers\": {...}} shape (not a bare server JSON) — a user-config detail worth documenting when the UI lands.

@axisrow

axisrow commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Local review (cycle 1) — clean

Reviewed locally across both layers (Claude subagent per layer + Codex adversarial on the combined diff), no bots pinged.

Verdict: no critical issues. Ship it. All high-risk areas verified airtight by all three reviewers:

Desktop layer (AO_KEEP_DAEMON, closes #2230):

  • ✅ Cross-launch regression — attach path decides the supervisor link ONLY from the daemon's durable owner record (shouldLinkOnAttach(owner) === owner === "app"), never the current process env. Both attach paths verified.
  • ✅ fd leak — keepDaemonLogFd closed on the success path, spawn-throw path, and guarded when openSync fails. Child retains its inherited copy.
  • ✅ truthy parsing — explicit allowlist (1/true/yes/on); off/no/unrecognized → default self-stop. 26 tests.
  • ✅ stdio lifetime + default-preserved (flag unset → byte-for-byte unchanged).

Backend layer (per-role env profile, closes #2195):

  • ✅ Env-merge mutation leak — mergeEnv deep-copies at two layers; never written back to project config; regression test pins it.
  • ✅ MCP pointer + PluginDirs slice defensive-copied (not aliased) in effectiveAgentConfig.
  • ✅ MCP Strict-alone = valid isolation; merge correctness per field; nil/empty inherits base.
  • ✅ Restore parity — MCP/plugin flags + cfg.Config.Validate() symmetric in BOTH launch and restore; --resume rebuilds from flags.
Verdict Reviewer Finding Disposition
approve codex No ship-blocking defect; all target areas have explicit coverage
approve claude (desktop) All 5 maintainer-flagged areas airtight
informational claude (desktop) stdio type annotation slightly narrow (cosmetic) no action
pre-existing claude (desktop) TOCTOU note on port-attach path (documented, not widened by this PR) no action
approve claude (backend) All 8 scrutiny areas pass; build/vet/tests green
follow-up claude (backend) CLI lacks --worker-system-prompt/--worker-env flags (reachable via --config-json) tracked follow-up, not in scope
minor (efficiency) claude (backend) buildSystemPrompt second loadProject call (redundant DB round-trip) deferred — needs a signature refactor, out of cleanup scope

No FIX verdicts → this is the final cycle. Local mode does not auto-merge; ready for maintainer review.

@axisrow
axisrow force-pushed the feat/desktop-keep-daemon-and-per-role-env branch from 5789f25 to ded1bbe Compare July 12, 2026 09:22
Comment thread frontend/src/main.ts Outdated
let keepDaemonLogFd: number | undefined;
const stdio: "pipe" | "ignore" | ["ignore", number, number] = keep
? (() => {
const logPath = path.join(os.homedir(), ".ao", "daemon.log");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ignores the selected daemon state root. For example, with AO_RUN_FILE=/tmp/ao-a/running.json AO_KEEP_DAEMON=1, persistent-mode logs still append to ~/.ao/daemon.log; a dev and an installed daemon also share that file. Derive the log path from dirname(runFilePath()) (and create the directory) so isolated runs do not mix or write state in the wrong location.


// isPluginURL reports whether s is an http(s) plugin URL rather than a local
// path, deciding --plugin-url vs --plugin-dir.
func isPluginURL(s string) bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL schemes are case-insensitive, but this only recognizes lowercase prefixes. A valid HTTPS://example.com/plugin.zip value is emitted as --plugin-dir instead of --plugin-url. Parse the value as a URL and compare the scheme case-insensitively, with a regression test.

// AgentConfig so an empty (default) config stays a zero value for storage and
// resolution, while a present-but-empty MCPConfig is meaningful: Strict on its
// own isolates a worker from every MCP source.
type MCPConfig struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new named contract type is missing its required schemaNames entry in backend/internal/httpd/apispec/specgen/build.go. The generated public API therefore exposes DomainMCPConfig rather than MCPConfig. Add the mapping and regenerate the OpenAPI spec and frontend types.

@somewherelostt

Copy link
Copy Markdown
Collaborator

Thanks for contributing to Agent Orchestrator.

This PR is being picked up by the current external contributor on-call pair:

If someone is already working on this, please continue as usual.
The on-call pair is added for visibility, tracking, and support, not to take over the work.
If you need help with review, direction, reproduction, or next steps, please tag @neversettle17-101 and @somewherelostt here.
You can also join the AO Discord if you want faster context, want to ask questions live, or just want to follow what the community is building.

You are also welcome to join the AO Discord community.

We do daily Discord calls at 10 PM IST, except Saturday.

Join the session here:
https://discord.gg/H6ZDcUXmq

Come by if you want to see what is being built, ask questions, or just hang around with the community.

Comment thread frontend/src/main.ts
// must be skipped — otherwise it would defeat the whole point on quit.
process.on("exit", () => {
if (daemonProcess && !supervisorLink?.connected) {
if (daemonProcess && !supervisorLink?.connected && !keepDaemonAlive(process.env)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still re-reads process.env at process exit, while the spawn path correctly captures const keep = keepDaemonAlive(process.env) as the daemon’s spawn-mode decision. If this Electron process starts a daemon with AO_KEEP_DAEMON=1 and process.env.AO_KEEP_DAEMON is later changed or unset before exit, the daemon was spawned unlinked/persistent but this fallback cleanup will kill it. Please persist the spawn-time keep-alive/owner state alongside daemonProcess and use that here, clearing it when the child exits or is explicitly stopped.

… profile (claude-code)

Two related capabilities, rebased clean onto current main.

1. AO_KEEP_DAEMON (desktop): keep the daemon alive after the Electron app
   closes. The app records a "persistent" owner in the run-file; on exit it
   redirects the daemon stdio to a log fd (allowlisted AO_KEEP_DAEMON env)
   so the process survives the parent exiting. The keep decision is
   persisted in the owner record and reused on relaunch.

2. Per-role worker environment profile (claude-code): AgentConfig gains
   SystemPrompt / Env / MCP / PluginDirs fields surfaced through the CLI
   (--worker-mcp-config, --worker-strict-mcp, --worker-plugin-dir) and the
   OpenAPI schema. effectiveAgentConfig deep-copies the project Env
   (mergeEnv) so per-role overrides never mutate the project config; the
   worker role prompt is rendered before the standing guard.

Closes AgentWrapper#2195, Closes AgentWrapper#2230

Co-Authored-By: Claude <noreply@anthropic.com>
@axisrow
axisrow force-pushed the feat/desktop-keep-daemon-and-per-role-env branch from f23d057 to 2eef759 Compare July 19, 2026 01:25
@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi @neversettle17-101 @somewherelostt — this is the last open PR from the per-role env + keep-daemon work (#2195 + #2230). I just rebased it clean onto current main (single commit, no conflicts): it had drifted behind the recent session-presentation / native-restore PRs and was showing as conflicting, now MERGEABLE. CI is fully green (10/10).

What it adds:

Would appreciate a review when you have a moment — happy to address anything. Thanks!

Local review (Claude subagent + Codex companion), cycle 1, both approve
with no critical findings. Three non-blocking cleanups applied:

- Reuse the spawn-path `agentConfig` for `.Env` instead of recomputing
  effectiveAgentConfig a second time (manager.go) — matches the restore
  path, which already reuses it.
- Deep-copy `merged.Env` via mergeEnv whenever either the project base or
  the role override carries Env, so a role override can never alias/mutate
  the project's base Env. Guards both inputs empty so a project with no
  config still resolves to a zero AgentConfig (defense-in-depth, matches
  the MCP pointer copy).
- Clarify the AO_KEEP_DAEMON comment in main.ts to note the process.on
  exit handler re-reads process.env because the spawn-captured `keep` is
  function-scoped (AO_KEEP_DAEMON is set once at startup, never mutated).

Deferred (out of scope for cleanup): daemon.log rotation, role-Env in the
workspace-cleanup hook path — no current consumer; follow-ups.

Co-Authored-By: Claude <noreply@anthropic.com>
@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Local review (cycle 1)

Reviewed locally (Claude subagent + Codex companion), no bots pinged. Critical-only bar.

Verdict Reviewer Finding Location
approve codex no critical findings — fd-leak, allowlist, cross-launch persistence, config-mutation, deep-copy, restore-parity all addressed
approve claude no critical findings — all 9 high-risk areas pass verification (env-merge deep-copy, MCP/Plugin copy, restore parity, fd close on both paths, allowlist, durable owner record, nil/empty inherit, reflect.DeepEqual, no race)

No FIX (critical) findings from either reviewer. Three non-blocking cleanups applied in 340e62be:

# Cleanup Applied
M1 Reuse spawn-path agentConfig.Env instead of recomputing effectiveAgentConfig (matches restore path) ✅ manager.go
M2 Deep-copy merged.Env whenever either base or override carries Env, so a role override can never alias the project Env (guards both-empty → zero config still holds; matches MCP copy) ✅ manager.go
M3 Clarify AO_KEEP_DAEMON comment re: exit handler reading process.env live (flag is function-scoped; set once, never mutated) ✅ main.ts

Deferred (out of scope, no current consumer): daemon.log rotation; role-Env in the workspace-cleanup hook path.

Gates green: gofmt clean, go build ok, session_manager/domain/cli/runfile/claudecode tests pass, 26 daemon-owner tests pass.

The stdio-selection was an IIFE inside a ternary solely to get a block
scope, but the openSync side effect escaped to the outer keepDaemonLogFd
anyway — the closure bought nothing over a normal if. Linear statements
read more directly than re-running a ternary-IIFE mentally. No behavior
change; 26 daemon-owner tests still pass, tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>

@illegalcall illegalcall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes after a first-principles review of a2d4ab6b...21ce0a03c.

The blocking findings are attached inline. Four existing maintainer threads also remain applicable and unresolved, so I am referencing rather than duplicating them:

  • persistent log output ignores the AO_RUN_FILE state root: #2621 (comment)
  • URL schemes are matched case-sensitively: #2621 (comment)
  • MCPConfig lacks the required schemaNames mapping and leaks as DomainMCPConfig: #2621 (comment)
  • exit cleanup re-reads process.env rather than retaining the spawn-time mode: #2621 (comment)

PR-wide standards issue: the PR explicitly combines two independent issues, contrary to the repository's “one issue per PR” and focused-change rules.

Non-blocking standards notes from the review: daemon ownership remains raw string state across Go/TS; up is an unclear prompt variable; and several one-call helpers/duplicated append-cleanup shapes conflict with the repository's surgical-change guidance.

Verification performed locally: fresh touched Go package tests, go build ./..., go vet ./..., golangci-lint, API regeneration, frontend typecheck, 26 focused daemon-owner tests, and the complete frontend suite (723 tests) passed. The full Go and race suites both hit the same timeout in unchanged kilocode.TestAuthStatusUnknownWhenKeyOnlyComesFromInteractiveShell; that test passed 3/3 in isolation.

Comment thread frontend/src/main.ts
// (owner "app"). The keep decision is durable: a daemon spawned keep-alive
// is owner "persistent" and stays unlinked even when the app reopens
// without AO_KEEP_DAEMON, so closing it does not stop the persistent daemon.
if (shouldLinkOnAttach(existing.owner)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] AO_KEEP_DAEMON is silently ignored when an app-owned daemon is already alive. A common transition is: quit the normal desktop app, immediately relaunch it with AO_KEEP_DAEMON=1, and hit the daemon's ~5s shutdown grace window. The run file still says owner:"app", so this branch reconnects the supervisor and the next quit still stops the daemon. The option therefore depends on timing or a prior manual ao stop.

Please handle the app -> persistent transition explicitly (for example, restart/adopt with a durable mode change) while preserving the intentional persistent -> unset behavior, and cover both directions in tests.

// MCPConfig emits nothing, so an unset config inherits the global MCP set as
// before. Strict alone (empty Configs) is valid: it means "no MCP at all".
func appendMCPFlags(cmd *[]string, mcp *domain.MCPConfig) {
if mcp == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This pins the opposite of issue #2195's priority-one MCP behavior. The issue says workers should default to no MCP and opt in explicitly, but a nil config returns without --strict-mcp-config, so every globally configured MCP server remains available. The new ‘unset inherits global’ test makes that mismatch deliberate.

The issue also contains broader compatibility wording, so please resolve the acceptance semantics explicitly. If least privilege is still the requirement, workers need a strict-empty default plus an explicit way to request inheritance.

// http(s):// entry maps to --plugin-url (a fetched zip); any other value is
// treated as a local path and mapped to --plugin-dir. Both flags are repeatable,
// so one is emitted per entry. Empty/whitespace entries are skipped.
func appendPluginFlags(cmd *[]string, dirs []string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] These flags add plugins; they do not scope the set a worker can load. User/project/global plugins and standalone skills remain active, so a configured worker still carries the orchestrator's globally enabled capabilities. That leaves the central least-privilege requirement from #2195 unimplemented.

Anthropic documents --plugin-dir / --plugin-url as session plugin additions, independently of installed user/project/local scopes: https://code.claude.com/docs/en/plugins-reference

Please add an isolation mechanism for other plugin/skill sources, or narrow the feature and PR claims so they do not promise plugin scoping.

Comment thread frontend/src/main.ts
}
keepDaemonLogFd = undefined;
}
if (keep) child.unref();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Persistent mode can attach a new desktop build to an old daemon after auto-update. quitAndInstallUpdate() exits and relaunches Electron, while this unref plus the exit guard intentionally preserves the old bundled daemon. On relaunch, bundled identity checks compare the executable path—not a build/API version—so the new generated frontend client can attach to the previous backend until the user discovers they must run ao stop.

Please add daemon/frontend version negotiation or explicitly stop/restart an app-spawned persistent daemon during the update flow.

// second instance would kill the supposedly-persistent daemon. The decision
// is read only from the daemon's record, never from the current process env.
it("does NOT re-link a persistent daemon on attach, even when AO_KEEP_DAEMON is unset now", () => {
expect(shouldLinkOnAttach("persistent")).toBe(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the important persistent -> unset relaunch, but not the reverse app -> AO_KEEP_DAEMON=1 transition. The current attach path still calls shouldLinkOnAttach("app") === true, so enabling the flag during the existing daemon's grace window has no effect. Please add a test at the attach-decision boundary, not only predicate tests.

// TestBuildProjectConfigWorkerProfileFlags: the worker MCP/plugin flags are
// assembled into the per-role AgentConfig that the daemon validates and
// claude-code maps to --mcp-config / --strict-mcp-config / --plugin-dir[-url].
func TestBuildProjectConfigWorkerProfileFlags(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses Cobra and only tests the options-to-struct helper. The repository requires command tests for CLI changes; please add an executeCLI/HTTP-capture case that parses the three new flags and verifies the emitted JSON wire shape. That would catch flag registration, repeatability, validation/usage exit behavior, and client/daemon DTO drift.

@axisrow

axisrow commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Closing this combined PR in favor of the split, per @illegalcall's one-issue-per-PR feedback (AGENTS.md: "Keep one issue per PR. If asked for separate work, create a separate branch and PR.").

The two halves now live as their own focused PRs:

Splitting rather than force-pushing this branch so the inline review comments here keep their line context. Not merging — just dropping the combined scope. Thanks for the thorough review; the findings all landed in the split PRs above.

@axisrow axisrow closed this Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants