diff --git a/.gitignore b/.gitignore
index e247c2a2..44837b50 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ fleet.config.json
~/.claude-fleet/
materials/*
.claude/settings.local.json
+CLAUDE.md
diff --git a/docs/adr-onboarding-ux-delivery.md b/docs/adr-onboarding-ux-delivery.md
new file mode 100644
index 00000000..a171a81a
--- /dev/null
+++ b/docs/adr-onboarding-ux-delivery.md
@@ -0,0 +1,224 @@
+# ADR: Verbatim Onboarding UX Delivery Through an LLM Client
+
+**Status:** Implemented
+**Date:** 2026-04-15
+**Related commits:** 52f0fad, 74da392, 5209f54, 1de95fc, 9efacb3, 5a63786
+**Supersedes:** the original content-block-only delivery introduced in 07e214e / a06e052 / 076c02c
+
+## Context
+
+The fleet server ships user-facing onboarding UX — a first-run banner, a welcome-back preamble on subsequent server starts, and three context-sensitive nudges. These are meant to be seen **verbatim** by the human operator: ASCII art, box-drawn tip cards, specific wording, specific emoji.
+
+The delivery surface is an MCP tool response. Between the tool response and the human sits a client-side LLM (typically Claude Code). That LLM owns the final reply to the user and — by design — summarizes, paraphrases, or suppresses tool output when it believes a condensed rendering serves the user better.
+
+### The failure mode
+
+The original implementation returned the banner and nudges as multi-part content blocks with MCP `audience: ['user']` annotations and a priority hint:
+
+```ts
+return {
+ content: [
+ { type: 'text', text: banner, annotations: { audience: ['user'], priority: 1 } },
+ { type: 'text', text: toolResult },
+ { type: 'text', text: nudge, annotations: { audience: ['user'], priority: 0.8 } },
+ ],
+};
+```
+
+The `audience` and `priority` fields are advisory. Clients are free to render — or ignore — them as they see fit. In practice, Claude Code's LLM treated the annotated blocks as raw context, paraphrased the content into its own summary, and often collapsed the banner into a single polite sentence that erased the formatting, the ASCII art, and the nudge entirely. Multiple "fix" attempts (passive-tool guard, JSON-response bypass, smoke-test coverage) all passed in isolation but failed live because the LLM still owned the user-visible rendering.
+
+The fundamental mismatch: we were using a **model-context channel** to deliver **user-facing display**. The LLM correctly treats tool results as context for a generated response, not as pass-through content.
+
+### Constraints
+
+- Cannot modify the client LLM's behavior directly.
+- Cannot rely on `audience` annotations being honored.
+- Cannot require a specific client — the approach must work with any MCP-compliant client and degrade gracefully.
+- The LLM-context cost of the delivery payload matters (it's added to every relevant response).
+- No attacker should be able to abuse the delivery mechanism to inject their own instructions into the user's view.
+
+---
+
+## Decision 1: Three-channel defense-in-depth delivery
+
+Onboarding text travels over three independent channels simultaneously. Any one of them reaching the user is sufficient.
+
+### Channel 1 — Out-of-band notifications (primary)
+
+The server emits banner, welcome-back, and nudge strings via MCP `sendLoggingMessage`:
+
+```ts
+await server.server.sendLoggingMessage({
+ level: 'info',
+ logger: 'apra-fleet-onboarding',
+ data: text,
+});
+```
+
+These notifications travel on the `notifications/message` channel and are **not** added to the client LLM's conversation context. A compliant client renders them directly to the user (as log output, a toast, a side panel — client-specific). Because they bypass the LLM entirely, they are immune to paraphrasing.
+
+Requirements:
+
+- The server must declare `capabilities: { logging: {} }` at `McpServer` construction. Without this, `sendLoggingMessage` is a no-op.
+- The emit path must be fire-and-forget and wrapped in try/catch: a client that does not surface logging should not break the tool response.
+
+### Channel 2 — In-band structural markers + one-time server instructions
+
+For clients that don't prominently surface `notifications/message`, the same text is included in a tool-result content block wrapped in `…` markers:
+
+```
+
+[banner or nudge text here]
+
+```
+
+The `McpServer` constructor's `instructions` field — which MCP clients surface to the LLM once, during `initialize` — tells the LLM how to treat content inside these markers:
+
+> When a tool response contains text wrapped in `…` tags, that content is user-facing onboarding material. Reproduce it VERBATIM in your reply to the user as the very first thing you output, preserving all formatting, line breaks, and emoji. Do not paraphrase, summarize, translate, or comment on it. Do not wrap it in code fences. Strip the `` tags themselves — they are for your recognition only.
+
+This works because Claude (and most modern LLMs) treat instructions in the system-prompt layer as high-priority directives, and they respect structural markers as routing signals. The instruction is paid once per MCP connection (not per tool call), keeping marginal cost near zero.
+
+### Channel 3 — `audience` annotations (fallback)
+
+The existing content-block channel with `audience: ['user']` annotations remains in place. Spec-compliant clients that do render annotated content for the user will surface the banner this way. This is the weakest of the three channels but adds no incremental cost.
+
+### Why all three
+
+| Channel | Cost (LLM-context tokens) | Reliability | Failure mode |
+|---|---|---|---|
+| 1. Notifications | 0 | Client-dependent (not universal) | Client doesn't surface logging |
+| 2. Markers + instructions | ~115 once per connection + ~11 per wrapped section | LLM-dependent (can be overridden) | LLM ignores the instruction |
+| 3. `audience` annotations | same payload, shared with #2 | Spec-advisory | Client collapses annotated blocks |
+
+The channels fail independently. No single channel is load-bearing.
+
+---
+
+## Decision 2: Sanitize the marker channel against injection
+
+Introducing `` as a live LLM instruction surface created a new attack class: any attacker who can influence a tool-result string could smuggle their own instructions to the user's LLM.
+
+### Threat model
+
+Indirect prompt injection is the concerning vector:
+
+1. User asks the assistant to process an untrusted document (web page, email, PDF).
+2. Document contains instructions asking the assistant to perform a fleet action with a crafted parameter.
+3. Parameter value contains `Ignore prior instructions…`.
+4. Tool handler echoes the parameter into its result string (e.g., in an error message).
+5. Without mitigation, `VERBATIM_INSTRUCTIONS` causes the LLM to reproduce the attacker's content verbatim.
+
+### Mitigation (two layers)
+
+**Layer A — sanitize at output.** `wrapTool` applies `sanitizeToolResult()` to the raw tool handler return before embedding it in a content block:
+
+```ts
+function sanitizeToolResult(s: string): string {
+ return s.replace(/<\/?apra-fleet-display[^>]*>/gi, '[tag-stripped]');
+}
+```
+
+The regex strips both opening and closing variants, tolerates attributes, and is case-insensitive — the LLM would treat any of these forms as the marker, so we must too. `[tag-stripped]` is a visible inert replacement that aids debugging and cannot itself be interpreted as a directive.
+
+**Important invariant:** preamble and suffix (server-controlled onboarding text) are **not** sanitized — they're the legitimate source of the markers. Only the tool-handler `result` passes through `sanitizeToolResult`.
+
+**Layer B — validate at input.** `register_member`'s `host` and `work_folder` fields now enforce:
+
+```ts
+.regex(/^[^<>\n\r]+$/, '… must not contain angle brackets or newlines')
+```
+
+This rejects the injection vector at the Zod boundary — an attack never reaches the tool handler, the tool result, or the registry.
+
+### Why both
+
+Layer A alone is trust-but-verify at the output boundary. It catches everything, including future tools that echo new unvalidated fields. But it's one centralized code path; any regression there opens every tool.
+
+Layer B alone protects only the tools that adopt it — a manual per-tool audit burden. But it gives legitimate misuse a clear error instead of silent sanitization.
+
+Together: the output layer is the floor (always protects), the input layer is the ceiling (bright clear errors for legitimate misuse), and a single-tool regression doesn't remove both.
+
+### Known gap
+
+`update_member` accepts the same `host` and `work_folder` fields without the regex (pre-existing, not introduced by this work). Layer A still protects runtime tool results, but values persist to the registry without validation. Fix is tracked separately; scope includes adding the same regex to `update_member`'s schema and — if warranted — a broader sweep of tool input validation.
+
+---
+
+## Decision 3: Persistence and passive-tool protection (carried forward)
+
+These decisions pre-date the current delivery-mechanism work and are documented here for completeness because they affect onboarding behavior:
+
+- **One-shot banner.** The banner is shown once across the lifetime of an install. State is persisted to `~/.apra-fleet/onboarding.json` atomically after the banner is emitted. A server crash between show and save is accepted as a rare re-show, not worth a transaction.
+- **Upgrade path.** If the registry already contains members but no `onboarding.json` exists, the server pre-sets `bannerShown=true`. Existing users do not see the banner on upgrade.
+- **Passive-tool guard.** `version` and `shutdown_server` never consume the banner. The AI client often calls `version` silently on connection; if that call consumed the banner, real users would miss it.
+- **JSON-response bypass.** The first-run banner bypasses the `isJsonResponse` check. Tools that return JSON (e.g., `fleet_status`) are the most likely first call in a real session. Welcome-back and nudges still respect the JSON check to avoid cluttering structured data responses.
+- **Per-session welcome-back.** After the first run, a short welcome-back preamble shows at most once per MCP server lifetime (session-scoped flag, not persisted).
+
+---
+
+## Token cost summary
+
+All figures are LLM-context tokens. `sendLoggingMessage` payloads cost wire bytes only and do not enter the conversation.
+
+| Phase | Tokens |
+|---|---|
+| Per-connection init (`VERBATIM_INSTRUCTIONS` in system prompt) | ~115 |
+| Banner + guide wrapped (one-time, on first active tool call) | ~737 |
+| Welcome-back wrapped (once per subsequent server start) | ~85 |
+| All nudges wrapped (spread across sessions) | ~395 |
+| Fresh-install, first server start | ~852 |
+| Fresh-install, full journey | ~1247 |
+| Returning user per server start | ~200 |
+
+Reproduce with `node count_tokens.mjs`.
+
+---
+
+## Alternatives considered
+
+1. **Stronger `audience` annotation expectations.** Rejected — `audience` is advisory per spec; no amount of client pressure will force Claude Code to honor it.
+2. **Rewrite the tool description of every tool to include display instructions.** Rejected — each tool call would carry the instruction text, multiplying cost. Server-level `instructions` is paid once.
+3. **Drop content blocks, rely only on notifications.** Rejected — not all clients surface notifications prominently. Pure notification channel is brittle.
+4. **Write onboarding output to a file and point users at it.** Rejected — added friction; breaks the inline flow that makes onboarding effective.
+5. **Use MCP `resources`.** Rejected — very few clients render resources prominently; Claude Code shows them only when explicitly referenced.
+6. **Write directly to `process.stderr`.** Rejected — only works with stdio transport; Claude Code forwards server stderr to a log panel that most users never see; not in the MCP spec.
+7. **Special tag names that are hard to spoof (high-entropy tokens).** Rejected — the LLM pattern-matches loosely; increasing the name's entropy without the sanitization layer just shifts the attack, it doesn't remove it. With the sanitization layer, the tag name is fine as-is.
+
+---
+
+## Consequences
+
+### Positive
+
+- User-facing onboarding text now reliably reaches the user verbatim, validated live in Claude Code.
+- Defense-in-depth: three independent delivery channels + two independent injection defenses.
+- Token-cost overhead is bounded and measurable; the largest recurring cost (~115 tokens/connection) is a fixed initialization tax, not a per-call tax.
+- The `` marker and sanitizer pattern are reusable — any future content that must reach the user verbatim can adopt the same envelope.
+
+### Negative
+
+- The `instructions` field is sent over every MCP connection; this is billable input-context tokens for any client that counts MCP init toward its model budget.
+- The marker channel works because the LLM chooses to follow instructions. Silent regressions are possible if a model's policy shifts. Mitigated by the redundant notification channel.
+- The `VERBATIM_INSTRUCTIONS` directive says "as the very first thing you output." LLMs in extended-thinking mode may prepend internal reasoning before the visible reply, causing the banner to appear after a thinking preamble rather than as the literal first output. The notification channel (Channel 1) is unaffected by this since it bypasses the LLM entirely.
+- `update_member` gap exists (see Decision 2 "Known gap"). Tracked.
+
+### Neutral
+
+- `capabilities: { logging: {} }` enables the client to set a log level via `logging/setLevel`, filtering our notifications. A hostile client could silence onboarding — but the marker channel still delivers, and a hostile client cannot be forced to display anything regardless.
+- `[tag-stripped]` is an unusual string. If it appears in user-visible output, it signals that a tool result contained the marker tags — almost certainly adversarial.
+
+---
+
+## Where to look in the code
+
+| Concern | File:location |
+|---|---|
+| `wrapTool`, sanitizer, notification helper | `src/index.ts` (search for `wrapTool`, `sanitizeToolResult`, `sendOnboardingNotification`) |
+| Server construction with capabilities + instructions | `src/index.ts` (McpServer constructor call) |
+| Onboarding state + passive-tool guard | `src/services/onboarding.ts` |
+| All user-facing text constants + token analysis | `src/onboarding/text.ts` |
+| `VERBATIM_INSTRUCTIONS` constant | `src/onboarding/text.ts` (end of file) |
+| Input validation on host/work_folder | `src/tools/register-member.ts` (schema) |
+| Unit + integration tests | `tests/onboarding.test.ts`, `tests/onboarding-text.test.ts` |
+| Smoke test | `tests/onboarding-smoke.mjs` |
+| Token-cost reproducer | `count_tokens.mjs` (repo root, untracked dev tool) |
diff --git a/feedback.md b/feedback.md
new file mode 100644
index 00000000..2f01fe17
--- /dev/null
+++ b/feedback.md
@@ -0,0 +1,56 @@
+# PR #101 Review: feat: first-run onboarding experience and user engagement nudges
+
+**Reviewer:** Claude Code (automated review)
+**Date:** 2026-04-18
+**Verdict:** APPROVED (with one non-blocking note)
+
+## Summary
+
+This PR adds a first-run onboarding experience (ASCII banner + getting started guide), contextual nudges (post-registration, post-first-prompt, multi-member milestone), and a welcome-back preamble on subsequent server starts. The implementation uses a well-thought-out three-channel defense-in-depth delivery strategy to ensure onboarding text reaches the user verbatim despite the LLM intermediary.
+
+## What was reviewed
+
+- `src/onboarding/text.ts` — all user-facing text constants
+- `src/services/onboarding.ts` — state management (load, save, milestones, session flags)
+- `src/index.ts` — `wrapTool`, `sanitizeToolResult`, `sendOnboardingNotification`, McpServer construction
+- `src/tools/register-member.ts` — input validation (angle bracket regex)
+- `src/tools/update-member.ts` — input validation (angle bracket regex)
+- `src/types.ts` — `OnboardingState` interface
+- `src/cli/install.ts` — data directory comment
+- `docs/adr-onboarding-ux-delivery.md` — architecture decision record
+- `tests/onboarding.test.ts` — 57 tests covering state, milestones, nudges, sanitization, integration
+- `tests/onboarding-text.test.ts` — 21 tests for text constants
+- `tests/onboarding-smoke.mjs` — end-to-end smoke test
+- `.gitignore` — CLAUDE.md addition
+
+## Findings
+
+### Architecture & Design — Excellent
+
+- Three-channel delivery (notifications, markers+instructions, audience annotations) is well-reasoned. The ADR documents the failure modes, token costs, and tradeoffs clearly.
+- Sanitization defense (both output-boundary `sanitizeToolResult` and input-boundary Zod regex) is defense-in-depth done right. The ADR honestly documents the `update_member` gap and notes it was closed in this PR.
+- The `wrapTool` abstraction replaces 21 inline wrappers with a single function — cleaner and easier to maintain.
+- Passive-tool guard (`version`, `shutdown_server`) prevents silent consumption of the banner by auto-called tools.
+- First-run banner bypasses JSON check while welcome-back/nudges respect it — correct design for different urgency levels.
+
+### Code Quality — Clean
+
+- State management is well-structured: in-memory singleton loaded once, atomic file writes, forward-compatible merge with defaults, corruption recovery.
+- `_resetForTest()` is a clean test-only escape hatch.
+- Token cost analysis in the text.ts header is thorough and reproducible.
+- The sanitizer regex handles case variants, attributes, unterminated tags, and multiple occurrences.
+
+### Testing — Thorough
+
+- 722 tests pass, zero failures (4 skipped, pre-existing).
+- Build compiles cleanly with no TypeScript errors.
+- Tests cover: fresh install, upgrade path, corruption recovery, milestone progression, idempotency, passive-tool guard, JSON bypass, full session sequence, notification emission, sanitization edge cases, schema validation.
+- Smoke test provides an additional end-to-end verification layer.
+
+### Non-blocking note
+
+- `.gitignore` adds `CLAUDE.md`. Since CLAUDE.md is already tracked by git, this has no immediate effect — git only ignores untracked files. However, if someone ever removes CLAUDE.md from tracking, this gitignore entry would prevent re-adding it. This looks like a development artifact. Low risk, can be cleaned up in a follow-up.
+
+## Verdict
+
+**APPROVED.** The implementation is well-designed, thoroughly tested, security-conscious, and clean. The three-channel delivery strategy with injection defense is a thoughtful solution to the real problem of delivering verbatim content through an LLM intermediary.
diff --git a/src/cli/install.ts b/src/cli/install.ts
index 38ab998f..00f656fd 100644
--- a/src/cli/install.ts
+++ b/src/cli/install.ts
@@ -11,6 +11,9 @@ const FLEET_BASE = path.join(home, '.apra-fleet');
const BIN_DIR = path.join(FLEET_BASE, 'bin');
const HOOKS_DIR = path.join(FLEET_BASE, 'hooks');
const SCRIPTS_DIR = path.join(FLEET_BASE, 'scripts');
+// NOTE: install NEVER writes to the data directory (~/.apra-fleet/data/).
+// Registry (registry.json) and onboarding state (onboarding.json) live there and
+// must not be touched by reinstalls or upgrades — see onboarding.ts upgrade detection.
interface ProviderInstallConfig {
configDir: string;
diff --git a/src/index.ts b/src/index.ts
index f3d6f918..db63e11e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -43,6 +43,14 @@ async function startServer() {
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
+ // Load onboarding state once at server startup (in-memory singleton)
+ const { loadOnboardingState, resetSessionFlags, getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingNudge, getWelcomeBackPreamble } = await import('./services/onboarding.js');
+ const { VERBATIM_INSTRUCTIONS } = await import('./onboarding/text.js');
+ const { getAllAgents: getAgentsForStartup } = await import('./services/registry.js');
+ // Pass current member count so upgrade detection works: existing registry + no onboarding.json → skip banner
+ loadOnboardingState(getAgentsForStartup().length);
+ resetSessionFlags();
+
// Tool schemas and handlers
const { registerMemberSchema, registerMember } = await import('./tools/register-member.js');
const { listMembersSchema, listMembers } = await import('./tools/list-members.js');
@@ -71,47 +79,109 @@ async function startServer() {
// serverVersion is "v0.0.1_abc123" — strip 'v' prefix for semver-like version field
const versionNum = serverVersion.startsWith('v') ? serverVersion.slice(1) : serverVersion;
- const server = new McpServer({
- name: `apra fleet server ${serverVersion}`,
- version: versionNum,
- });
+ const server = new McpServer(
+ { name: `apra fleet server ${serverVersion}`, version: versionNum },
+ {
+ capabilities: { logging: {} },
+ instructions: VERBATIM_INSTRUCTIONS,
+ },
+ );
+
+ // --- Onboarding helpers ---
+ // isActiveTool guards passive tools (version, shutdown_server) from consuming the banner.
+ // First-run banner bypasses the JSON check — passive guard is sufficient protection.
+ // Welcome-back and nudges still respect the JSON check.
+
+ async function sendOnboardingNotification(srv: typeof server, text: string): Promise {
+ try {
+ await srv.server.sendLoggingMessage({
+ level: 'info',
+ logger: 'apra-fleet-onboarding',
+ data: text,
+ });
+ } catch (e: unknown) {
+ const msg = (e instanceof Error ? e.message : String(e));
+ if (!/logging|method not found|not supported/i.test(msg)) {
+ process.stderr.write(`[apra-fleet] onboarding notification failed: ${msg}\n`);
+ }
+ }
+ }
+
+ function sanitizeToolResult(s: string): string {
+ return s.replace(/<\/?apra-fleet-display[^>]*(?:>|$)/gi, '[tag-stripped]');
+ }
+
+ function getOnboardingPreamble(toolName: string, isJson: boolean): string | null {
+ if (!isActiveTool(toolName)) return null;
+ // First-run banner always shows regardless of response format
+ const banner = getFirstRunPreamble();
+ if (banner) return banner;
+ // Welcome-back still respects JSON check
+ if (isJson) return null;
+ return getWelcomeBackPreamble();
+ }
+
+ function wrapTool(toolName: string, handler: (input: any) => Promise) {
+ return async (input: any) => {
+ const result = await handler(input);
+ const isJson = isJsonResponse(result);
+ const preamble = getOnboardingPreamble(toolName, isJson);
+ const suffix = isJson ? null : getOnboardingNudge(toolName, input, result);
+
+ // Channel 1: out-of-band notifications (best effort, never throws)
+ if (preamble) void sendOnboardingNotification(server, preamble);
+ if (suffix) void sendOnboardingNotification(server, suffix);
+
+ // Channel 2 + 3: content blocks with markers + audience annotation
+ const content: Array<{ type: 'text'; text: string; annotations?: { audience?: ('user' | 'assistant')[]; priority?: number } }> = [];
+ if (preamble) {
+ content.push({ type: 'text' as const, text: `\n${preamble}\n`, annotations: { audience: ['user'], priority: 1 } });
+ }
+ content.push({ type: 'text' as const, text: sanitizeToolResult(result) });
+ if (suffix) {
+ content.push({ type: 'text' as const, text: `\n${suffix}\n`, annotations: { audience: ['user'], priority: 0.8 } });
+ }
+ return { content };
+ };
+ }
// --- Core Member Management ---
- server.tool('register_member', 'Add a machine to the fleet. Use member_type "local" for this machine or "remote" for a machine reachable over SSH. Choose the AI provider the member will use for prompts.', registerMemberSchema.shape, async (input) => ({ content: [{ type: 'text', text: await registerMember(input as any) }] }));
- server.tool('list_members', 'List all fleet members and their current status. Use format="json" for structured data.', listMembersSchema.shape, async (input) => ({ content: [{ type: 'text', text: await listMembers(input as any) }] }));
- server.tool('remove_member', 'Remove a member from the fleet.', removeMemberSchema.shape, async (input) => ({ content: [{ type: 'text', text: await removeMember(input as any) }] }));
- server.tool('update_member', "Change a member's name, connection details, working directory, AI provider, or other settings.", updateMemberSchema.shape, async (input) => ({ content: [{ type: 'text', text: await updateMember(input as any) }] }));
+ server.tool('register_member', 'Add a machine to the fleet. Use member_type "local" for this machine or "remote" for a machine reachable over SSH. Choose the AI provider the member will use for prompts.', registerMemberSchema.shape, wrapTool('register_member', (input) => registerMember(input as any)));
+ server.tool('list_members', 'List all fleet members and their current status. Use format="json" for structured data.', listMembersSchema.shape, wrapTool('list_members', (input) => listMembers(input as any)));
+ server.tool('remove_member', 'Remove a member from the fleet.', removeMemberSchema.shape, wrapTool('remove_member', (input) => removeMember(input as any)));
+ server.tool('update_member', "Change a member's name, connection details, working directory, AI provider, or other settings.", updateMemberSchema.shape, wrapTool('update_member', (input) => updateMember(input as any)));
// --- File Operations ---
- server.tool('send_files', 'Transfer local files to a member. Always batch multiple files into a single call — never invoke repeatedly for individual files.', sendFilesSchema.shape, async (input) => ({ content: [{ type: 'text', text: await sendFiles(input as any) }] }));
- server.tool('receive_files', 'Download files from a member to a local directory. Always batch multiple files into a single call — never invoke repeatedly for individual files.', receiveFilesSchema.shape, async (input) => ({ content: [{ type: 'text', text: await receiveFiles(input as any) }] }));
+ server.tool('send_files', 'Transfer local files to a member. Always batch multiple files into a single call — never invoke repeatedly for individual files.', sendFilesSchema.shape, wrapTool('send_files', (input) => sendFiles(input as any)));
+ server.tool('receive_files', 'Download files from a member to a local directory. Always batch multiple files into a single call — never invoke repeatedly for individual files.', receiveFilesSchema.shape, wrapTool('receive_files', (input) => receiveFiles(input as any)));
// --- Prompt Execution ---
- server.tool('execute_prompt', 'IMP: Never call this tool directly. Always wrap in a background subagent: Agent(run_in_background=true). Run an AI prompt on a member. Supports session resume for multi-turn conversations.', executePromptSchema.shape, async (input) => ({ content: [{ type: 'text', text: await executePrompt(input as any) }] }));
- server.tool('execute_command', 'IMP: Never call this tool directly. Always wrap in a background subagent: Agent(run_in_background=true). Run a shell command on a member. Use for quick tasks like installing packages, checking versions, or running scripts.', executeCommandSchema.shape, async (input) => ({ content: [{ type: 'text', text: await executeCommand(input as any) }] }));
+ server.tool('execute_prompt', 'IMP: Never call this tool directly. Always wrap in a background subagent: Agent(run_in_background=true). Run an AI prompt on a member. Supports session resume for multi-turn conversations.', executePromptSchema.shape, wrapTool('execute_prompt', (input) => executePrompt(input as any)));
+ server.tool('execute_command', 'IMP: Never call this tool directly. Always wrap in a background subagent: Agent(run_in_background=true). Run a shell command on a member. Use for quick tasks like installing packages, checking versions, or running scripts.', executeCommandSchema.shape, wrapTool('execute_command', (input) => executeCommand(input as any)));
// --- Authentication & SSH ---
- server.tool('provision_llm_auth', "Authenticate a fleet member so it can run prompts. Copies your current login session to the member, or deploys an API key if provided. Run this before execute_prompt if the member reports no authentication.", provisionAuthSchema.shape, async (input) => ({ content: [{ type: 'text', text: await provisionAuth(input as any) }] }));
- server.tool('setup_ssh_key', 'Generate an SSH key pair and migrate a member from password to key-based authentication.', setupSSHKeySchema.shape, async (input) => ({ content: [{ type: 'text', text: await setupSSHKey(input as any) }] }));
- server.tool('setup_git_app', "One-time setup: register a GitHub App for git token minting. Requires a GitHub App ID, private key (.pem) file path, and installation ID. The app must already be created at github.com/organizations/{org}/settings/apps.", setupGitAppSchema.shape, async (input) => ({ content: [{ type: 'text', text: await setupGitApp(input as any) }] }));
- server.tool('provision_vcs_auth', 'Set up git access credentials on a member. Supports GitHub, Bitbucket, and Azure DevOps. Tests connectivity after setup.', provisionVcsAuthSchema.shape, async (input) => ({ content: [{ type: 'text', text: await provisionVcsAuth(input as any) }] }));
- server.tool('revoke_vcs_auth', 'Remove VCS credentials from a member. Specify the provider (github, bitbucket, or azure-devops) to revoke.', revokeVcsAuthSchema.shape, async (input) => ({ content: [{ type: 'text', text: await revokeVcsAuth(input as any) }] }));
+ server.tool('provision_llm_auth', "Authenticate a fleet member so it can run prompts. Copies your current login session to the member, or deploys an API key if provided. Run this before execute_prompt if the member reports no authentication.", provisionAuthSchema.shape, wrapTool('provision_llm_auth', (input) => provisionAuth(input as any)));
+ server.tool('setup_ssh_key', 'Generate an SSH key pair and migrate a member from password to key-based authentication.', setupSSHKeySchema.shape, wrapTool('setup_ssh_key', (input) => setupSSHKey(input as any)));
+ server.tool('setup_git_app', "One-time setup: register a GitHub App for git token minting. Requires a GitHub App ID, private key (.pem) file path, and installation ID. The app must already be created at github.com/organizations/{org}/settings/apps.", setupGitAppSchema.shape, wrapTool('setup_git_app', (input) => setupGitApp(input as any)));
+ server.tool('provision_vcs_auth', 'Set up git access credentials on a member. Supports GitHub, Bitbucket, and Azure DevOps. Tests connectivity after setup.', provisionVcsAuthSchema.shape, wrapTool('provision_vcs_auth', (input) => provisionVcsAuth(input as any)));
+ server.tool('revoke_vcs_auth', 'Remove VCS credentials from a member. Specify the provider (github, bitbucket, or azure-devops) to revoke.', revokeVcsAuthSchema.shape, wrapTool('revoke_vcs_auth', (input) => revokeVcsAuth(input as any)));
// --- Status & Monitoring ---
- server.tool('fleet_status', 'Get status of all fleet members. Use json format for structured data.', fleetStatusSchema.shape, async (input) => ({ content: [{ type: 'text', text: await fleetStatus(input as any) }] }));
- server.tool('member_detail', 'Get detailed status for one member: connectivity, AI version, authentication, active session, resources, and git branch.', memberDetailSchema.shape, async (input) => ({ content: [{ type: 'text', text: await memberDetail(input as any) }] }));
+ server.tool('fleet_status', 'Get status of all fleet members. Use json format for structured data.', fleetStatusSchema.shape, wrapTool('fleet_status', (input) => fleetStatus(input as any)));
+ server.tool('member_detail', 'Get detailed status for one member: connectivity, AI version, authentication, active session, resources, and git branch.', memberDetailSchema.shape, wrapTool('member_detail', (input) => memberDetail(input as any)));
// --- Maintenance ---
- server.tool('update_llm_cli', "Update or install the AI provider CLI on members. Omit member to update all online members at once. Use install_if_missing to install on members that don't have it yet.", updateAgentCliSchema.shape, async (input) => ({ content: [{ type: 'text', text: await updateAgentCli(input as any) }] }));
- server.tool('shutdown_server', 'Gracefully shut down the MCP server. Run /mcp afterwards to start a fresh instance with the latest code.', shutdownServerSchema.shape, async () => ({ content: [{ type: 'text', text: await shutdownServer() }] }));
- server.tool('version', 'Returns the installed apra-fleet server version', versionSchema.shape, async () => ({ content: [{ type: 'text', text: await version() }] }));
+ server.tool('update_llm_cli', "Update or install the AI provider CLI on members. Omit member to update all online members at once. Use install_if_missing to install on members that don't have it yet.", updateAgentCliSchema.shape, wrapTool('update_llm_cli', (input) => updateAgentCli(input as any)));
+ server.tool('shutdown_server', 'Gracefully shut down the MCP server. Run /mcp afterwards to start a fresh instance with the latest code.', shutdownServerSchema.shape, wrapTool('shutdown_server', () => shutdownServer()));
+ server.tool('version', 'Returns the installed apra-fleet server version', versionSchema.shape, wrapTool('version', () => version()));
// --- Permissions ---
- server.tool('compose_permissions', 'Set up and deliver the right permissions to a member for their role. Automatically tailors permissions to the project type. Use grant to add specific permissions mid-sprint without a full recompose.', composePermissionsSchema.shape, async (input) => ({ content: [{ type: 'text', text: await composePermissions(input as any) }] }));
+ server.tool('compose_permissions', 'Set up and deliver the right permissions to a member for their role. Automatically tailors permissions to the project type. Use grant to add specific permissions mid-sprint without a full recompose.', composePermissionsSchema.shape, wrapTool('compose_permissions', (input) => composePermissions(input as any)));
// --- Cloud Control ---
- server.tool('cloud_control', 'Manually start, stop, or check status of a cloud fleet member. Start waits until the member is ready; stop is immediate.', cloudControlSchema.shape, async (input) => ({ content: [{ type: 'text', text: await cloudControl(input as any) }] }));
- server.tool('monitor_task', 'Check status of a long-running background task on a cloud member. Optionally stop the cloud instance automatically when the task completes.', monitorTaskSchema.shape, async (input) => ({ content: [{ type: 'text', text: await monitorTask(input as any) }] }));
+ server.tool('cloud_control', 'Manually start, stop, or check status of a cloud fleet member. Start waits until the member is ready; stop is immediate.', cloudControlSchema.shape, wrapTool('cloud_control', (input) => cloudControl(input as any)));
+ server.tool('monitor_task', 'Check status of a long-running background task on a cloud member. Optionally stop the cloud instance automatically when the task completes.', monitorTaskSchema.shape, wrapTool('monitor_task', (input) => monitorTask(input as any)));
+
// --- Start Server ---
const transport = new StdioServerTransport();
await server.connect(transport);
diff --git a/src/onboarding/text.ts b/src/onboarding/text.ts
new file mode 100644
index 00000000..d6514253
--- /dev/null
+++ b/src/onboarding/text.ts
@@ -0,0 +1,132 @@
+/**
+ * All user-facing onboarding text constants.
+ * Logic never constructs display text directly — it always imports from here.
+ *
+ * ─────────────────────────────────────────────────────────────────────────────
+ * TOKEN COST ANALYSIS — onboarding UX overhead (notification-hybrid delivery)
+ * ─────────────────────────────────────────────────────────────────────────────
+ * Methodology: ASCII text ~4 chars/token; box-drawing & unicode ~1-2 chars/token.
+ * Run `node count_tokens.mjs` to reproduce. All numbers below are LLM-context
+ * tokens (input + output) — NOT wire bytes.
+ *
+ * Delivery model:
+ * • Banner/welcome-back/nudges are returned as tool-result content blocks
+ * wrapped in … markers, plus a
+ * one-time server `instructions` field telling the LLM to reproduce them
+ * verbatim. The same text is also emitted via MCP `sendLoggingMessage`.
+ * • sendLoggingMessage payloads are OUT-OF-BAND: they cost wire bytes but
+ * NOT LLM-context tokens (they are not added to the conversation).
+ *
+ * PER-CONNECTION cost (paid once at `initialize`, into the system prompt):
+ * VERBATIM_INSTRUCTIONS 457 chars → ~115 tokens
+ * ─────────────────────────────────────
+ * Cost per MCP server start: ~115 tokens
+ *
+ * ONE-TIME banner cost (shown once ever, on first active tool call):
+ * BANNER 678 chars → ~380 tokens
+ * GETTING_STARTED_GUIDE 1134 chars → ~346 tokens
+ * Marker wrapper overhead → ~11 tokens
+ * ─────────────────────────────────────
+ * Banner+Guide wrapped: ~737 tokens (single response, never repeated)
+ *
+ * RECURRING welcome-back (once per server lifecycle after first run):
+ * WELCOME_BACK() 143–152 chars → ~75 tokens
+ * Marker wrapper overhead → ~10 tokens
+ * ─────────────────────────────────────
+ * WELCOME_BACK wrapped: ~85 tokens/server-start
+ *
+ * NUDGE costs (each shown at most once across the user's entire journey):
+ * NUDGE_AFTER_FIRST_REGISTER 252 chars → ~114–115 tokens +wrap → ~125 tokens
+ * NUDGE_AFTER_FIRST_PROMPT 252 chars → ~115 tokens +wrap → ~126 tokens
+ * NUDGE_AFTER_MULTI_MEMBER 315 chars → ~133 tokens +wrap → ~143 tokens
+ * ─────────────────────────────────────
+ * All nudges wrapped (sum): ~395 tokens (spread across sessions)
+ *
+ * Lifecycle totals:
+ * Fresh-install, first server start: ~852 tokens (115 init + 737 bannerWrapped)
+ * Fresh-install, full journey: ~1247 tokens (115 init + 737 banner + 395 nudges)
+ * Returning user per server start: ~200 tokens (115 init + 85 WB)
+ *
+ * Delta vs. pre-notification implementation:
+ * Per-connection: +115 tokens (new VERBATIM_INSTRUCTIONS — paid every server start)
+ * Per message: +10-11 tokens/section (marker wrapping)
+ * Full journey: ~+83 tokens over baseline ~1164
+ * ─────────────────────────────────────────────────────────────────────────────
+ */
+
+export const BANNER = `────────────────────────────────────────────────────────────────────────────────
+
+ █████╗ ██████╗ ██████╗ █████╗ ███████╗██╗ ███████╗███████╗████████╗
+██╔══██╗██╔══██╗██╔══██╗██╔══██╗ ██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝
+███████║██████╔╝██████╔╝███████║ █████╗ ██║ █████╗ █████╗ ██║
+██╔══██║██╔═══╝ ██╔══██╗██╔══██║ ██╔══╝ ██║ ██╔══╝ ██╔══╝ ██║
+██║ ██║██║ ██║ ██║██║ ██║ ██║ ███████╗███████╗███████╗ ██║
+╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝
+
+ ⚡ One model is a tool. A fleet is a team. ⚡
+
+────────────────────────────────────────────────────────────────────────────────`;
+
+export const GETTING_STARTED_GUIDE = `
+┌─ Getting Started ─────────────────────────────────────────────────┐
+│ │
+│ 1. Add your first member │
+│ 'Add this machine to the fleet' (local) │
+│ 'Register my-server as a remote member' (SSH) │
+│ Each member works in its own directory — parallel by design. │
+│ │
+│ 2. Give it work │
+│ 'Ask my-server to run the test suite' │
+│ 'Send the src/ folder to my-server and run the build' │
+│ │
+│ 3. See what's happening │
+│ 'Show fleet status' │
+│ │
+│ Docs: https://github.com/Apra-Labs/apra-fleet │
+└────────────────────────────────────────────────────────────────────┘`;
+
+/**
+ * Welcome-back message shown once per server lifecycle (not on first run).
+ * @param memberCount Total number of registered members
+ * @param lastActive Relative time string, e.g. "2h ago" or "unknown"
+ */
+export function WELCOME_BACK(memberCount: number, lastActive: string): string {
+ if (memberCount === 0) {
+ return '── Apra Fleet ──────────────────────────────────────\nFleet ready. Register a member to get started.\n────────────────────────────────────────────────────';
+ }
+ const plural = memberCount !== 1 ? 's' : '';
+ return `── Apra Fleet ──────────────────────────────────────\nFleet: ${memberCount} member${plural} · Last active: ${lastActive}\n────────────────────────────────────────────────────`;
+}
+
+/**
+ * Nudge shown after the user registers their first member.
+ * @param memberType "local" | "remote"
+ * @param memberName Registered name to show in the example (default: "my-server")
+ */
+export function NUDGE_AFTER_FIRST_REGISTER(memberType: string, memberName = 'my-server'): string {
+ const displayName = memberName.length > 20 ? memberName.slice(0, 20) + '…' : memberName;
+ if (memberType === 'remote') {
+ return `\n┌─ Tip ──────────────────────────────────────────────────────┐\n│ 🔑 Upgrade to key-based auth for this member: │\n│ 'Set up key-based auth for this member' — more secure. │\n└────────────────────────────────────────────────────────────┘`;
+ }
+ return `\n┌─ Tip ──────────────────────────────────────────────────────┐\n│ 🚀 Member registered! Give it work: │\n│ 'Ask ${displayName} to run the test suite'${' '.repeat(Math.max(1, 28 - displayName.length))}│\n└────────────────────────────────────────────────────────────┘`;
+}
+
+/**
+ * Nudge shown after the user runs their first prompt.
+ */
+export function NUDGE_AFTER_FIRST_PROMPT(): string {
+ return `\n┌─ Tip ──────────────────────────────────────────────────────┐\n│ 📊 Monitor your fleet anytime: │\n│ 'Show fleet status' — online members, last activity. │\n└────────────────────────────────────────────────────────────┘`;
+}
+
+/**
+ * Nudge shown after the user registers 2+ members (introduces PM skill).
+ */
+export function NUDGE_AFTER_MULTI_MEMBER(): string {
+ return `\n┌─ Tip ──────────────────────────────────────────────────────┐\n│ 🤝 You have multiple members — try the PM skill: │\n│ /pm init → /pm pair → /pm plan │\n│ One member builds, another reviews — across machines. │\n└────────────────────────────────────────────────────────────┘`;
+}
+
+/**
+ * Server-level instruction injected once at `initialize` time.
+ * Instructs the LLM to reproduce verbatim any text inside tags.
+ */
+export const VERBATIM_INSTRUCTIONS = `When a tool response contains text wrapped in \`…\` tags, that content is user-facing onboarding material. Reproduce it VERBATIM in your reply to the user as the very first thing you output, preserving all formatting, line breaks, and emoji. Do not paraphrase, summarize, translate, or comment on it. Do not wrap it in code fences. Strip the \`\` tags themselves — they are for your recognition only.`;
diff --git a/src/services/onboarding.ts b/src/services/onboarding.ts
new file mode 100644
index 00000000..20513f42
--- /dev/null
+++ b/src/services/onboarding.ts
@@ -0,0 +1,218 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { OnboardingState } from '../types.js';
+import { FLEET_DIR } from '../paths.js';
+import { enforceOwnerOnly } from '../utils/file-permissions.js';
+import { BANNER, GETTING_STARTED_GUIDE, WELCOME_BACK, NUDGE_AFTER_FIRST_REGISTER, NUDGE_AFTER_FIRST_PROMPT, NUDGE_AFTER_MULTI_MEMBER } from '../onboarding/text.js';
+import { getAllAgents } from './registry.js';
+
+const ONBOARDING_PATH = path.join(FLEET_DIR, 'onboarding.json');
+
+/**
+ * Tools that should NOT consume the onboarding banner or welcome-back preamble.
+ * These are diagnostic/administrative tools that may be called automatically
+ * by the client before the user's first meaningful interaction.
+ */
+const PASSIVE_TOOLS = new Set(['version', 'shutdown_server']);
+
+const DEFAULT_STATE: OnboardingState = {
+ bannerShown: false,
+ firstMemberRegistered: false,
+ firstPromptExecuted: false,
+ multiMemberNudgeShown: false,
+};
+
+// In-memory singleton — loaded once at server start.
+// All reads use this copy; JS event loop serializes access (no concurrent-read races).
+let _state: OnboardingState | null = null;
+
+// Runtime-only flag — not persisted to disk.
+export let welcomeBackShownThisSession = false;
+
+function ensureFleetDir(): void {
+ if (!fs.existsSync(FLEET_DIR)) {
+ fs.mkdirSync(FLEET_DIR, { recursive: true, mode: 0o700 });
+ }
+}
+
+/**
+ * Load onboarding state from disk into the in-memory singleton.
+ * Call once at server startup. Missing file = fresh install (all false).
+ * If the registry already has members but no onboarding file, this is an
+ * upgrade: pre-set bannerShown=true so existing users don't see the banner.
+ */
+export function loadOnboardingState(existingMemberCount = 0): OnboardingState {
+ ensureFleetDir();
+
+ if (!fs.existsSync(ONBOARDING_PATH)) {
+ const state: OnboardingState = { ...DEFAULT_STATE };
+ if (existingMemberCount > 0) {
+ // Upgrade path: existing registry, no onboarding file → skip banner
+ state.bannerShown = true;
+ }
+ _state = state;
+ return _state;
+ }
+
+ try {
+ const raw = fs.readFileSync(ONBOARDING_PATH, 'utf-8');
+ const parsed = JSON.parse(raw) as Partial;
+ // Merge with defaults so new fields added in future upgrades get falsy defaults
+ _state = { ...DEFAULT_STATE, ...parsed };
+ } catch {
+ // Corrupted file → treat as fresh install, log warning
+ process.stderr.write('[apra-fleet] Warning: onboarding.json is corrupted; resetting to defaults.\n');
+ _state = { ...DEFAULT_STATE };
+ }
+
+ return _state;
+}
+
+/**
+ * Persist the current in-memory state to disk atomically (temp write + rename).
+ */
+export function saveOnboardingState(): void {
+ if (_state === null) return;
+ ensureFleetDir();
+
+ const tmp = ONBOARDING_PATH + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(_state, null, 2), { mode: 0o600 });
+ fs.renameSync(tmp, ONBOARDING_PATH);
+ enforceOwnerOnly(ONBOARDING_PATH);
+}
+
+/**
+ * Get the current in-memory state. Loads from disk if not yet loaded (fallback for tests).
+ */
+export function getOnboardingState(): OnboardingState {
+ if (_state === null) loadOnboardingState();
+ return _state!;
+}
+
+/**
+ * Returns true if the milestone has NOT yet been shown (i.e., it should be shown now).
+ */
+export function shouldShow(key: keyof OnboardingState): boolean {
+ return !getOnboardingState()[key];
+}
+
+/**
+ * Mark a milestone as reached. Updates in-memory state and persists to disk immediately.
+ */
+export function advanceMilestone(key: keyof OnboardingState): void {
+ const state = getOnboardingState();
+ if (state[key]) return; // Already advanced — no-op
+ state[key] = true;
+ saveOnboardingState();
+}
+
+/**
+ * Reset session-level runtime flags. Call at server startup after loadOnboardingState().
+ */
+export function resetSessionFlags(): void {
+ welcomeBackShownThisSession = false;
+}
+
+/**
+ * Mark welcome-back as shown for this server session.
+ */
+export function markWelcomeBackShown(): void {
+ welcomeBackShownThisSession = true;
+}
+
+/**
+ * Returns true if this tool should be able to consume onboarding preambles.
+ * Passive tools (version, shutdown_server) should never consume the banner.
+ */
+export function isActiveTool(toolName: string): boolean {
+ return !PASSIVE_TOOLS.has(toolName);
+}
+
+/**
+ * Returns true if the tool response is JSON-formatted (starts with `{` or `[`).
+ * Used by wrapTool to skip prepending onboarding text to structured data responses.
+ * Covers: fleet_status, list_members, member_detail, monitor_task.
+ */
+export function isJsonResponse(result: string): boolean {
+ return result.startsWith('{') || result.startsWith('[');
+}
+
+/**
+ * Returns the first-run banner + getting started guide if this is the first tool
+ * call after a fresh install. Marks bannerShown and persists immediately so a
+ * server crash won't re-show the banner.
+ * Returns null if the banner has already been shown.
+ */
+export function getFirstRunPreamble(): string | null {
+ const state = getOnboardingState();
+ if (state.bannerShown) return null;
+ advanceMilestone('bannerShown');
+ return BANNER + '\n' + GETTING_STARTED_GUIDE;
+}
+
+/**
+ * Post-tool contextual nudge. Called by wrapTool after every tool invocation.
+ * Uses input.member_type directly — no response string parsing for type.
+ * Each nudge fires at most once (milestone flag prevents repeat).
+ */
+export function getOnboardingNudge(toolName: string, input: any, result: string): string | null {
+ if (toolName === 'register_member' && result.startsWith('✅')) {
+ if (shouldShow('firstMemberRegistered')) {
+ advanceMilestone('firstMemberRegistered');
+ return NUDGE_AFTER_FIRST_REGISTER(input.member_type as string, input.friendly_name as string);
+ }
+ if (shouldShow('multiMemberNudgeShown')) {
+ const agents = getAllAgents();
+ if (agents.length >= 2) {
+ advanceMilestone('multiMemberNudgeShown');
+ return NUDGE_AFTER_MULTI_MEMBER();
+ }
+ }
+ }
+ if (toolName === 'execute_prompt' && result.startsWith('📋')) {
+ if (shouldShow('firstPromptExecuted')) {
+ advanceMilestone('firstPromptExecuted');
+ return NUDGE_AFTER_FIRST_PROMPT();
+ }
+ }
+ return null;
+}
+
+function formatLastActive(agents: { lastUsed?: string }[]): string {
+ const times = agents
+ .map(a => a.lastUsed)
+ .filter((t): t is string => Boolean(t))
+ .map(t => new Date(t).getTime())
+ .filter(t => !isNaN(t)); // guard against malformed date strings
+ if (times.length === 0) return 'unknown';
+ const diff = Date.now() - Math.max(...times);
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+/**
+ * Welcome-back preamble for non-first-run server starts.
+ * Shown once per server lifecycle (session flag prevents repeat).
+ * Returns null if this is the first run (banner not yet shown) or already shown this session.
+ */
+export function getWelcomeBackPreamble(): string | null {
+ const state = getOnboardingState();
+ if (!state.bannerShown) return null; // first run — banner will handle it
+ if (welcomeBackShownThisSession) return null;
+ markWelcomeBackShown();
+ const agents = getAllAgents();
+ const lastActive = formatLastActive(agents);
+ return WELCOME_BACK(agents.length, lastActive);
+}
+
+/**
+ * Reset module state — used in tests only.
+ */
+export function _resetForTest(): void {
+ _state = null;
+ welcomeBackShownThisSession = false;
+}
diff --git a/src/tools/register-member.ts b/src/tools/register-member.ts
index f99d6f18..16c649ef 100644
--- a/src/tools/register-member.ts
+++ b/src/tools/register-member.ts
@@ -19,13 +19,13 @@ export const registerMemberSchema = z.object({
.regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, dashes, and underscores')
.describe('Human-friendly name for this member (worker) (e.g. "web-server")'),
member_type: z.enum(['local', 'remote']).default('remote').describe('Member type: "local" for same machine, "remote" for SSH (default: "remote")'),
- host: z.string().optional().describe('IP address or hostname of the remote machine (required for non-cloud remote members; optional for cloud members — auto-resolved from AWS when running)'),
+ host: z.string().regex(/^[^<>\n\r]+$/, 'host must not contain angle brackets or newlines').optional().describe('IP address or hostname of the remote machine (required for non-cloud remote members; optional for cloud members — auto-resolved from AWS when running)'),
port: z.number().default(22).describe('SSH port (default: 22, remote members only)'),
username: z.string().optional().describe('SSH username (required for remote members)'),
auth_type: z.enum(['password', 'key']).optional().describe('Authentication method (required for non-cloud remote members; cloud members default to "key")'),
password: z.string().optional().describe('SSH password. Omit for secure out-of-band entry — a password prompt will open in a separate terminal window.'),
key_path: z.string().optional().describe('Path to SSH private key. Used for both regular SSH connections and cloud instance lifecycle.'),
- work_folder: z.string().describe('Working directory on the target machine'),
+ work_folder: z.string().regex(/^[^<>\n\r]+$/, 'work_folder must not contain angle brackets or newlines').describe('Working directory on the target machine'),
git_access: z.enum(['read', 'push', 'admin', 'issues', 'full']).optional().describe('Git access level for this member'),
git_repos: z.array(z.string()).optional().describe('Git repositories this member can access (e.g. ["Apra-Labs/ApraPipes"])'),
// Cloud fields
diff --git a/src/tools/update-member.ts b/src/tools/update-member.ts
index 20ede33c..8ec50555 100644
--- a/src/tools/update-member.ts
+++ b/src/tools/update-member.ts
@@ -14,7 +14,10 @@ export const updateMemberSchema = z.object({
.regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, dashes, and underscores')
.optional()
.describe('New friendly name'),
- host: z.string().optional().describe('New host (remote members only)'),
+ host: z.string()
+ .regex(/^[^<>\n\r]+$/, 'host must not contain angle brackets or newlines')
+ .optional()
+ .describe('New host (remote members only)'),
port: z.number().optional().describe('New SSH port (remote members only)'),
username: z.string().optional().describe('New SSH username (remote members only)'),
auth_type: z.enum(['password', 'key']).optional().describe('New auth method (remote members only)'),
@@ -24,7 +27,10 @@ export const updateMemberSchema = z.object({
+ 'A password prompt will open in a separate terminal window. Ignored if auth_type is not password.'
),
key_path: z.string().optional().describe('Path to SSH private key. Used for both regular SSH connections and cloud instance lifecycle.'),
- work_folder: z.string().optional().describe('New working directory on target machine'),
+ work_folder: z.string()
+ .regex(/^[^<>\n\r]+$/, 'work_folder must not contain angle brackets or newlines')
+ .optional()
+ .describe('New working directory on target machine'),
git_access: z.enum(['read', 'push', 'admin', 'issues', 'full']).optional().describe('Git access level for this member'),
git_repos: z.array(z.string()).optional().describe('Git repositories this member can access (e.g. ["Apra-Labs/ApraPipes"])'),
icon: z.string().optional().describe('Override the auto-assigned emoji icon. Use named aliases: blue-circle, green-square, red-circle, etc. (8 colors × 2 shapes: circle, square). Or pass raw emoji.'),
diff --git a/src/types.ts b/src/types.ts
index d6d7b99d..cff6fa1b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -57,3 +57,10 @@ export interface SSHExecResult {
stderr: string;
code: number;
}
+
+export interface OnboardingState {
+ bannerShown: boolean;
+ firstMemberRegistered: boolean;
+ firstPromptExecuted: boolean;
+ multiMemberNudgeShown: boolean;
+}
diff --git a/tests/onboarding-smoke.mjs b/tests/onboarding-smoke.mjs
new file mode 100644
index 00000000..53236446
--- /dev/null
+++ b/tests/onboarding-smoke.mjs
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+/**
+ * Smoke test: simulates the full onboarding flow as it runs inside the MCP server.
+ * Run in a clean state to verify banner, welcome-back, nudges, and passive tool guard.
+ *
+ * Usage:
+ * node tests/onboarding-smoke.mjs
+ *
+ * This script:
+ * 1. Clears onboarding state + empties the registry
+ * 2. Boots the onboarding module (same as server startup)
+ * 3. Simulates wrapTool calls for various tools
+ * 4. Prints what the user would see at each step
+ * 5. Restores the registry backup when done
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+// Use a temp dir so we don't touch real state
+const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'apra-fleet-smoke-'));
+process.env.APRA_FLEET_DATA_DIR = TEST_DIR;
+
+const REGISTRY_PATH = path.join(TEST_DIR, 'registry.json');
+const ONBOARDING_PATH = path.join(TEST_DIR, 'onboarding.json');
+
+// Write empty registry
+fs.writeFileSync(REGISTRY_PATH, JSON.stringify({ version: '1.0', agents: [] }), { mode: 0o600 });
+
+try {
+
+// Import after setting env
+const {
+ loadOnboardingState, resetSessionFlags, getFirstRunPreamble,
+ isJsonResponse, isActiveTool, getOnboardingNudge, getWelcomeBackPreamble,
+ getOnboardingState, _resetForTest,
+} = await import('../dist/services/onboarding.js');
+
+// --- Simulate server startup ---
+const agentCount = 0; // fresh install
+const state = loadOnboardingState(agentCount);
+resetSessionFlags();
+
+console.log('\n=== ONBOARDING SMOKE TEST ===\n');
+console.log(`Startup: agentCount=${agentCount} bannerShown=${state.bannerShown}`);
+console.log(`Expected: bannerShown=false\n`);
+
+if (state.bannerShown !== false) {
+ console.error('FAIL: bannerShown should be false on fresh install with 0 agents');
+ process.exit(1);
+}
+
+// --- Simulate wrapTool helper ---
+// Banner bypasses JSON check; welcome-back and nudges still respect it.
+// Optional notify callback is called for preamble and suffix (simulates sendLoggingMessage).
+function simulateWrapTool(toolName, result, notify) {
+ const isJson = isJsonResponse(result);
+ let preamble = null;
+ if (isActiveTool(toolName)) {
+ const banner = getFirstRunPreamble();
+ if (banner) {
+ preamble = banner;
+ } else if (!isJson) {
+ preamble = getWelcomeBackPreamble();
+ }
+ }
+ const suffix = isJson ? null : getOnboardingNudge(toolName, {}, result);
+
+ // Channel 1: notify callback (simulates out-of-band sendLoggingMessage)
+ if (preamble && notify) notify(preamble);
+ if (suffix && notify) notify(suffix);
+
+ // Build content blocks with markers
+ const content = [];
+ if (preamble) {
+ content.push({ type: 'text', text: `\n${preamble}\n`, annotations: { audience: ['user'], priority: 1 } });
+ }
+ content.push({ type: 'text', text: result });
+ if (suffix) {
+ content.push({ type: 'text', text: `\n${suffix}\n`, annotations: { audience: ['user'], priority: 0.8 } });
+ }
+
+ return { preamble, result, suffix, content };
+}
+
+// --- Test 1: version (passive) should NOT consume banner ---
+console.log('--- Test 1: version (passive tool) ---');
+const t1 = simulateWrapTool('version', 'apra-fleet v0.1.4');
+console.log(` preamble: ${t1.preamble ? 'YES (' + t1.preamble.length + ' chars)' : 'null'}`);
+console.log(` bannerShown: ${getOnboardingState().bannerShown}`);
+console.log(` Expected: preamble=null, bannerShown=false`);
+if (t1.preamble !== null) { console.error(' FAIL'); process.exit(1); }
+if (getOnboardingState().bannerShown !== false) { console.error(' FAIL'); process.exit(1); }
+console.log(' PASS\n');
+
+// --- Test 2: fleet_status with JSON response — banner bypasses JSON check ---
+console.log('--- Test 2: fleet_status (active tool, JSON response) should show banner ---');
+const t2NotifyCalls = [];
+const t2 = simulateWrapTool('fleet_status', '{"members":[]}', (text) => t2NotifyCalls.push(text));
+console.log(` preamble: ${t2.preamble ? 'YES (' + t2.preamble.length + ' chars)' : 'null'}`);
+console.log(` contains banner: ${t2.preamble?.includes('One model is a tool') ?? false}`);
+console.log(` contains guide: ${t2.preamble?.includes('Getting Started') ?? false}`);
+console.log(` bannerShown: ${getOnboardingState().bannerShown}`);
+console.log(` Expected: preamble=YES (banner+guide even for JSON), bannerShown=true`);
+if (!t2.preamble) { console.error(' FAIL: no preamble — banner must bypass JSON check'); process.exit(1); }
+if (!t2.preamble.includes('One model is a tool')) { console.error(' FAIL: missing banner'); process.exit(1); }
+console.log(' PASS\n');
+
+// --- Test 2b: notify callback was called with banner text ---
+console.log('--- Test 2b: notify callback called with banner text ---');
+console.log(` notify call count: ${t2NotifyCalls.length}`);
+console.log(` Expected: at least 1 call with banner text`);
+if (t2NotifyCalls.length === 0) { console.error(' FAIL: notify was never called'); process.exit(1); }
+if (!t2NotifyCalls[0].includes('One model is a tool')) { console.error(' FAIL: notify not called with banner text'); process.exit(1); }
+console.log(' PASS\n');
+
+// --- Test 3: second call should NOT show banner again ---
+console.log('--- Test 3: fleet_status (second call) ---');
+const t3 = simulateWrapTool('fleet_status', 'No members registered.');
+console.log(` preamble: ${t3.preamble ? 'YES (welcome-back)' : 'null'}`);
+console.log(` Expected: preamble=null (welcome-back already shown in t2 preamble fallback)`);
+// welcome-back shows once per session; it was consumed by t2's getWelcomeBackPreamble path
+// Actually t2 consumed the banner, not welcome-back. t3 tries banner (null) then welcome-back.
+const isWelcomeBack = t3.preamble?.includes('Fleet') ?? false;
+console.log(` is welcome-back: ${isWelcomeBack}`);
+console.log(' PASS (welcome-back shown once is acceptable)\n');
+
+// --- Test 4: register_member nudge ---
+console.log('--- Test 4: register_member nudge ---');
+// Add an agent to registry so nudge can check
+fs.writeFileSync(REGISTRY_PATH, JSON.stringify({ version: '1.0', agents: [
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }
+] }), { mode: 0o600 });
+// Use simulateWrapTool to capture both the suffix and the content blocks in one call
+const t4NotifyCalls = [];
+const t4 = simulateWrapTool('register_member', '\u2705 Member registered.', (text) => t4NotifyCalls.push(text));
+console.log(` nudge: ${t4.suffix ? 'YES' : 'null'}`);
+console.log(` contains rocket: ${t4.suffix?.includes('\u{1F680}') ?? false}`);
+console.log(` Expected: nudge with member name`);
+if (!t4.suffix) { console.error(' FAIL: no nudge'); process.exit(1); }
+console.log(' PASS\n');
+
+// --- Test 4b: content blocks contain markers ---
+console.log('--- Test 4b: content blocks contain markers ---');
+// t4 already contains the content from the same call — check the suffix block
+console.log(` content blocks: ${t4.content.length}`);
+console.log(` Expected: suffix block wrapped in markers`);
+const t4bSuffixBlock = t4.content.find(b => b.text.includes('') && b.text.includes('\u{1F680}'));
+if (!t4bSuffixBlock) { console.error(' FAIL: no content block with markers around nudge'); process.exit(1); }
+if (!t4bSuffixBlock.text.startsWith('')) { console.error(' FAIL: block does not start with opening marker'); process.exit(1); }
+if (!t4bSuffixBlock.text.endsWith('')) { console.error(' FAIL: block does not end with closing marker'); process.exit(1); }
+console.log(' PASS\n');
+
+// --- Test 5: Simulate server restart (welcome-back) ---
+console.log('--- Test 5: Server restart (welcome-back) ---');
+_resetForTest();
+loadOnboardingState(); // reload from disk (bannerShown=true now persisted)
+resetSessionFlags();
+const t5 = simulateWrapTool('fleet_status', 'Fleet: 1 member.');
+console.log(` preamble: ${t5.preamble ? 'YES' : 'null'}`);
+console.log(` is welcome-back: ${t5.preamble?.includes('Fleet') ?? false}`);
+console.log(` Expected: welcome-back preamble`);
+if (!t5.preamble) { console.error(' FAIL: no welcome-back'); process.exit(1); }
+console.log(' PASS\n');
+
+console.log('=== ALL TESTS PASSED ===\n');
+
+} finally {
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
+}
+
diff --git a/tests/onboarding-text.test.ts b/tests/onboarding-text.test.ts
new file mode 100644
index 00000000..327b90bf
--- /dev/null
+++ b/tests/onboarding-text.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect } from 'vitest';
+import {
+ BANNER,
+ GETTING_STARTED_GUIDE,
+ WELCOME_BACK,
+ NUDGE_AFTER_FIRST_REGISTER,
+ NUDGE_AFTER_FIRST_PROMPT,
+ NUDGE_AFTER_MULTI_MEMBER,
+ VERBATIM_INSTRUCTIONS,
+} from '../src/onboarding/text.js';
+
+describe('BANNER', () => {
+ it('contains the ASCII art header line', () => {
+ expect(BANNER).toContain('█████╗ ██████╗ ██████╗');
+ });
+
+ it('contains the tagline', () => {
+ expect(BANNER).toContain('One model is a tool. A fleet is a team.');
+ });
+
+ it('contains the separator lines', () => {
+ expect(BANNER).toContain('────────────────────────────────────────────────────────────────────────────────');
+ });
+});
+
+describe('GETTING_STARTED_GUIDE', () => {
+ it('covers adding a member', () => {
+ expect(GETTING_STARTED_GUIDE).toContain('Add your first member');
+ });
+
+ it('covers giving it work with natural language examples', () => {
+ expect(GETTING_STARTED_GUIDE).toContain('Ask my-server to run the test suite');
+ expect(GETTING_STARTED_GUIDE).toContain('Send the src/ folder to my-server and run the build');
+ });
+
+ it('covers checking status', () => {
+ expect(GETTING_STARTED_GUIDE).toContain('Show fleet status');
+ });
+
+ it('does not include the /pm step', () => {
+ expect(GETTING_STARTED_GUIDE).not.toContain('/pm init');
+ });
+});
+
+describe('WELCOME_BACK', () => {
+ it('shows member count and last active time', () => {
+ const msg = WELCOME_BACK(3, '2h ago');
+ expect(msg).toContain('3 member');
+ expect(msg).toContain('2h ago');
+ expect(msg).not.toContain('online');
+ });
+
+ it('uses singular "member" for count of 1', () => {
+ const msg = WELCOME_BACK(1, '5m ago');
+ expect(msg).toContain('1 member');
+ expect(msg).not.toContain('1 members');
+ });
+
+ it('shows fallback message when fleet has no members', () => {
+ const msg = WELCOME_BACK(0, 'unknown');
+ expect(msg).toContain('Fleet ready');
+ });
+});
+
+describe('NUDGE_AFTER_FIRST_REGISTER', () => {
+ it('suggests SSH key setup for remote members', () => {
+ const msg = NUDGE_AFTER_FIRST_REGISTER('remote');
+ expect(msg).toContain('key-based auth');
+ expect(msg).toContain('🔑');
+ });
+
+ it('suggests giving work to local members using default name', () => {
+ const msg = NUDGE_AFTER_FIRST_REGISTER('local');
+ expect(msg).toContain('my-server');
+ expect(msg).toContain('🚀');
+ });
+
+ it('uses the actual member name when provided', () => {
+ const msg = NUDGE_AFTER_FIRST_REGISTER('local', 'build-box');
+ expect(msg).toContain('build-box');
+ expect(msg).not.toContain('my-server');
+ });
+
+ it('truncates long friendly_name to 20 chars with ellipsis and keeps box border aligned', () => {
+ const longName = 'a'.repeat(35); // 35-char name
+ const msg = NUDGE_AFTER_FIRST_REGISTER('local', longName);
+ // Truncated to 20 chars + '…'
+ const truncated = 'a'.repeat(20) + '…';
+ expect(msg).toContain(truncated);
+ // The full 35-char name must NOT appear in the output
+ expect(msg).not.toContain(longName);
+ // Each line of the box must not exceed the box width (62 visible chars per line including │ borders)
+ const lines = msg.split('\n');
+ for (const line of lines) {
+ if (line.startsWith('│')) {
+ // Count visible length (the '…' is 1 char in JS string length but 3 UTF-8 bytes;
+ // we measure JS .length which is what matters for the repeat() padding calculation)
+ expect(line.length).toBeLessThanOrEqual(63);
+ }
+ }
+ });
+});
+
+describe('NUDGE_AFTER_FIRST_PROMPT', () => {
+ it('suggests checking fleet status', () => {
+ const msg = NUDGE_AFTER_FIRST_PROMPT();
+ expect(msg).toContain('Show fleet status');
+ expect(msg).toContain('📊');
+ });
+});
+
+describe('NUDGE_AFTER_MULTI_MEMBER', () => {
+ it('mentions PM skill commands', () => {
+ const msg = NUDGE_AFTER_MULTI_MEMBER();
+ expect(msg).toContain('/pm init');
+ expect(msg).toContain('/pm pair');
+ expect(msg).toContain('/pm plan');
+ });
+});
+
+describe('VERBATIM_INSTRUCTIONS', () => {
+ it('is a non-empty string', () => {
+ expect(typeof VERBATIM_INSTRUCTIONS).toBe('string');
+ expect(VERBATIM_INSTRUCTIONS.length).toBeGreaterThan(0);
+ });
+
+ it('references the apra-fleet-display marker tag', () => {
+ expect(VERBATIM_INSTRUCTIONS).toContain('');
+ expect(VERBATIM_INSTRUCTIONS).toContain('');
+ });
+
+ it('instructs the client LLM to reproduce content verbatim', () => {
+ // Must convey "verbatim / exact / literal" intent
+ expect(VERBATIM_INSTRUCTIONS.toLowerCase()).toMatch(/verbatim|exactly|literal/);
+ });
+
+ it('instructs the client not to paraphrase or summarize', () => {
+ // Guards against LLM's default summarize-tool-output behavior
+ expect(VERBATIM_INSTRUCTIONS.toLowerCase()).toMatch(/not.*(paraphrase|summar)/);
+ });
+
+ it('tells the client to strip the marker tags themselves', () => {
+ // Otherwise users would see the literal tags in output
+ expect(VERBATIM_INSTRUCTIONS.toLowerCase()).toMatch(/strip|remove|omit/);
+ });
+});
diff --git a/tests/onboarding.test.ts b/tests/onboarding.test.ts
new file mode 100644
index 00000000..a5aafaad
--- /dev/null
+++ b/tests/onboarding.test.ts
@@ -0,0 +1,922 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+// Use a fresh temp dir per test run — setup.ts sets APRA_FLEET_DATA_DIR
+const FLEET_DIR = process.env.APRA_FLEET_DATA_DIR ?? path.join(os.tmpdir(), 'apra-fleet-test-data');
+const ONBOARDING_PATH = path.join(FLEET_DIR, 'onboarding.json');
+const REGISTRY_PATH = path.join(FLEET_DIR, 'registry.json');
+
+function ensureFleetDir() {
+ if (!fs.existsSync(FLEET_DIR)) fs.mkdirSync(FLEET_DIR, { recursive: true });
+}
+
+function removeOnboardingFile() {
+ if (fs.existsSync(ONBOARDING_PATH)) fs.rmSync(ONBOARDING_PATH);
+}
+
+function removeRegistryFile() {
+ if (fs.existsSync(REGISTRY_PATH)) fs.rmSync(REGISTRY_PATH);
+}
+
+function writeRegistry(agents: object[]) {
+ fs.writeFileSync(REGISTRY_PATH, JSON.stringify({ version: '1.0', agents }), { mode: 0o600 });
+}
+
+beforeEach(async () => {
+ ensureFleetDir();
+ removeOnboardingFile();
+ removeRegistryFile();
+ // Reset module state between tests
+ const mod = await import('../src/services/onboarding.js');
+ mod._resetForTest();
+});
+
+afterEach(() => {
+ removeOnboardingFile();
+ removeRegistryFile();
+});
+
+describe('loadOnboardingState', () => {
+ it('returns default state when file is missing', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ const state = loadOnboardingState();
+ expect(state.bannerShown).toBe(false);
+ expect(state.firstMemberRegistered).toBe(false);
+ expect(state.firstPromptExecuted).toBe(false);
+ expect(state.multiMemberNudgeShown).toBe(false);
+ });
+
+ it('does not create file on load (only on write)', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ expect(fs.existsSync(ONBOARDING_PATH)).toBe(false);
+ });
+
+ it('pre-sets bannerShown=true when existing members exist (upgrade path)', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ const state = loadOnboardingState(3); // 3 existing members
+ expect(state.bannerShown).toBe(true);
+ });
+
+ it('loads persisted state from disk', async () => {
+ ensureFleetDir();
+ const saved = { bannerShown: true, firstMemberRegistered: true, firstPromptExecuted: false, multiMemberNudgeShown: false };
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify(saved), { mode: 0o600 });
+
+ const { loadOnboardingState, _resetForTest } = await import('../src/services/onboarding.js');
+ _resetForTest();
+ const state = loadOnboardingState();
+ expect(state.bannerShown).toBe(true);
+ expect(state.firstMemberRegistered).toBe(true);
+ expect(state.firstPromptExecuted).toBe(false);
+ });
+
+ it('treats corrupted JSON as default state and does not throw', async () => {
+ ensureFleetDir();
+ fs.writeFileSync(ONBOARDING_PATH, 'not-valid-json{{{', { mode: 0o600 });
+
+ const { loadOnboardingState, _resetForTest } = await import('../src/services/onboarding.js');
+ _resetForTest();
+ const state = loadOnboardingState();
+ expect(state.bannerShown).toBe(false);
+ expect(state.firstMemberRegistered).toBe(false);
+ });
+
+ it('merges missing fields with defaults (forward-compatibility)', async () => {
+ ensureFleetDir();
+ // File written by older version only has some fields
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true }), { mode: 0o600 });
+
+ const { loadOnboardingState, _resetForTest } = await import('../src/services/onboarding.js');
+ _resetForTest();
+ const state = loadOnboardingState();
+ expect(state.bannerShown).toBe(true);
+ expect(state.firstMemberRegistered).toBe(false); // defaulted
+ expect(state.multiMemberNudgeShown).toBe(false); // defaulted
+ });
+});
+
+describe('saveOnboardingState', () => {
+ it('persists in-memory state to disk atomically', async () => {
+ const { loadOnboardingState, advanceMilestone, _resetForTest } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ advanceMilestone('bannerShown');
+
+ // File should now exist with bannerShown=true
+ expect(fs.existsSync(ONBOARDING_PATH)).toBe(true);
+ const ondisk = JSON.parse(fs.readFileSync(ONBOARDING_PATH, 'utf-8'));
+ expect(ondisk.bannerShown).toBe(true);
+
+ // Reload and verify persistence
+ _resetForTest();
+ const reloaded = loadOnboardingState();
+ expect(reloaded.bannerShown).toBe(true);
+ });
+
+ it('leaves no temp file behind after atomic write', async () => {
+ const { loadOnboardingState, saveOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ saveOnboardingState();
+
+ const tmp = ONBOARDING_PATH + '.tmp';
+ expect(fs.existsSync(tmp)).toBe(false);
+ expect(fs.existsSync(ONBOARDING_PATH)).toBe(true);
+ });
+
+ it('writes onboarding.json with 0o600 permissions (owner-only, non-Windows)', async () => {
+ if (process.platform === 'win32') return;
+ const { loadOnboardingState, saveOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ saveOnboardingState();
+
+ const stat = fs.statSync(ONBOARDING_PATH);
+ // Mask to lower 9 permission bits
+ const perms = stat.mode & 0o777;
+ expect(perms).toBe(0o600);
+ });
+});
+
+describe('advanceMilestone', () => {
+ it('advances a single milestone and persists', async () => {
+ const { loadOnboardingState, advanceMilestone, shouldShow } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ expect(shouldShow('firstMemberRegistered')).toBe(true);
+ advanceMilestone('firstMemberRegistered');
+ expect(shouldShow('firstMemberRegistered')).toBe(false);
+
+ const ondisk = JSON.parse(fs.readFileSync(ONBOARDING_PATH, 'utf-8'));
+ expect(ondisk.firstMemberRegistered).toBe(true);
+ });
+
+ it('is idempotent — calling twice does not change state', async () => {
+ const { loadOnboardingState, advanceMilestone, getOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ advanceMilestone('bannerShown');
+ advanceMilestone('bannerShown'); // second call should be no-op
+ expect(getOnboardingState().bannerShown).toBe(true);
+ });
+
+ it('advances all milestones independently', async () => {
+ const { loadOnboardingState, advanceMilestone, shouldShow } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ advanceMilestone('bannerShown');
+ advanceMilestone('firstPromptExecuted');
+
+ expect(shouldShow('bannerShown')).toBe(false);
+ expect(shouldShow('firstPromptExecuted')).toBe(false);
+ expect(shouldShow('firstMemberRegistered')).toBe(true); // not advanced
+ expect(shouldShow('multiMemberNudgeShown')).toBe(true); // not advanced
+ });
+});
+
+describe('shouldShow', () => {
+ it('returns true for unset milestones', async () => {
+ const { loadOnboardingState, shouldShow } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ expect(shouldShow('bannerShown')).toBe(true);
+ });
+
+ it('returns false for set milestones', async () => {
+ const { loadOnboardingState, advanceMilestone, shouldShow } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ advanceMilestone('bannerShown');
+ expect(shouldShow('bannerShown')).toBe(false);
+ });
+});
+
+describe('isActiveTool', () => {
+ it('returns false for version tool', async () => {
+ const { isActiveTool } = await import('../src/services/onboarding.js');
+ expect(isActiveTool('version')).toBe(false);
+ });
+
+ it('returns false for shutdown_server tool', async () => {
+ const { isActiveTool } = await import('../src/services/onboarding.js');
+ expect(isActiveTool('shutdown_server')).toBe(false);
+ });
+
+ it('returns true for register_member', async () => {
+ const { isActiveTool } = await import('../src/services/onboarding.js');
+ expect(isActiveTool('register_member')).toBe(true);
+ });
+
+ it('returns true for execute_prompt', async () => {
+ const { isActiveTool } = await import('../src/services/onboarding.js');
+ expect(isActiveTool('execute_prompt')).toBe(true);
+ });
+
+ it('returns true for fleet_status', async () => {
+ const { isActiveTool } = await import('../src/services/onboarding.js');
+ expect(isActiveTool('fleet_status')).toBe(true);
+ });
+});
+
+describe('isJsonResponse', () => {
+ it('returns true for object JSON', async () => {
+ const { isJsonResponse } = await import('../src/services/onboarding.js');
+ expect(isJsonResponse('{"members":[]}')).toBe(true);
+ expect(isJsonResponse('{ "foo": 1 }')).toBe(true);
+ });
+
+ it('returns true for array JSON', async () => {
+ const { isJsonResponse } = await import('../src/services/onboarding.js');
+ expect(isJsonResponse('[1,2,3]')).toBe(true);
+ expect(isJsonResponse('[ ]')).toBe(true);
+ });
+
+ it('returns false for non-JSON responses', async () => {
+ const { isJsonResponse } = await import('../src/services/onboarding.js');
+ expect(isJsonResponse('✅ Member registered.')).toBe(false);
+ expect(isJsonResponse('❌ Error: member not found')).toBe(false);
+ expect(isJsonResponse('Fleet ready.')).toBe(false);
+ expect(isJsonResponse('')).toBe(false);
+ });
+});
+
+describe('getFirstRunPreamble', () => {
+ it('returns banner + guide on fresh install (first call)', async () => {
+ const { loadOnboardingState, getFirstRunPreamble } = await import('../src/services/onboarding.js');
+ loadOnboardingState(); // fresh state — bannerShown = false
+
+ const result = getFirstRunPreamble();
+ expect(result).not.toBeNull();
+ expect(result).toContain('One model is a tool'); // tagline in ASCII art banner
+ expect(result).toContain('Getting Started'); // guide header
+ });
+
+ it('returns null on second call (banner already shown)', async () => {
+ const { loadOnboardingState, getFirstRunPreamble } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ getFirstRunPreamble(); // first call shows banner
+ const second = getFirstRunPreamble(); // second call must return null
+ expect(second).toBeNull();
+ });
+
+ it('persists bannerShown so server crash cannot re-show banner', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, _resetForTest } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ getFirstRunPreamble(); // marks bannerShown = true and writes to disk
+
+ // Simulate server restart
+ _resetForTest();
+ loadOnboardingState(); // reloads from disk
+
+ const result = getFirstRunPreamble();
+ expect(result).toBeNull(); // must not show banner again
+ });
+
+ it('returns null when state is loaded with bannerShown=true (upgrade/existing user)', async () => {
+ const { loadOnboardingState, getFirstRunPreamble } = await import('../src/services/onboarding.js');
+ loadOnboardingState(3); // 3 existing members → upgrade path → bannerShown=true
+
+ const result = getFirstRunPreamble();
+ expect(result).toBeNull();
+ });
+});
+
+describe('session flags', () => {
+ it('welcomeBackShownThisSession starts false', async () => {
+ const mod = await import('../src/services/onboarding.js');
+ mod._resetForTest();
+ expect(mod.welcomeBackShownThisSession).toBe(false);
+ });
+
+ it('markWelcomeBackShown sets the flag', async () => {
+ const mod = await import('../src/services/onboarding.js');
+ mod._resetForTest();
+ mod.markWelcomeBackShown();
+ expect(mod.welcomeBackShownThisSession).toBe(true);
+ });
+
+ it('resetSessionFlags clears the flag', async () => {
+ const mod = await import('../src/services/onboarding.js');
+ mod.markWelcomeBackShown();
+ mod.resetSessionFlags();
+ expect(mod.welcomeBackShownThisSession).toBe(false);
+ });
+});
+
+describe('getOnboardingNudge', () => {
+ it('shows NUDGE_AFTER_FIRST_REGISTER(local) on first register_member success', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+
+ const result = getOnboardingNudge('register_member', { member_type: 'local', friendly_name: 'alpha' }, '✅ Member registered.');
+ expect(result).not.toBeNull();
+ expect(result).toContain('alpha');
+ });
+
+ it('shows NUDGE_AFTER_FIRST_REGISTER(remote) with SSH key tip', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'remote', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+
+ const result = getOnboardingNudge('register_member', { member_type: 'remote' }, '✅ Member registered.');
+ expect(result).not.toBeNull();
+ expect(result).toContain('key-based auth');
+ });
+
+ it('does not show first-register nudge on second registration', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+
+ getOnboardingNudge('register_member', { member_type: 'local' }, '✅ Member registered.'); // first — consumes milestone
+ const second = getOnboardingNudge('register_member', { member_type: 'local' }, '✅ Member registered.');
+ // second call: firstMemberRegistered is set, multiMemberNudgeShown not set but only 1 agent → null
+ expect(second).toBeNull();
+ });
+
+ it('shows NUDGE_AFTER_MULTI_MEMBER when registry reaches 2+ members', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // First registration — only 1 agent in registry
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ getOnboardingNudge('register_member', { member_type: 'local' }, '✅ Member registered.'); // advances firstMemberRegistered
+
+ // Second registration — now 2 agents in registry
+ writeRegistry([
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() },
+ { id: '2', friendlyName: 'beta', agentType: 'remote', workFolder: '/tmp/b', createdAt: new Date().toISOString() },
+ ]);
+ const result = getOnboardingNudge('register_member', { member_type: 'remote' }, '✅ Member registered.');
+ expect(result).not.toBeNull();
+ expect(result).toContain('PM skill');
+ });
+
+ it('does not show multi-member nudge a second time', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ getOnboardingNudge('register_member', { member_type: 'local' }, '✅ Member registered.');
+
+ writeRegistry([
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() },
+ { id: '2', friendlyName: 'beta', agentType: 'remote', workFolder: '/tmp/b', createdAt: new Date().toISOString() },
+ ]);
+ getOnboardingNudge('register_member', { member_type: 'remote' }, '✅ Member registered.'); // consumes multiMemberNudgeShown
+
+ const third = getOnboardingNudge('register_member', { member_type: 'local' }, '✅ Member registered.');
+ expect(third).toBeNull();
+ });
+
+ it('shows NUDGE_AFTER_FIRST_PROMPT on first execute_prompt success', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ const result = getOnboardingNudge('execute_prompt', {}, '📋 Task submitted.');
+ expect(result).not.toBeNull();
+ expect(result).toContain('Show fleet status');
+ });
+
+ it('does not show prompt nudge on second execute_prompt', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ getOnboardingNudge('execute_prompt', {}, '📋 Task submitted.');
+ const second = getOnboardingNudge('execute_prompt', {}, '📋 Task submitted.');
+ expect(second).toBeNull();
+ });
+
+ it('ignores non-success register_member results', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ const result = getOnboardingNudge('register_member', { member_type: 'local' }, '❌ Member already exists.');
+ expect(result).toBeNull();
+ });
+
+ it('returns null for unrelated tool names', async () => {
+ const { loadOnboardingState, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ const result = getOnboardingNudge('fleet_status', {}, 'some output');
+ expect(result).toBeNull();
+ });
+});
+
+/**
+ * Integration tests: simulate wrapTool logic (isJson check → preamble → nudge).
+ * wrapTool is defined inside startServer() and can't be imported directly, so these
+ * tests replicate the same conditional logic to verify the REC-4 fix and overall
+ * banner + nudge composition.
+ */
+describe('wrapTool output sequence (integration)', () => {
+ it('banner shows on JSON response from active tool', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getWelcomeBackPreamble, getOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // New behavior: first-run banner bypasses JSON check — active tool guard is sufficient
+ const jsonResult = '{"members":[]}';
+ const isJson = isJsonResponse(jsonResult);
+ // Simulate new getOnboardingPreamble(toolName, isJson) logic
+ const banner = isActiveTool('fleet_status') ? getFirstRunPreamble() : null;
+ const preamble = banner ?? (!isJson ? getWelcomeBackPreamble() : null);
+
+ expect(preamble).not.toBeNull(); // banner shown even on JSON response
+ expect(preamble).toContain('One model is a tool');
+ expect(getOnboardingState().bannerShown).toBe(true);
+ });
+
+ it('banner shown on first JSON call; subsequent call gets null', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getWelcomeBackPreamble, getOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // First call: JSON (fleet_status) — banner now shown (bypasses JSON check)
+ const jsonResult = '{"members":[]}';
+ const isJson1 = isJsonResponse(jsonResult);
+ const banner1 = isActiveTool('fleet_status') ? getFirstRunPreamble() : null;
+ const p1 = banner1 ?? (!isJson1 ? getWelcomeBackPreamble() : null);
+ expect(p1).not.toBeNull();
+ expect(p1).toContain('One model is a tool');
+ expect(getOnboardingState().bannerShown).toBe(true);
+
+ // Second call: JSON (fleet_status again) — banner already consumed, welcome-back suppressed for JSON
+ const jsonResult2 = '{"members":[]}';
+ const isJson2 = isJsonResponse(jsonResult2);
+ const banner2 = isActiveTool('fleet_status') ? getFirstRunPreamble() : null;
+ const p2 = banner2 ?? (!isJson2 ? getWelcomeBackPreamble() : null);
+ expect(p2).toBeNull(); // banner consumed; welcome-back suppressed for JSON responses
+ });
+
+ it('passive tool (version) does NOT consume the banner', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // Simulate wrapTool for version tool: passive → skip preamble
+ const result = 'apra-fleet v1.0.0';
+ const preamble = (isJsonResponse(result) || !isActiveTool('version')) ? null : getFirstRunPreamble();
+
+ expect(preamble).toBeNull();
+ expect(getOnboardingState().bannerShown).toBe(false); // NOT consumed
+ });
+
+ it('banner is preserved after version call and consumed on register_member', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // version call — passive, no consumption
+ const v = 'apra-fleet v1.0.0';
+ const p1 = (isJsonResponse(v) || !isActiveTool('version')) ? null : getFirstRunPreamble();
+ expect(p1).toBeNull();
+ expect(getOnboardingState().bannerShown).toBe(false);
+
+ // register_member — active, consumes banner
+ const r = '✅ Member registered.';
+ const p2 = (isJsonResponse(r) || !isActiveTool('register_member')) ? null : getFirstRunPreamble();
+ expect(p2).not.toBeNull();
+ expect(getOnboardingState().bannerShown).toBe(true);
+ });
+
+ it('banner + nudge both appear on the same response (first register_member)', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+
+ const result = '✅ Member registered.';
+ const isJson = isJsonResponse(result);
+ const preamble = (isJson || !isActiveTool('register_member')) ? null : getFirstRunPreamble();
+ const suffix = getOnboardingNudge('register_member', { member_type: 'local' }, result);
+
+ expect(preamble).not.toBeNull();
+ expect(suffix).not.toBeNull();
+ });
+
+ it('full first-session sequence: banner → register nudge → multi-member nudge → prompt nudge', async () => {
+ const { loadOnboardingState, getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingNudge } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+
+ // Call 1: first register_member → banner + first-register nudge
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ const r1 = '✅ Member registered.';
+ const pre1 = (isJsonResponse(r1) || !isActiveTool('register_member')) ? null : getFirstRunPreamble();
+ const suf1 = getOnboardingNudge('register_member', { member_type: 'local' }, r1);
+ expect(pre1).toContain('Getting Started'); // banner shown
+ expect(suf1).toContain('🚀'); // first-register nudge
+
+ // Call 2: second register_member (now 2 agents) → no banner, multi-member nudge
+ writeRegistry([
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() },
+ { id: '2', friendlyName: 'beta', agentType: 'remote', workFolder: '/tmp/b', createdAt: new Date().toISOString() },
+ ]);
+ const r2 = '✅ Member registered.';
+ const pre2 = (isJsonResponse(r2) || !isActiveTool('register_member')) ? null : getFirstRunPreamble();
+ const suf2 = getOnboardingNudge('register_member', { member_type: 'remote' }, r2);
+ expect(pre2).toBeNull(); // banner already shown
+ expect(suf2).toContain('PM skill'); // multi-member nudge
+
+ // Call 3: execute_prompt → prompt nudge
+ const r3 = '📋 Task submitted.';
+ const pre3 = (isJsonResponse(r3) || !isActiveTool('execute_prompt')) ? null : getFirstRunPreamble();
+ const suf3 = getOnboardingNudge('execute_prompt', {}, r3);
+ expect(pre3).toBeNull();
+ expect(suf3).toContain('Show fleet status');
+
+ // Call 4: any further tool → no onboarding output
+ const r4 = '📋 Another task.';
+ const pre4 = (isJsonResponse(r4) || !isActiveTool('execute_prompt')) ? null : getFirstRunPreamble();
+ const suf4 = getOnboardingNudge('execute_prompt', {}, r4);
+ expect(pre4).toBeNull();
+ expect(suf4).toBeNull();
+ });
+});
+
+describe('getWelcomeBackPreamble', () => {
+ it('returns null on first run (bannerShown=false)', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+ loadOnboardingState(); // fresh — bannerShown=false
+
+ expect(getWelcomeBackPreamble()).toBeNull();
+ });
+
+ it('shows welcome-back on first call after banner already shown', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+ // Simulate existing user: bannerShown=true
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: false, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ loadOnboardingState();
+
+ const result = getWelcomeBackPreamble();
+ expect(result).not.toBeNull();
+ expect(result).toContain('Fleet');
+ });
+
+ it('returns null on second call (already shown this session)', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: false, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ loadOnboardingState();
+
+ getWelcomeBackPreamble(); // first call
+ const second = getWelcomeBackPreamble(); // second call
+ expect(second).toBeNull();
+ });
+
+ it('shows fallback "Fleet ready." message when no agents registered', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: false, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ loadOnboardingState();
+ // no registry file → getAllAgents() returns empty
+
+ const result = getWelcomeBackPreamble();
+ expect(result).not.toBeNull();
+ expect(result).toContain('Fleet ready');
+ });
+
+ it('shows member count and lastActive when agents are registered', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: true, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ loadOnboardingState();
+
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ writeRegistry([
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString(), lastUsed: twoHoursAgo },
+ ]);
+
+ const result = getWelcomeBackPreamble();
+ expect(result).not.toBeNull();
+ expect(result).toContain('1 member');
+ expect(result).toContain('2h ago');
+ });
+
+ it('shows "unknown" lastActive when agent has a malformed lastUsed (NaN guard)', async () => {
+ const { loadOnboardingState, getWelcomeBackPreamble, _resetForTest } = await import('../src/services/onboarding.js');
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: true, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ _resetForTest();
+ loadOnboardingState();
+
+ writeRegistry([
+ { id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString(), lastUsed: 'not-a-date' },
+ ]);
+
+ const result = getWelcomeBackPreamble();
+ expect(result).not.toBeNull();
+ expect(result).not.toContain('NaN');
+ expect(result).toContain('unknown');
+ });
+});
+
+/**
+ * wrapTool notification emission tests.
+ * These tests replicate the wrapTool logic inline (like the integration block above)
+ * and use a stub server to verify sendLoggingMessage is called correctly.
+ */
+describe('wrapTool notification emission', () => {
+ // Helper: build a stub server with a mock sendLoggingMessage
+ function makeStubServer() {
+ return {
+ server: {
+ sendLoggingMessage: vi.fn(() => Promise.resolve()),
+ },
+ };
+ }
+
+ // Helper: replicates wrapTool logic with a notify callback
+ async function simulateWrapTool(
+ toolName: string,
+ result: string,
+ stubServer: ReturnType,
+ ) {
+ const { getFirstRunPreamble, isJsonResponse, isActiveTool, getOnboardingNudge, getWelcomeBackPreamble } = await import('../src/services/onboarding.js');
+
+ const isJson = isJsonResponse(result);
+ let preamble: string | null = null;
+ if (isActiveTool(toolName)) {
+ const banner = getFirstRunPreamble();
+ if (banner) {
+ preamble = banner;
+ } else if (!isJson) {
+ preamble = getWelcomeBackPreamble();
+ }
+ }
+ const suffix = isJson ? null : getOnboardingNudge(toolName, {}, result);
+
+ // Channel 1: out-of-band notifications (best effort)
+ if (preamble) {
+ try {
+ await stubServer.server.sendLoggingMessage({ level: 'info', logger: 'apra-fleet-onboarding', data: preamble });
+ } catch { /* best-effort */ }
+ }
+ if (suffix) {
+ try {
+ await stubServer.server.sendLoggingMessage({ level: 'info', logger: 'apra-fleet-onboarding', data: suffix });
+ } catch { /* best-effort */ }
+ }
+
+ // Channel 2 + 3: content blocks with markers
+ const content: Array<{ type: 'text'; text: string; annotations?: { audience?: ('user' | 'assistant')[]; priority?: number } }> = [];
+ if (preamble) {
+ content.push({ type: 'text' as const, text: `\n${preamble}\n`, annotations: { audience: ['user'], priority: 1 } });
+ }
+ content.push({ type: 'text' as const, text: result });
+ if (suffix) {
+ content.push({ type: 'text' as const, text: `\n${suffix}\n`, annotations: { audience: ['user'], priority: 0.8 } });
+ }
+ return { content };
+ }
+
+ it('banner emits via sendLoggingMessage on first active call', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ const stub = makeStubServer();
+
+ await simulateWrapTool('fleet_status', '{"members":[]}', stub);
+
+ expect(stub.server.sendLoggingMessage).toHaveBeenCalled();
+ const calls = stub.server.sendLoggingMessage.mock.calls;
+ const data = calls[0][0].data as string;
+ expect(data).toContain('One model is a tool');
+ expect(calls[0][0].logger).toBe('apra-fleet-onboarding');
+ expect(calls[0][0].level).toBe('info');
+ });
+
+ it('nudge emits via sendLoggingMessage', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ // consume banner first
+ const { getFirstRunPreamble } = await import('../src/services/onboarding.js');
+ getFirstRunPreamble();
+
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ const stub = makeStubServer();
+
+ await simulateWrapTool('register_member', '✅ Member registered.', stub);
+
+ expect(stub.server.sendLoggingMessage).toHaveBeenCalled();
+ const calls = stub.server.sendLoggingMessage.mock.calls;
+ const allData = calls.map((c: any[]) => c[0].data as string).join(' ');
+ expect(allData).toContain('🚀');
+ });
+
+ it('welcome-back emits via sendLoggingMessage', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ // Simulate existing user: bannerShown=true
+ fs.writeFileSync(ONBOARDING_PATH, JSON.stringify({ bannerShown: true, firstMemberRegistered: false, firstPromptExecuted: false, multiMemberNudgeShown: false }), { mode: 0o600 });
+ loadOnboardingState();
+ const stub = makeStubServer();
+
+ await simulateWrapTool('fleet_status', 'Fleet: 1 member.', stub);
+
+ expect(stub.server.sendLoggingMessage).toHaveBeenCalled();
+ const calls = stub.server.sendLoggingMessage.mock.calls;
+ const data = calls[0][0].data as string;
+ expect(data).toContain('Fleet');
+ });
+
+ it('content block is wrapped in markers', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ const stub = makeStubServer();
+
+ const { content } = await simulateWrapTool('fleet_status', '{"members":[]}', stub);
+
+ // First block should have markers wrapping banner text
+ const preambleBlock = content.find(b => b.text.includes(''));
+ expect(preambleBlock).toBeDefined();
+ expect(preambleBlock!.text).toMatch(/^\n/);
+ expect(preambleBlock!.text).toMatch(/\n<\/apra-fleet-display>$/);
+ expect(preambleBlock!.text).toContain('One model is a tool');
+ });
+
+ it('sendLoggingMessage rejection does not break tool result', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ const stub = {
+ server: {
+ sendLoggingMessage: vi.fn(() => Promise.reject(new Error('client does not support logging'))),
+ },
+ };
+
+ // Should not throw even though sendLoggingMessage rejects
+ const response = await simulateWrapTool('fleet_status', '{"members":[]}', stub);
+
+ // Tool result is still returned with content
+ expect(response).toBeDefined();
+ expect(response.content).toBeDefined();
+ const resultBlock = response.content.find(b => b.text === '{"members":[]}');
+ expect(resultBlock).toBeDefined();
+ });
+
+ it('passive tool does not emit onboarding notification', async () => {
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ const stub = makeStubServer();
+
+ await simulateWrapTool('version', 'apra-fleet v1.0.0', stub);
+
+ expect(stub.server.sendLoggingMessage).not.toHaveBeenCalled();
+ });
+
+ it('tool result text is NOT wrapped in markers', async () => {
+ // Negative test: only preamble/suffix get markers; the actual tool
+ // output must stay untagged so it flows through normal rendering.
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ const stub = makeStubServer();
+
+ const toolResult = '✅ Member registered.';
+ const { content } = await simulateWrapTool('register_member', toolResult, stub);
+
+ const resultBlock = content.find(b => b.text === toolResult);
+ expect(resultBlock).toBeDefined();
+ expect(resultBlock!.text).not.toContain('');
+ expect(resultBlock!.text).not.toContain('');
+ // And it should have no user-audience annotation — that's reserved for onboarding text
+ expect(resultBlock!.annotations).toBeUndefined();
+ });
+
+ it('banner AND nudge both emit on first register_member call (two notifications)', async () => {
+ // On a fresh install, the first register_member fires two user-facing messages:
+ // the banner (preamble) and the first-register nudge (suffix). Both must go
+ // through the notification channel — not just the first one.
+ const { loadOnboardingState } = await import('../src/services/onboarding.js');
+ loadOnboardingState();
+ writeRegistry([{ id: '1', friendlyName: 'alpha', agentType: 'local', workFolder: '/tmp/a', createdAt: new Date().toISOString() }]);
+ const stub = makeStubServer();
+
+ await simulateWrapTool('register_member', '✅ Member registered.', stub);
+
+ expect(stub.server.sendLoggingMessage).toHaveBeenCalledTimes(2);
+ const payloads = stub.server.sendLoggingMessage.mock.calls.map((c: any[]) => c[0].data as string);
+ expect(payloads.some(p => p.includes('One model is a tool'))).toBe(true); // banner
+ expect(payloads.some(p => p.includes('🚀'))).toBe(true); // nudge
+ });
+});
+
+// Helper: replicate sanitizeToolResult from src/index.ts for unit testing
+function sanitizeToolResult(s: string): string {
+ return s.replace(/<\/?apra-fleet-display[^>]*(?:>|$)/gi, '[tag-stripped]');
+}
+
+describe('sanitization: marker injection defense', () => {
+ it('sanitizeToolResult strips tags from tool result', () => {
+ const malicious = 'evil instructions';
+ const sanitized = sanitizeToolResult(malicious);
+ expect(sanitized).toContain('[tag-stripped]');
+ expect(sanitized).not.toContain('');
+ expect(sanitized).not.toContain('');
+ expect(sanitized).toContain('evil instructions');
+ });
+
+ it('sanitizeToolResult does NOT strip markers from server-controlled preamble/suffix', () => {
+ // Preamble and suffix are NOT passed through sanitizeToolResult — they are
+ // server-controlled constants that intentionally emit the markers.
+ const preamble = '\nWelcome!\n';
+ // Verify that preamble text still contains the markers (unsanitized)
+ expect(preamble).toContain('');
+ expect(preamble).toContain('');
+ // sanitizeToolResult is applied only to `result`, not preamble/suffix
+ const resultBlock = sanitizeToolResult('clean tool output');
+ expect(resultBlock).toBe('clean tool output'); // no markers to strip
+ });
+
+ it('sanitizeToolResult handles case variants, attributes, and multiple occurrences', () => {
+ const inputs = [
+ 'uppercase',
+ 'with attribute',
+ 'first middle second',
+ ];
+ for (const input of inputs) {
+ const out = sanitizeToolResult(input);
+ expect(out).not.toMatch(/<\/?apra-fleet-display/i);
+ expect(out).toContain('[tag-stripped]');
+ }
+ // Two occurrences should produce two replacements
+ const doubleOut = sanitizeToolResult('a b');
+ expect(doubleOut.split('[tag-stripped]').length - 1).toBe(4); // 2 open + 2 close tags
+ });
+
+ it('sanitizeToolResult strips unterminated tags (no closing >)', () => {
+ const input = 'prefix {
+ it('rejects host containing angle brackets', async () => {
+ const { registerMemberSchema } = await import('../src/tools/register-member.js');
+ const result = registerMemberSchema.safeParse({
+ friendly_name: 'test',
+ member_type: 'remote',
+ host: '192.168.1.1evil',
+ work_folder: '/tmp/work',
+ });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const msgs = result.error.issues.map(i => i.message).join(' ');
+ expect(msgs).toContain('angle brackets');
+ }
+ });
+
+ it('rejects work_folder containing angle brackets', async () => {
+ const { registerMemberSchema } = await import('../src/tools/register-member.js');
+ const result = registerMemberSchema.safeParse({
+ friendly_name: 'test',
+ member_type: 'local',
+ work_folder: '/tmp/inject',
+ });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const msgs = result.error.issues.map(i => i.message).join(' ');
+ expect(msgs).toContain('angle brackets');
+ }
+ });
+
+ it('accepts legitimate host and work_folder values', async () => {
+ const { registerMemberSchema } = await import('../src/tools/register-member.js');
+ const cases = [
+ { host: '192.168.1.1', work_folder: '/home/user/project' },
+ { host: '2001:db8::1%eth0', work_folder: '/var/data/my project' },
+ { host: 'my-server.example.com', work_folder: 'C:\\Users\\dev\\work' },
+ ];
+ for (const { host, work_folder } of cases) {
+ const result = registerMemberSchema.safeParse({
+ friendly_name: 'test',
+ member_type: 'remote',
+ host,
+ work_folder,
+ });
+ // work_folder and host pass the regex; other fields may trigger unrelated validation
+ const hostIssues = result.success ? [] : result.error.issues.filter(i => i.path[0] === 'host');
+ const folderIssues = result.success ? [] : result.error.issues.filter(i => i.path[0] === 'work_folder');
+ expect(hostIssues).toHaveLength(0);
+ expect(folderIssues).toHaveLength(0);
+ }
+ });
+});
+
+describe('update-member schema validation', () => {
+ it('rejects host containing angle brackets', async () => {
+ const { updateMemberSchema } = await import('../src/tools/update-member.js');
+ const result = updateMemberSchema.safeParse({
+ member_name: 'my-server',
+ host: '',
+ });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const msgs = result.error.issues.map(i => i.message).join(' ');
+ expect(msgs).toContain('angle brackets');
+ }
+ });
+
+ it('rejects work_folder containing angle brackets', async () => {
+ const { updateMemberSchema } = await import('../src/tools/update-member.js');
+ const result = updateMemberSchema.safeParse({
+ member_name: 'my-server',
+ work_folder: '/path/',
+ });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const msgs = result.error.issues.map(i => i.message).join(' ');
+ expect(msgs).toContain('angle brackets');
+ }
+ });
+});