Skip to content

Add opt-in external control for agents: vibeyard-ctl (closes #168) - #169

Open
amirseg2 wants to merge 6 commits into
elirantutia:mainfrom
amirseg2:feat/external-control
Open

amirseg2 wants to merge 6 commits into
elirantutia:mainfrom
amirseg2:feat/external-control

Conversation

@amirseg2

@amirseg2 amirseg2 commented Oct 7, 2026

Copy link
Copy Markdown

Summary

This implements the external control proposed in #168. It lets an agent running inside a Vibeyard tab open, rename, list and close tabs, with no UI automation:

$VIBEYARD_CTL new-session --name "fix-login-bug" --prompt-file task.md   # → {"ok":true,"result":{"sessionId":"…"}}
$VIBEYARD_CTL rename <sessionId> "fix-login-bug (done)"
$VIBEYARD_CTL list --mine
$VIBEYARD_CTL close <sessionId>

It is off by default: Preferences → General → "Allow tabs to control tabs (vibeyard-ctl)".

What each command does

  • new-session behaves like the New Session dialog.
    • The tab opens in the background and keeps your focus. --focus switches to it.
    • The prompt is passed as a startup argument, so nothing is typed into the terminal.
    • For Claude, --name is also passed to claude --name, so the worker appears under that name in Claude Code's own session list and cross-session messaging.
    • Args and env default to the project's saved defaults.
    • An explicit --profile must belong to the provider that will actually run. It never silently falls back to the default account.
  • rename sticks the way a UI rename does. Renaming a tab to its current name is a no-op.
  • close goes through the existing confirmation path. A working tab shows the usual "still active" dialog, and the command returns awaiting_confirmation.
  • list returns each tab's id, name, status (not_started / idle / working / input / exited), owner, creation time and cost.

How it works

It reuses the existing hook-status.ts pattern.

  1. On every launch the app installs a small Python client, ~/.vibeyard/run/bin/vibeyard-ctl (with a .cmd wrapper on Windows), the same way it installs the statusline script.
  2. Each agent tab gets the client's path in VIBEYARD_CTL. That folder is also prepended to PATH.
  3. The client drops a JSON request into ~/.vibeyard/commands/.
  4. The main process picks it up with fs.watch plus a polling fallback, validates it, and passes it to the renderer over IPC.
  5. The renderer runs it through existing functions: addSession / renameSession / closeSessionWithConfirm.
  6. The result is written back for the client to print.

vibeyard ctl … in the npm launcher just forwards to the installed client.

Keeping it safe

  • Off by default. While it's off, the command server doesn't run and the client returns disabled.
  • Callers must be inside a tab. Each agent process gets a random per-process VIBEYARD_CTL_TOKEN. It lives only in memory, is replaced on every spawn, and is revoked when the process exits or dies. A request without a valid token gets unauthorized. This guards against scripts running outside Vibeyard. It is not a security boundary between processes of the same user: anything a tab runs inherits that tab's token. The docs say so.
  • Ownership. A tab can rename or close only itself and the tabs it created. createdBy is persisted, so this survives restarts.
  • File handling. The commands folder must have mode 0700, be owned by the user, and not be a symlink. Requests are opened with O_NOFOLLOW | O_NONBLOCK (where the platform has them), checked through fstat on the open file, and read with a byte limit. Results are written to a random temp name with O_EXCL, then renamed. No shell is involved anywhere: args reach node-pty as an argv array.

Test plan

  • npm run build passes.
  • npm test passes: 152 files, 2093 tests, on Node 24 and Node 23 (macOS).
  • Typecheck: the main and preload configs report 0 errors. The renderer config has the same pre-existing error count as main; this PR adds none.
  • Manually tested in the app on macOS:
    • list;
    • new-session in the background, with the prompt delivered;
    • --name shows up in Claude Code's session list, and a message sent by that name gets a reply;
    • rename is sticky, including non-ASCII names;
    • close of an idle tab closes it immediately;
    • close of a busy tab shows the dialog, and Cancel keeps the tab;
    • not_owner when closing or renaming someone else's tab;
    • unauthorized with a missing or forged token;
    • disabled when the preference is off.
  • Manually tested on Windows 11 ARM64 (Parallels):
    • the same flow;
    • --prompt-file delivers %, ^, !, ", & and CRLF byte-for-byte;
    • the .cmd wrapper returns the right JSON and exit codes from cmd.exe, PowerShell and Git Bash.
  • Linux: not tested manually. CI covers the unit tests.

Notes

  • One existing test changed intent slightly: pty-manager.test.ts, "lets user env vars override provider-set vars". A user PATH still wins over the provider's, but the client folder is now prepended in front of it. That's how agents find vibeyard-ctl without an absolute path. Agents are documented to use $VIBEYARD_CTL, which never depends on PATH.
  • Windows limitation (documented): the .cmd wrapper runs through cmd.exe, which cuts an inline --prompt at the first newline and expands %VAR%. --prompt-file is lossless, and the docs and --help recommend it on Windows.
  • Pre-existing, not from this PR: on a Windows machine where python3 is only the Microsoft Store stub, hook-status.test.ts (the buildStatusLinePython tests) fails with exit 9009. The same happens on main. The new client tests use pythonBin from platform.ts, the same command the app runs, so they work there.
  • Not in this PR (possible follow-ups):
    • typing into a running tab (send);
    • focusing a tab;
    • waking a tab that hasn't started since restart (it reports not_started);
    • callers from outside Vibeyard.
  • Docs: a new "External control (vibeyard-ctl)" section in CLAUDE.md, a README section, and ctl added to the preload namespace list.
  • History: the history is 6 commits. Squash-merge is fine by me.

Thanks for considering it! Happy to adjust anything: naming, the file-drop transport, or scope.

🤖 Generated with Claude Code

amirseg2 and others added 6 commits October 7, 2026 11:20
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant