Skip to content

fix(packaged): keep agent daemon-CLI invocations out of the single-instance gate - #7388

Open
lorenzozanee wants to merge 1 commit into
nexu-io:mainfrom
lorenzozanee:fix/agent-cli-single-instance-restart
Open

fix(packaged): keep agent daemon-CLI invocations out of the single-instance gate#7388
lorenzozanee wants to merge 1 commit into
nexu-io:mainfrom
lorenzozanee:fix/agent-cli-single-instance-restart

Conversation

@lorenzozanee

Copy link
Copy Markdown
Contributor

Fixes #7154

Why

The built-in agent runs live-artifact tool calls by exec'ing the packaged binary itself ($env:OD_BIN tools …, export …). Those launches booted the packaged Electron entry and fell into inspectExistingDesktopForLauncher() after the --headless fast path. When the running desktop reported windowVisible: false, the gate classified it as a headless owner and killed + restarted it (action=restart reason=headless-owner) — each artifact/export call could restart the app mid-use; with the window visible it merely focus-flashed. On 0.19.2 the same restart path escalated into an EPIPE hang. Root cause: nothing distinguishes argv-shaped daemon-CLI invocations from a user double-launch at the entry.

What users will see

Agent live-artifact register/verify/export calls no longer restart or focus-flash the running desktop. The invocations still execute against the daemon; GUI launches, deeplinks, file-association opens, and updater/launcher handoffs behave exactly as before.

Surface area

  • Default behavior change — packaged entries whose argv[1] is the configured daemon CLI entry (basename fallback daemon-cli.mjs / cli.js) re-spawn as Node (ELECTRON_RUN_AS_NODE=1) instead of entering the launcher gate; environment alone never routes

Screenshots

Not applicable — packaged process routing only, no UI.

Bug fix verification

  • Predicate matrix apps/packaged/tests/agent-tool-invocation.test.ts (13 cases): red on base (module absent), green on branch.
  • Codex env boundary apps/daemon/tests/runtimes/codex-resume-args.test.ts (ELECTRON_RUN_AS_NODE in compiled include_only): red on base, green on branch.
  • Gate focus branch apps/packaged/tests/launcher-after-quit.test.ts (windowVisible:true[STATUS×3, SHOW], SHUTDOWN provably absent): coverage hardening; passed on base too, not claimed as regression proof.
  • Reviewer note: the two glue invariants have no unit seam (index.ts imports electron, unrunnable under node vitest) — please eyeball that the guard sits between the headless fast path and inspectExistingDesktopForLauncher (index.ts:133 vs :169) and that the child env sets ELECTRON_RUN_AS_NODE: "1". Known residual edges, accepted: a file literally named daemon-cli.mjs passed to the desktop exe routes as CLI when no entry path is baked into the build config, and --headless invocations keep taking the existing fast path before this guard.

Validation

  • pnpm --filter @open-design/packaged exec vitest run — 23 files / 336 tests pass
  • Scoped reruns: packaged launcher+predicate suites 30/30; daemon codex-resume-args 9/9
  • pnpm guard clean

…stance gate

Agent tool calls that exec the packaged binary ($env:OD_BIN tools ...,
export ...) entered main() like any GUI launch: past the --headless fast
path nothing distinguished them, so they ran
inspectExistingDesktopForLauncher() and, whenever the running desktop
reported windowVisible:false, killed and restarted it as a headless
owner. Each agent artifact/export call could therefore restart the app
the user was using — restarts landed within ~0.2s of every OD_BIN call,
and on 0.19.2 the same path escalated into an EPIPE hang.

Classify these invocations at the packaged entry instead: when argv[1]
is the configured daemon CLI entry (or matches its bundled basename,
daemon-cli.mjs/cli.js), re-spawn process.execPath as Node with
ELECTRON_RUN_AS_NODE=1, inherited stdio, and the child exit code, so the
daemon CLI runs without touching the launcher gate. Environment alone
never routes: agents legitimately launching the desktop inherit
run-scoped OD_TOOL_TOKEN/ELECTRON_RUN_AS_NODE, so neither may decide.

Also pass ELECTRON_RUN_AS_NODE through the Codex shell-environment
include list next to OD_NODE_BIN, so node-mode children survive that
boundary too.
@lorenzozanee
lorenzozanee requested a review from a team as a code owner August 25, 2026 10:38
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks @lorenzozanee — the regression write-up is very clear, and the split between the packaged launcher gate and the daemon-CLI respawn path is easy to follow.

I’ve queued the reviewer routing and PR checks on our side.

@lefarcen
lefarcen requested a review from nettee August 25, 2026 10:42
@lefarcen lefarcen added size/M PR changes 100-300 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix labels Aug 25, 2026
@lefarcen lefarcen added the needs-validation Runtime change detected; needs human or /explore agent validation. label Aug 25, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

🧪 Queued for QA validation — this PR changes a user-facing runtime path, so we’re marking it for a manual QA pass before merge. Nothing needed from you right now; we’ll update here once it reaches the validation step. Thanks for the contribution! 🙏

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lorenzozanee, I reviewed all six changed files and the scoped tests. The argv-only predicate and Codex environment allowlist are thoughtfully covered, but the new subprocess boundary has two correctness issues in packaged runtime lifecycle (macOS helper selection and signal exit propagation), so I am requesting changes before merge.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// single-instance gate. Re-spawn in Electron-as-Node mode (same pattern as
// the packaged sidecar spawn env) with inherited stdio and exit code.
if (isAgentToolInvocation(process.argv, { daemonCliEntry: config.daemonCliEntry })) {
const child = spawn(process.execPath, process.argv.slice(1), {

@nettee nettee Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Use the packaged Electron-as-Node command resolver here instead of process.execPath directly. apps/packaged/src/sidecars.ts already exports resolvePackagedElectronNodeCommand() and deliberately selects the hidden App Helper executable on macOS (the existing Dock-identity fix) before setting ELECTRON_RUN_AS_NODE. This new path bypasses that contract, so macOS agent calls can start the main app executable and recreate the Dock/GUI identity or focus side effect that this PR is intended to remove. Await that resolver (or extract a shared command helper) before spawning, and add a macOS helper-selection assertion for this agent path.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

stdio: "inherit",
windowsHide: true,
});
child.on("exit", (code) => app.exit(code ?? 0));

@nettee nettee Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Preserve a non-zero result for signal-terminated or failed child processes. Node reports code=null when the CLI child is killed by SIGINT or SIGTERM, but code ?? 0 maps that cancellation to success, so an interrupted agent tools call can be reported as a successful invocation. Use the close event (or inspect signal) and map code === null or spawn errors to a non-zero exit while preserving numeric codes.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Hey @lorenzozanee — following up here: the current blockers are already covered in @nettee's review/comments on this head, specifically the macOS helper-command path and the exit/signal propagation on the respawned child process. Once those are addressed, a re-review on the same two points should unblock the PR.

💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …):
Take over nexu-io/open-design#7388 until it merges — read https://raw.githubusercontent.com/nexu-io/looper/main/skills/pr-takeover/SKILL.md and follow it.

@lefarcen

Copy link
Copy Markdown
Contributor

Heads-up: PR #7494 is also open against #7154. Both PRs touch apps/packaged/src/index.ts and aim to stop the packaged/headless path from colliding with the desktop runtime, so it’s worth comparing approaches while we decide which path lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/M PR changes 100-300 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Agent tool calls to packaged CLI (\$env:OD_BIN\ tools live-artifacts / export) trigger desktop single-instance 'headless-owner' restart

3 participants