Files
paseo/docs/ad-hoc-daemon-testing.md
Mohamed Boudra dec47d72d9 docs: lowercase internal docs + migrate website docs to public-docs/ (#634)
* docs: rename to lowercase + drop leftover plans

Rename docs in docs/ to lowercase kebab-case for consistency and update
all references in CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md,
packages/server/CLAUDE.md, and inter-doc links.

Drop two leftover design plan docs:
- docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
- docs/plan-approval-normalization.md

* docs: drop stale uppercase entries from case-insensitive rename

* feat(website): power /docs from public-docs/ markdown tree

Move website docs out of TSX route components and into a root-level
public-docs/ directory of plain markdown files with frontmatter
(title, description, nav, order).

- Add packages/website/src/docs.ts loader using import.meta.glob with
  ?raw to compile the markdown into the bundle at build time.
- Replace the 9 hand-written docs/*.tsx routes with a single $.tsx
  catch-all that renders any slug via react-markdown.
- Drive the docs sidebar nav from frontmatter order/nav.
- Auto-discover docs routes in vite.config.ts so the sitemap stays in
  sync without manual edits.

* fix(website): bind dev server to 0.0.0.0 so port collisions trigger fallback

`host: "127.0.0.1"` (or unset) lets macOS coexist with another process
holding an IPv6 dual-stack `*:8082` socket, so Vite never sees
EADDRINUSE and silently binds alongside it. Forcing IPv4 wildcard
makes the conflict real, and Vite's default `strictPort: false`
falls through to the next free port.

* fix(website): restore docs page styling after markdown migration

Add a .docs-prose class that mirrors the styling the original
docs/*.tsx components hand-rolled (h1/h2/h3 sizes, paragraph/list
spacing, link colors, code blocks, callout-style blockquotes).

ReactMarkdown was emitting unstyled HTML because the previous
wrapper class only had inline-code rules — headings and code
blocks fell back to user-agent defaults.
2026-04-30 17:27:15 +08:00

5.3 KiB

Ad-hoc daemon testing

Spin up an isolated daemon programmatically without touching the main daemon on port 6767.

Quick start

import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import { createPaseoDaemon } from "./bootstrap.js";
import { DaemonClient } from "./test-utils/daemon-client.js";

const logger = pino({ level: "warn" });
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));

const daemon = await createPaseoDaemon(
  {
    listen: "127.0.0.1:0", // OS picks a free port
    paseoHome,
    corsAllowedOrigins: [],
    hostnames: true,
    mcpEnabled: false,
    staticDir,
    mcpDebug: false,
    agentClients: {},
    agentStoragePath: path.join(paseoHome, "agents"),
    relayEnabled: false,
    relayEndpoint: "relay.paseo.sh:443",
    appBaseUrl: "https://app.paseo.sh",
    // Add custom config here, e.g.:
    // providerOverrides: { ... },
  },
  logger,
);

await daemon.start();
const target = daemon.getListenTarget();
const port = target!.type === "tcp" ? target!.port : null;

const client = new DaemonClient({
  url: `ws://127.0.0.1:${port}/ws`,
  appVersion: "0.1.54", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });

// ... do your testing ...

await client.close();
await daemon.stop();
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });

Run with:

npx tsx packages/server/src/server/your-script.ts

Using the test helper

For simpler cases, createTestPaseoDaemon + DaemonClient handles temp dirs and port selection:

import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";

const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
  url: `ws://127.0.0.1:${daemon.port}/ws`,
  appVersion: "0.1.54",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });

// ... test ...

await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs

The test helper does not expose providerOverrides. Use createPaseoDaemon directly when you need it (see quick start above).

Common client methods

// Provider discovery
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
const models = await client.listProviderModels("claude");
const modes = await client.listProviderModes("claude");

// Agent lifecycle
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
await client.sendMessage(agent.id, "Hello");
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");

Gotchas

1. appVersion gates provider visibility

The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an appVersion >= 0.1.45. The DaemonClient sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.

Always pass appVersion:

const client = new DaemonClient({
  url: `ws://127.0.0.1:${port}/ws`,
  appVersion: "0.1.54",
});

2. Provider snapshots are async

After the daemon starts, providers are probed in the background. The first getProvidersSnapshot() call will likely return status: "loading" for most providers. Poll until the provider you care about is no longer loading:

let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
for (let i = 0; i < 20; i++) {
  const entry = snapshot.entries.find((e) => e.provider === "gemini");
  if (entry && entry.status !== "loading") break;
  await new Promise((r) => setTimeout(r, 2_000));
  snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
}

3. fetchAgents is required before most operations

Call client.fetchAgents() after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like get_providers_snapshot_request will silently hang.

4. listen: "127.0.0.1:0" for port allocation

Always use port 0 so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.

5. Script must live inside packages/server

The test utilities use relative imports through the TypeScript project. Place your script somewhere under packages/server/src/ and import from there. Scripts outside the repo will fail with module resolution errors.

6. Cleanup on failure

Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:

try {
  // ... test logic ...
} finally {
  await client.close();
  await daemon.stop().catch(() => undefined);
  await rm(paseoHomeRoot, { recursive: true, force: true });
}

7. ACP providers spawn real processes

When testing ACP providers (e.g., Gemini with extends: "acp"), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.