Skip to content

Plugin processes: long-lived host.spawn for plugins - #731

Merged
baxen merged 4 commits into
mainfrom
honey/plugin-processes
Oct 9, 2026
Merged

baxen merged 4 commits into
mainfrom
honey/plugin-processes

Conversation

@baxen

@baxen baxen commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Lets a plugin start a long-lived process it declares in its manifest, talk to it over stdin/stdout, and optionally run it as one of its own Agents2 agents. This is the plugin-layer half of running Claude Code agents on Agents2. The Claude Code plugin that uses it is the stacked PR on top.

Stacked on #693 (murderbot/agents2).

How it works

  • Declared up front. A manifest lists processes under host.processes: { id, program, args? }. The plugin import preview shows them as "full user access", like host commands. At most 16 per manifest.
  • ctx.host.spawn(id, options) starts one. Options are extra args, cwd (~/ expands, created if missing), env (a null value removes a variable), agent, and onStdout / onStderr callbacks. It returns { write, end, kill, exited }. Output streams over a Tauri Channel as UTF-8 text, and a character split across reads is kept whole.
  • Running as an agent. With agent: <pubkey>, native checks that the agent's type belongs to the calling plugin. It then puts the agent's key, relay URL and auth tag in the environment (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, BUZZ_RELAY_URL, BUZZ_AUTH_TAG) and the bundled buzz CLI first on PATH. So the process can use buzz exactly as a harness agent does, and the key never passes through the webview. Every process starts with inherited BUZZ_* / NOSTR_* variables removed, so it never gets the app's own identity.
  • Lifecycle.
    • Each process runs in its own process group. Kill sends SIGTERM to the group, then SIGKILL after 3 s. Descendants are always reaped after exit.
    • A process stops when the plugin that started it unloads, when the main webview reloads, and when its output channel is gone.
    • On app exit every group is SIGKILLed synchronously, because the async tasks never run again after RunEvent::Exit.
    • A spawn that was still in flight when the page reloaded or the plugin unloaded is refused or killed, never left orphaned.
    • A process that closes stdout and stderr but keeps running is not killed for it.
    • Only the plugin that started a process can write to it or kill it. There are at most 64 at once.
  • macOS PATH. ~/.local/bin is now on the effective path, which is where the official Claude Code installer puts claude. This also applies to host commands.

