Host Commands

Let the AI request native commands on your Windows or Linux machine through VisiDog CLI file hooks.

What to install and where to run it

  1. Install a supported Node.js LTS release on the host. The runner below uses only Node built-in modules; no npm installation is needed.
  2. Save the complete runner as visidog-host.mjs, preferably outside the project folder.
  3. Open VisiDog CLI, select the same local project folder through File System Access (FSA), and activate the hook icon. A working browser FileSystemObserver and Web Locks are required; OPFS projects cannot use this tool.
  4. In CLI Settings, check Host command shell. The initial suggestion is cmd.exe for a browser reporting Windows and /bin/sh for Linux or macOS. Enter the executable name or path used on the host and save. The browser cannot verify installed shells; an unknown platform leaves the field empty for you to fill in.
  5. Start the runner with the absolute project path, using the Windows or Linux command below. Wait for Watching … before requesting work from the AI.
  6. Keep the browser and runner open. In Act Mode, ask the AI to call execute_host_command. Its definition appears only while FSA hooks are active. Plan Mode refuses native commands.

The host needs the tools your commands use: Git, Python, compilers or package managers must be installed on that machine and reachable in the runner's environment. Commands run with the permissions and environment of the account running the watcher. Use your ordinary user account; the runner is not a shell sandbox.

AI tool callCLI writes requestHost runs commandHost writes receiptCLI returns result to AI
project/
└── .visidog/hooks/
    ├── commands/       req-UUID.json — written by VisiDog CLI
    ├── receipts/       res-UUID.json — written by the host
    │                   other JSON files belong to prompt admission
    └── host-state/     claim-UUID.json — durable claims used by this runner

The browser only relays files. The external runner observes commands/ through native filesystem events and executes the shell command. The browser's existing FileHooks observer receives the result in receipts/. Neither side needs a local HTTP server or periodic polling.

JSON request and receipt protocol

AI tool arguments

{ "command": "npm test", "cwd": ".", "timeout": 60000 }

command is required and cwd defaults to the project root (.). The AI does not choose a shell argument: VisiDog CLI adds it to the request from the saved Host command shell setting and tells the AI which shell to write commands for. timeout defaults to 60,000 ms and accepts an integer from 1 to 600,000 ms. It is a browser wait deadline, including queuing and receipt delivery, rather than a host process kill timer.

CLI → host: commands/req-UUID.json

{
  "version": 1,
  "id": "12345678-1234-4234-abcd-123456789abc",
  "projectId": "project-local-id",
  "sessionId": "session-local-id",
  "command": "npm test",
  "cwd": ".",
  "shell": "cmd.exe",
  "timeout": 60000,
  "createdAt": 1791288000000
}
FieldMeaning
versionProtocol version, currently 1.
idGenerated UUID; must match both filenames and the receipt's id. It differs from the AI tool-call ID.
projectId, sessionIdBrowser project and conversation identifiers. The runner's project path comes from its launch argument.
commandNon-empty shell source, up to 32,768 characters. The CLI does not expand or execute it.
cwdWorking directory, up to 4,096 characters. This example resolves it against the project and requires it to remain inside the project after resolving symbolic links.
shellExecutable name or path copied by the CLI from its saved Host command shell setting, up to 256 characters. Always supplied for CLI-generated requests; the AI cannot override it. PowerShell is also supported. The runner's OS-dependent fallback only applies to external requests that omit this field.
timeoutBrowser wait limit in milliseconds; the sample host does not use it to stop a process.
createdAtUnix timestamp in milliseconds. This runner uses it to reject a request whose browser deadline has already passed before execution begins.

Strings cannot contain a null character. Requests are UTF-8 JSON. The runner waits briefly after native events and leaves incomplete JSON for the next event.

Host → CLI: receipts/res-UUID.json

{
  "id": "12345678-1234-4234-abcd-123456789abc",
  "exitCode": 0,
  "stdout": "All tests passed\n",
  "stderr": ""
}

The four fields above are required. exitCode is an integer or null if the process could not start or terminated by signal. Add an error string for a host execution failure. A nonzero exit code can return captured output without an error field:

{ "id": "12345678-1234-4234-abcd-123456789abc", "exitCode": 2, "stdout": "", "stderr": "Test failed\n" }
{ "id": "12345678-1234-4234-abcd-123456789abc", "exitCode": null, "stdout": "", "stderr": "", "error": "Shell not found" }

Each line above is a separate example receipt. The CLI returns the receipt fields with ok computed as true only for exit code zero without a non-empty host error. Receipts must be at most 4 MiB. The runner keeps up to 128 KiB of bytes per output stream, continues draining the rest, and reports truncated.stdout / truncated.stderr when it drops output. It decodes outputs as UTF-8.

Publish the complete result by writing a temporary file in receipts/ and renaming it to res-UUID.json. The temporary filename is ignored by the CLI. Only events for a matching pending UUID can settle an AI call.

Complete host runner — Windows and Linux

Save this exact code as visidog-host.mjs. Both operating systems use the same file. It executes one command at a time, groups duplicate events, claims UUIDs durably before execution, and atomically publishes receipts. Files already present when the runner starts are left untouched; submit a new request after it prints Watching.

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';

if (!process.argv[2]) throw new Error('Usage: node visidog-host.mjs ABSOLUTE_PROJECT_PATH');
if (!path.isAbsolute(process.argv[2])) throw new Error('Use an absolute project path.');
const project = fs.realpathSync(process.argv[2]);
const hooks = path.join(project, '.visidog', 'hooks');
const commands = path.join(hooks, 'commands');
const receipts = path.join(hooks, 'receipts');
const state = path.join(hooks, 'host-state');
for (const directory of [commands, receipts, state]) fs.mkdirSync(directory, { recursive: true });

const pattern = /^req-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i;
const baseline = new Set(fs.readdirSync(commands));
const outputLimit = 128 * 1024;
let queue = Promise.resolve();
let timer;
let closing = false;

function validText(value, maximum) {
  return typeof value === 'string' && value.trim().length > 0
    && value.length <= maximum && !value.includes('\0');
}

function validate(request, id) {
  if (!request || request.version !== 1 || request.id !== id
    || !validText(request.command, 32768) || !validText(request.cwd, 4096)
    || (request.shell !== undefined && !validText(request.shell, 256))
    || !Number.isInteger(request.timeout) || request.timeout < 1 || request.timeout > 600000
    || !Number.isSafeInteger(request.createdAt) || request.createdAt < 0
    || !validText(request.projectId, 256) || !validText(request.sessionId, 256)) {
    throw new Error('Invalid version-1 command request.');
  }
}

async function workingDirectory(request) {
  const cwd = await fsp.realpath(path.resolve(project, request.cwd));
  const relative = path.relative(project, cwd);
  if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
    throw new Error('cwd must resolve inside the selected project.');
  }
  if (!(await fsp.stat(cwd)).isDirectory()) throw new Error('cwd is not a directory.');
  return cwd;
}

async function publish(id, result) {
  const destination = path.join(receipts, `res-${id}.json`);
  const temporary = path.join(receipts, `.res-${id}-${randomUUID()}.tmp`);
  const json = JSON.stringify({ ...result, id }, null, 2) + '\n';
  if (Buffer.byteLength(json) > 4 * 1024 * 1024) throw new Error('Receipt exceeds 4 MiB.');
  await fsp.writeFile(temporary, json, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
  await fsp.rename(temporary, destination);
}

function execute(request, cwd) {
  return new Promise(resolve => {
    const shell = request.shell || (process.platform === 'win32'
      ? process.env.ComSpec || 'cmd.exe' : '/bin/sh');
    const base = path.basename(shell).toLowerCase();
    const options = { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] };
    const child = ['pwsh', 'pwsh.exe', 'powershell', 'powershell.exe'].includes(base)
      ? spawn(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', request.command], options)
      : spawn(request.command, { ...options, shell });
    const streams = {
      stdout: { chunks: [], bytes: 0, truncated: false },
      stderr: { chunks: [], bytes: 0, truncated: false }
    };
    for (const name of ['stdout', 'stderr']) {
      child[name].on('data', chunk => {
        const output = streams[name];
        const room = Math.max(0, outputLimit - output.bytes);
        if (chunk.length > room) output.truncated = true;
        if (room) {
          const kept = chunk.subarray(0, room);
          output.chunks.push(kept);
          output.bytes += kept.length;
        }
      });
    }
    let launchError;
    child.on('error', error => { launchError = error.message; });
    child.on('close', (exitCode, signal) => {
      const result = {
        exitCode,
        stdout: Buffer.concat(streams.stdout.chunks).toString('utf8'),
        stderr: Buffer.concat(streams.stderr.chunks).toString('utf8'),
        truncated: { stdout: streams.stdout.truncated, stderr: streams.stderr.truncated }
      };
      if (launchError || signal) result.error = launchError || `Process ended by signal ${signal}`;
      resolve(result);
    });
  });
}

async function consume(name) {
  const match = pattern.exec(name);
  if (!match || baseline.has(name) || closing) return;
  const id = match[1];
  const requestPath = path.join(commands, name);
  let request;
  try {
    if ((await fsp.stat(requestPath)).size > 256 * 1024) throw new Error('Request exceeds 256 KiB.');
    request = JSON.parse((await fsp.readFile(requestPath, 'utf8')).replace(/^\uFEFF/, ''));
  } catch (error) {
    if (error.code !== 'ENOENT') console.error(`${name}: ${error.message}; awaiting another event.`);
    return;
  }
  if (fs.existsSync(path.join(receipts, `res-${id}.json`))) return;
  // The exclusive claim is retained, including after a crash or receipt-write failure.
  let claim;
  try {
    claim = await fsp.open(path.join(state, `claim-${id}.json`), 'wx', 0o600);
  } catch (error) {
    if (error.code === 'EEXIST') return;
    throw error;
  }
  try {
    await claim.writeFile(JSON.stringify({ id, pid: process.pid, claimedAt: Date.now() }));
    await claim.sync();
  } finally {
    await claim.close();
  }
  let result;
  try {
    validate(request, id);
    const cwd = await workingDirectory(request);
    if (Date.now() >= request.createdAt + request.timeout) throw new Error('Request expired before host execution.');
    if (closing) throw new Error('Host watcher is stopping; command was not started.');
    console.log(`Running ${id} in ${cwd}`);
    result = await execute(request, cwd);
  } catch (error) {
    result = { exitCode: null, stdout: '', stderr: '', error: String(error.message).slice(0, 4096) };
  }
  await publish(id, result);
  console.log(`Receipt ${id}: exitCode=${result.exitCode}`);
}

async function scan() {
  // A scan is triggered by an event, never by a periodic timer.
  for (const name of (await fsp.readdir(commands)).sort()) {
    if (closing) break;
    await consume(name);
  }
}

const watcher = fs.watch(commands, () => {
  if (closing) return;
  clearTimeout(timer);
  timer = setTimeout(() => {
    queue = queue.then(scan).catch(error => console.error('Host watcher:', error.message));
  }, 150);
});
watcher.on('error', error => {
  console.error('Filesystem observation failed:', error.message);
  process.exitCode = 1;
  stop();
});

function stop() {
  if (closing) return;
  closing = true;
  clearTimeout(timer);
  watcher.close();
  console.log('Watcher stopped. Any running command may continue; waiting for its receipt.');
}
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
console.log(`Watching ${commands}`);
console.log(`Ignoring ${baseline.size} existing entries; publish a NEW request after this message.`);

The sample uses Node.js native filesystem watching and child-process execution. Event callbacks schedule a scan of the commands directory; duplicate notifications cannot repeat an already claimed UUID. A scan also handles notifications that do not supply a filename.

Windows setup

Download the runner into a tools folder. Open PowerShell as your regular user and use the path of the folder selected in VisiDog CLI:

node --version
$project = 'C:\Projects\my-app'
$runner = 'C:\Tools\visidog-host.mjs'
node $runner $project

If Node is not on PATH, invoke its installed executable directly:

& 'C:\Program Files\nodejs\node.exe' 'C:\Tools\visidog-host.mjs' 'C:\Projects\my-app'

For a Windows browser, CLI Settings initially suggests cmd.exe. Verify and save that value to use Windows command syntax, regardless of which terminal you use to start the runner:

{ "command": "ver && echo VisiDog host ready", "cwd": ".", "timeout": 60000 }

To use PowerShell, set Host command shell in CLI Settings to powershell.exe, or pwsh.exe if PowerShell 7 is installed. Save the setting before the next AI call; the CLI supplies it in the JSON request. The tool arguments remain:

{ "command": "Get-Location; Write-Output 'VisiDog host ready'", "cwd": ".", "timeout": 60000 }

Commands receive no interactive input. Windows tools may emit a legacy code page while this runner expects UTF-8; configure the invoked tool to emit UTF-8 when output contains non-ASCII characters. Restart the runner after installing a tool or changing PATH.

Optional: start at sign-in

After verifying the runner manually, create a Task Scheduler task for your user account with a logon trigger. Set Program/script to your Node executable and Add arguments to the quoted runner and project paths:

"C:\Tools\visidog-host.mjs" "C:\Projects\my-app"

Select “Run only when user is logged on” and leave “Run with highest privileges” unchecked. Keep one runner per project; its durable claims also prevent duplicate execution if two watchers are accidentally started.

Linux setup

Install a supported Node.js LTS release, save the runner in your home directory, and start it in a terminal:

node --version
mkdir -p "$HOME/.local/share/visidog"
# Save the downloaded file as $HOME/.local/share/visidog/visidog-host.mjs
node "$HOME/.local/share/visidog/visidog-host.mjs" "$HOME/projects/my-app"

For Linux, CLI Settings initially suggests /bin/sh. Save that value for POSIX shell commands. Set the same field to /bin/bash and save before requesting Bash syntax:

{ "command": "pwd; printf 'VisiDog host ready\n'", "cwd": ".", "timeout": 60000 }
{ "command": "set -o pipefail; npm test 2>&1 | tee test-output.log", "cwd": ".", "timeout": 60000 }

Each line is a separate tool-call example. The requested shell must be installed. Node uses native filesystem watching on Linux; no inotify-tools or Python package is needed.

Optional: a user systemd service

On systems with user systemd services, save this unit as ~/.config/systemd/user/visidog-host.service. Replace /usr/bin/node with the absolute executable path returned by command -v node, and adjust both project and runner paths. Use a separate unit for each project.

[Unit]
Description=VisiDog host command watcher

[Service]
Type=simple
ExecStart=/usr/bin/node %h/.local/share/visidog/visidog-host.mjs %h/projects/my-app
WorkingDirectory=%h/projects/my-app
Restart=on-failure
RestartSec=3
TimeoutStopSec=30

[Install]
WantedBy=default.target
mkdir -p "$HOME/.config/systemd/user"
# Save the unit above before running these commands.
systemctl --user daemon-reload
systemctl --user enable --now visidog-host.service
journalctl --user -u visidog-host.service -f

A service has its own PATH. Add an Environment=PATH=… line to the unit when your tools live outside the service's default PATH. A service stop has systemd's own process-management semantics; it is separate from the browser's wait timeout. Pending claims remain retained after an interrupted service.

Verify the complete round trip

  1. Wait for the runner's Watching message, activate the project's FSA hooks, and select Act Mode.
  2. Ask the AI: “Use execute_host_command to run echo VisiDog host ready in the project root, then report the exit code and stdout.”
  3. Check that commands/req-UUID.json appears, the host logs Running UUID, and receipts/res-UUID.json appears.
  4. The AI should receive exitCode: 0 and the echo text in stdout. Run a failing command next to check that a nonzero exit code is returned.

You can also test only the host before connecting the AI. Publish one UTF-8 request after the watcher has started, with a fresh UUID and current timestamp. The browser has no pending call for this manually created request, so its receipt tests the host only.

Windows: publish a host-only test request
$project = 'C:\Projects\my-app'
$id = [guid]::NewGuid().ToString()
$request = @{
  version = 1; id = $id; projectId = 'manual-project'; sessionId = 'manual-session'
  command = 'echo VisiDog host ready'; cwd = '.'; timeout = 60000
  createdAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
} | ConvertTo-Json
$directory = Join-Path $project '.visidog\hooks\commands'
$temporary = Join-Path $directory ".req-$id.tmp"
$destination = Join-Path $directory "req-$id.json"
[IO.File]::WriteAllText($temporary, $request, [Text.UTF8Encoding]::new($false))
[IO.File]::Move($temporary, $destination)
Write-Output (Join-Path $project ".visidog\hooks\receipts\res-$id.json")
Linux: publish a host-only test request
node --input-type=module - "$HOME/projects/my-app" <<'JS'
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
const project = process.argv[2];
const id = randomUUID();
const directory = path.join(project, '.visidog/hooks/commands');
const temporary = path.join(directory, `.req-${id}.tmp`);
fs.writeFileSync(temporary, JSON.stringify({
  version: 1, id, projectId: 'manual-project', sessionId: 'manual-session',
  command: 'echo VisiDog host ready', cwd: '.', timeout: 60000, createdAt: Date.now()
}));
fs.renameSync(temporary, path.join(directory, `req-${id}.json`));
console.log(path.join(project, `.visidog/hooks/receipts/res-${id}.json`));
JS

Stopping, restarting and diagnosing failures

A browser timeout, Stop or disabling hooks ends the CLI's wait. It does not send a cancellation request to the host or kill its process. Inspect the receipt and host process before retrying a command with effects. The sample rejects expired requests before starting them, but a command already running can finish after the browser stops waiting.

Press Ctrl+C in the runner terminal to stop accepting requests. The sample leaves an active command running and attempts to write its final receipt. Long-lived server commands can keep it alive indefinitely; use commands that finish, or implement a separate host policy for background jobs and process-tree termination.

The runner ignores all files present at startup and never automatically replays a claim. After a crash, a claim-UUID.json without a result means execution is uncertain: the command may have run partly or fully. Examine the machine state and resolve the work before requesting another execution. Do not delete the claim just to force a replay. A durable claim prevents duplicate execution; it cannot guarantee that every claimed command finishes.

SymptomWhat to check
Tool is absentThe selected project must use FSA, hooks must be active, and native browser observation must be available. See File Hooks setup.
PLAN_MODE_READ_ONLYThe AI is in Plan Mode. Use the CLI's normal plan approval flow to enter Act Mode.
HOST_COMMAND_SHELL_REQUIREDEnter and save Host command shell in CLI Settings. The browser could not identify a platform or the field was cleared; no request has been published.
No host log for a requestBoth sides must use the same physical folder. Start the watcher before publishing requests; check startup exclusions, existing claims, permissions and watcher errors.
cwd must resolve inside…The directory must exist within the selected project. The sample checks its real path, including symbolic links.
Request expired…The queue or startup delay exceeded the request's wait limit. Verify host and browser clocks and use a suitable wait deadline.
Shell/tool unavailableInstall the requested executable or adjust the runner's PATH. Specify the correct shell and use syntax for that shell.
Receipt exists, but the AI timed outThe browser may have stopped waiting, reloaded, lost observation or received the result after its deadline. Late files do not reopen an old tool call.
Incomplete or invalid receiptPublish by atomic rename; include matching id, exitCode, stdout and stderr. Stay within 4 MiB.
Watcher error or folder replacedStop and restart observation after restoring the directory. Prefer a local filesystem; native notifications can be unreliable on network or virtual filesystems. Reconcile uncertain work before submitting new requests.

Keep requests and receipts until you have reconciled the work. Retain durable claims across restarts; this example does not clean them automatically. FileHooks allowedCommands governs prompt slash commands, not native host commands.

For an existing host integration, implement the same request validation, UUID claiming, native execution and atomic receipt publication. host-state/ is specific to this sample; the browser protocol only requires commands/req-UUID.json and receipts/res-UUID.json.