FOUNDATION files (edited per baxen's direction; please review closely)

  • src/plugins/api.ts: PluginManifest.host.processes.

Checks (at 825103f9)

  • cargo test -p buzz-foundation passes (338). A few host_command timing tests flake when the machine is busy and pass on rerun; this PR doesn't touch them. cargo test -p buzzodz-plugins passes. Clippy is clean.
  • New native tests in src-tauri/src/host_process/tests.rs:
    • stdin to stdout until close;
    • stderr and exit code;
    • only the owner can write or kill;
    • kill reaches a child that traps TERM;
    • stop_all;
    • a dropped channel stops the process;
    • split UTF-8;
    • ~ expansion;
    • a spawn from before a reload is refused;
    • a process may close its output and keep running;
    • shutdown kills a TERM-ignoring group at once.
  • src/features/host/service.test.ts: spawn streams output, a plugin unload kills its processes, a process that finishes starting after its plugin unloaded is killed, and the browser refuses.
  • pnpm check: typecheck and design checks pass. Biome fails only on three pre-existing noNonNullAssertion warnings in src/features/agents2/service.test.ts from feat(agents2): plugin agent types on a new Agents2 page #693.

Not done

  • No sandbox: a declared process has full user access, and an agent process holds a key that can sign any kind. Agents2's kind allowlist applies only to publish from the webview.
  • Windows is built for (CREATE_NO_WINDOW, no process groups) but untested.

🤖 Generated with Claude Code

@baxen
baxen marked this pull request as ready for review October 8, 2026 20:58
@baxen
baxen requested a review from a team as a October 8, 2026 20:58
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T21:02:51.466551Z 825103f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 825103f9f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/host_process.rs Outdated
Comment on lines +334 to +337
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Contain Windows plugin process trees in a job

When a declared process launches a helper on Windows, this only applies CREATE_NO_WINDOW; the stop path consequently calls start_kill() on the direct child, unlike the existing host_command::windows_job implementation. A helper can therefore survive kill(), plugin unload, page reload, or app exit—potentially retaining inherited agent credentials—and inherited stdout/stderr handles can also prevent exited from resolving. Spawn the child suspended inside a kill-on-close job and retain/terminate that job for every lifecycle cleanup path.

AGENTS.md reference: AGENTS.md:L89-L91

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in dbca1413: on Windows the process now starts suspended inside a kill-on-close job (reusing host_command::windows_job), and kill, unload, reload and natural exit all terminate the job, so helpers go with it. App exit is covered by kill-on-close when the app's job handle closes. Verified on the Windows runner via a dispatched CI run.

@wesbillman wesbillman 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.

Two changes needed:

  • Reuse the existing Windows job owner for descendant/exit cleanup; this agrees with the existing Windows finding, so I am not duplicating that inline.
  • Contain output callback exceptions so they cannot strand exited (new inline finding).

Star Lord’s automated source review via Wes’s account. Head 825103f9f7369a827d95b7ef9205f8d491041e78; base 7cda820b79e57f7a828d265c0be8271836e4939a. Source-only; no tests/app execution. One CI snapshot: JavaScript shards/required CI failing, Windows skipped; causes not investigated.

Comment on lines +158 to +162
const onEvent = new Channel<ProcessEvent>((event) => {
if (event.type === "exit") exit(event.code);
else if (event.type === "stdout") onStdout?.(event.data);
else onStderr?.(event.data);
});

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.

[P2] Contain output callback exceptions inside the channel handler

A thrown onStdout/onStderr callback does more than lose one chunk: the pinned Tauri API 2.11.1 advances its channel sequence index only after onmessage returns. An exception here leaves that index stuck, so subsequent output and the final exit event are queued indefinitely. process.exited never settles, including after kill, leaving callers' completion/retry cleanup waiting. Catch and report plugin callback errors inside this handler so it always returns; cover a throwing output callback followed by an exit using the real channel sequencing rather than the current direct-callback mock.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in dbca1413: the channel handler now catches a throwing onStdout/onStderr and logs it, so it always returns and later output and exit still arrive. Added a test where the output handler throws and exited still settles. It drives the handler directly, not the real Tauri channel sequencing, but what it checks is that the handler never throws, and that is what keeps the sequence index moving.

@baxen
baxen force-pushed the murderbot/agents2 branch from 79efed3 to bb9a9cb Compare October 8, 2026 22:12
@baxen
baxen force-pushed the murderbot/agents2 branch 2 times, most recently from 275a556 to f0d5996 Compare October 8, 2026 23:32
Base automatically changed from murderbot/agents2 to main October 8, 2026 23:45
@baxen
baxen force-pushed the honey/plugin-processes branch from 825103f to dbca141 Compare October 8, 2026 23:54

@wesbillman wesbillman 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.

No further changes requested: Windows job ownership and the output-callback guard address both findings from the prior review.

Star Lord’s automated source follow-up via Wes’s account — head dbca1413cd292f3aa3171dcd7856c35354d80a4b, base 23ae0c09980c27179f4e5d4397fc7f02f4ef9212.

Source-only; no tests/app execution, and native lifecycle/real Tauri-channel behavior remains unverified. The Windows run stopped before tests on unchanged agent-controller lint; automatic CI had Rust/browser-measurement failures and unfinished jobs at inspection—not merge readiness.

@wesbillman wesbillman 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.

One P2 test regression: the shutdown assertion now allows asynchronous cleanup to satisfy a contract that must hold when the async runtime stops. The production fixes from the prior review are unchanged.

Star Lord’s automated source follow-up via Wes’s account — head 01715f44c995d6e491f04ab8471cb78c6fae7f12, base 23ae0c09980c27179f4e5d4397fc7f02f4ef9212. Source-only; no tests/app execution or mutation reproduction. Current-head CI was still running at inspection; native lifecycle behavior remains unverified.

}
};
processes.shutdown();
assert_dead(child).await;

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.

[P2] Preserve the synchronous-shutdown assertion

assert_dead can now wait five seconds, while shutdown() first calls stop_all(), whose background task sends TERM and escalates after STOP_GRACE (three seconds). Thus this test can pass even if the synchronous SIGKILL loop in shutdown() is removed: the still-running test runtime gets time to do the asynchronous cleanup instead. That misses the app-exit case this test names, where those tasks cannot run. Keep the zombie-aware death check, but make this assertion independent of the asynchronous stop path (for example, hold that cleanup behind a fixture gate), so removing the synchronous kill still fails the test.

Honey and others added 3 commits October 8, 2026 17:22
Plugins declare programs under `host.processes` and start them with
`ctx.host.spawn(id, { args, cwd, env, agent })`: stdin stays open, stdout and
stderr stream back over an IPC channel, and the process is killed with its
process group when the plugin kills it or unloads, the page reloads, or the
app exits.

`agent` runs the process as an Agents2 agent of the calling plugin's own
type: native puts its key, community and owner attestation in the
environment, as the harness does for buzz-acp, and puts the bundled agent
tools (the `buzz` CLI) on PATH. The key never enters the WebView.

The macOS search path also includes ~/.local/bin, where per-user installers
such as Claude Code's put their binaries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
…g output handlers

On Windows a plugin process now starts suspended inside a kill-on-close job,
like host commands, so stopping it also ends the helpers it started instead
of only the direct child.

A plugin's onStdout/onStderr that throws no longer stalls the IPC channel,
which would have held back later output and the exit event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
A killed descendant stays a zombie until its new parent reaps it and still
answers kill(0), which failed the descendant check on Linux CI. Wait for it
to die, and accept a zombie.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
@baxen
baxen force-pushed the honey/plugin-processes branch from 01715f4 to 86daa9d Compare October 9, 2026 00:23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>

@wesbillman wesbillman 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.

Changes still needed: the synchronous-shutdown test finding remains unchanged. The new Unix-only helper gates address Windows lint without introducing another source finding.

Star Lord’s automated source follow-up via Wes’s account — head b5c4fe5ce196732fe68795497cdf06e4a36b8955, base a5f6dc65cba7d615f3c057da18f25e4e1697991e. Source-only; no tests/app execution. At inspection, automatic CI was pending and Windows validation was running; native lifecycle behavior remains unverified.

@baxen
baxen merged commit 02c01c1 into main Oct 9, 2026
30 of 31 checks passed
@baxen
baxen deleted the honey/plugin-processes branch October 9, 2026 00:43
wpfleger96 pushed a commit that referenced this pull request Oct 9, 2026
…nsole

* origin/main: (25 commits)
  fix(agents): show restart-required status on agent cards (#780)
  test: prune redundant Vitest cases with named keepers (#800)
  fix(sidebar): remove custom sections without deleting channels (#710)
  fix(messages): keep readable names for mentions added while editing (#808)
  feat(channels): find open channels with #, ranked like the other pickers (#795)
  Claude Code agents on Agents2 (#732)
  test: remove tests that only restate config and source text (#805)
  fix(runtime): fast dev Goose profile and stable sources for just desktop (#768)
  fix(inbox): exclude channel-archived conversations (#757)
  fix(channels): manage sessions through ordinary channel actions (#794)
  fix(inbox): keep refresh status out of the conversation list (#758)
  fix(test): pin the Vitest locale so date and list tests pass outside en-US (#799)
  test(browser): wait for the header menu before dismissing it (#787)
  Buzz MCP tools, with native agent reads, uploads and memory (#791)
  feat(me): polish sharing and add the shared panel workspace (#793)
  fix(channels): keep composer docked while a cold channel loads (#786)
  fix: recover timestamp-rejected read-state snapshots (#785)
  feat(plugins): resolve overlapping panels and link renderers by order (#725)
  feat(me): personal conversations and session sharing baseline (#784)
  Plugin processes: long-lived host.spawn for plugins (#731)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants