diff --git a/CHANGELOG.md b/CHANGELOG.md index 304515f59..c15910fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +- Squad MCP now also manages agent profiles, abilities, extra project folders, skills, group chats, channel membership and scheduled routines. +- Squad is built in as an MCP connection. Select an agent in the running app with `/squad`, send it direct messages with `/message`, and read conversation history and delivery receipts. The connection also reads Squad artifacts and memories. + ## 0.9.10 - 2026-11-04 ### Added diff --git a/README.md b/README.md index 131062c66..bb8ccbb2f 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,7 @@ Node installs use `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` for requests, inclu | `/goals complete` | Mark the current goal complete and advance queued work when completion requirements are met | | `/goals clear` | Clear this session's current goal while retaining queued work | | `/goals templates` | List reusable goal templates | -| `/squad` | Open/manage the local Autohand Squad runtime | +| `/squad` | Select an agent in the running Squad app; `/squad app` opens or installs it | | `/go` | Pair this session with the Autohand Code iOS app | | `/sync` | Sync settings across devices | | `/add-dir` | Add additional workspace directory | @@ -436,12 +436,18 @@ Node installs use `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` for requests, inclu | `/mcp install` | Install validated Official MCP Registry servers (stdio and HTTP) | | `/team` | Manage team collaboration | | `/tasks` | List team tasks | -| `/message` | Send team message | +| `/message` | Message the selected Squad agent or a local CLI teammate | | `/import` | Import data from Claude, Codex, Gemini, Cursor, OpenCode, Kimi, Grok hooks, and other agents | | `/repeat` | Repeat previous actions | | `/browser` | Browser integration | | `/review` | Code review | +Squad MCP is built in. Select an agent with `/squad`, then use `/message ` +and `/squad history`. `/message squad ` addresses an agent +explicitly; `/message team ` addresses your local CLI team. +See [Squad connection and commands](docs/mcp.md#built-in-autohand-squad) for +delivery receipts, Library tools, bridge discovery and opt-out settings. + Persistent goal data remains in the workspace across conversations, but execution does not silently transfer work owned by another live session. Bare `/goal` inspects prior state and starts the next queued item when this session has no goal and no live peer owns the backlog. Use `/goal resume` to resume this session's paused goal or explicitly start queued work. Published CLI announcements appear as a cached launch block and a persistent line above the composer. Press `Ctrl+X` to dismiss the visible item, or use `/whatsnew` to review and dismiss all active announcements. Dismissal is per announcement; `--offline` keeps cached announcements visible without making announcement requests. diff --git a/docs/adr/2026-11-05-built-in-squad-messaging.md b/docs/adr/2026-11-05-built-in-squad-messaging.md new file mode 100644 index 000000000..3a1f9da5c --- /dev/null +++ b/docs/adr/2026-11-05-built-in-squad-messaging.md @@ -0,0 +1,136 @@ +# Built-in Squad messaging uses the running app's queues + +Date: 2026-11-05 + +## Context + +`/message` only addressed the active local CLI team, while `/squad` opened +or installed the app. Squad's external `send_message` started independent +Mission Control runs. Those paths could not select a desktop agent and send +a message into its actual direct conversation. The app already owns durable +message queues, saved agent permissions, transcripts, artifacts and memories. + +## Decision + +Add a session-owned Squad connection and a keyboard agent picker using the +existing Ink modal and input pause/resume hooks. A selected desktop agent +receives `/message `; `/message team` explicitly addresses the existing +local team. Explicit `/message squad ` is a one-off recipient. +IDs disambiguate duplicate names. Preserve top-level `autohand squad` app +launch and legacy standalone commands. No implicit app install/start is +performed when using the connection. + +Bundle the Squad operator MCP schemas/runtime from `autohandai/squad`, with +MIT attribution and source revision, instead of requiring another package or +an installed app executable. Inject it into the runtime MCP list without +persisting or syncing it. Node/Bun source installs launch their entrypoint +with the current runtime; standalone binaries launch themselves directly. +Honor disabled MCP, opt-out, bare, offline and no-daemon behavior. Configured +Squad entries win. Managed member environment/connection markers prevent +operator access from being added to an agent already inside Squad. + +Both slash commands and MCP use the same loopback bridge/tool implementation. +Direct sends enqueue in the actual member conversation; they preserve busy +work and paused queues, and return a durable receipt. The CLI sends no model, +engine or permission override. Library access is read-only. Existing normal +MCP discovery, permission checks and tool execution remain in the path. + +## Evidence and limits + +Focused command/MCP/installer/computer-use tests pass (41 tests), as do +type checking and lint. `tests/tuistory/squad-connection.tuistory.test.ts` +drives the built CLI through keyboard selection, direct delivery/history and +normal `tool_search`/`run_tool_script` invocation of the bundled MCP against +an isolated HTTP fixture. Squad's `scripts/check-control-plane.mjs` separately +checks actual bridge delivery and transcript persistence with a fake engine. +No live agent/provider is used as a test destination. Full `bun run proof` +is required before integration; its final result is recorded with the change. + +`AUTOHAND_TEST_WORKERS=1 bun run proof` passed lint, type checking and all +965 executed unit files: 13,786 tests passed and 80 were skipped. Earlier +parallel runs exposed an old exact-context assertion (updated for the new +connection) and intermittent cancellation/file-lock deadlines in unchanged +tests. Those timing tests pass in isolation; the memory-index test also +passes on unchanged `main`. Their implementation and timeout budgets were +left intact. The serial run removes competing test workers without skipping +coverage; terminal proof follows the rebuilt CLI in the same command. + +The complete terminal run executed 538 cases across 110 files: 532 passed, +four failed and two were skipped, so the overall proof command exited 1. +The orchestration-toggle and caret cases passed focused reruns; all 20 +resize cases passed a full-file rerun. Console connector synchronization +failed again at `toolsListed > 0` on both this branch and an independently +built, unchanged `9d10d59d` checkout. The baseline orchestration file (11 +cases), resize case and caret case passed. These results distinguish the +existing reproducible connector issue from intermittent terminal failures; +they do not establish that the full repository proof is green. No production +orchestration, resize, caret or Console-sync code was changed here. + +The new Squad scenarios pass in both full-screen and standard layouts on +the built Node CLI and compiled macOS ARM64 CLI. Their explicit fixture +connection also passes with the terminal runner's default opt-out enabled. + +Unit and terminal runners disable automatic Squad connections by default, +including scenarios that launch the CLI through a custom driver. The Squad +fixture explicitly opts back in against its isolated HTTP server, so generic +tests do not depend on an installed app or accidentally use its bridge. + +The same Tuistory flow also passes with the compiled macOS ARM64 binary +(`AUTOHAND_SQUAD_TEST_BINARY`), including its automatic MCP self-launch. +Before the management expansion, compiled stdio initialization/tool listing +succeeded offline with protocol-only stdout and 18 tools. A separate read-only roster +check against the installed Squad bridge recognized 18 members with presence; +it sent zero messages and made no app lifecycle calls. Local evidence is in +`.artifacts/squad-mcp/compiled-mcp-proof.json` and `live-readonly-proof.json`. + +## Expanded operator management + +The canonical Squad MCP now exposes 37 tools, adding saved member profiles, +abilities, directory grants, skill selections, channel settings/membership, +private groups and scheduled routines. Bundle its `management.mjs` alongside +the runtime and schemas. This keeps tool behavior identical between Squad +and CLI without introducing a second client implementation. Management +requires an updated bridge; a missing member-management endpoint explains +that the app must be updated, without starting an upgrade. + +Profile edits use canonical revisions. Folder grants and skill selections +apply during later engine preparation, preserving current work. Groups reuse +private channels and dispatch only explicit recipients; the bridge persists +replies after client disconnect. Routines require a timezone and are disabled +by default. Operator powers remain unavailable to managed Squad members. +The rationale and controlled bridge evidence are in +[Squad ADR-0181](https://github.com/autohandai/squad/blob/main/docs/adrs/ADR-0181-operator-mcp-manages-saved-members-and-work.md). + +Focused CLI validation: typecheck, 13 related unit tests, and the actual +Tuistory flow in both standard and fullscreen layouts pass. The terminal +scenario now discovers and calls the new `get_member_profile` tool through +the ordinary MCP tool-search/permission path, against an isolated fixture. +Both layouts also pass with the compiled macOS ARM64 executable. Its offline +stdio smoke now lists all 37 tools with protocol-only stdout. The +required full `AUTOHAND_TEST_WORKERS=1 AUTOHAND_TUISTORY_WORKERS=2 bun run proof` +completed: lint/typecheck and all unit files passed (965 files; 13,787 tests, +80 skipped). Terminal results were 108 passing files and two failing files +(527 passed tests, nine failed, two skipped). Console connector sync failed +at `toolsListed > 0`, previously reproduced on the unchanged base. The +extension file had one nonzero process exit and seven explicit `ENOSPC` +fixture-creation failures. This is not a green local aggregate result. + +All 24 reported CI checks on implementation commit `3478d59a` passed, +including the four terminal shards and Windows terminal checks; the optional +manual `proof` job was skipped. Local aggregate evidence is in +`.artifacts/squad-mcp/management-proof.log`. After removing an obsolete generated binary, the focused extension file +passed all 12 tests. The full local aggregate remains failed; this rerun does +not replace it. These results do not claim a published CLI or installed +Squad upgrade. + +## Consequences + +Desktop selection changes the unqualified `/message` recipient for this +session; the selection receipt documents the local-team escape and clearing +command. Selection is not persisted between sessions. There is no continuous +desktop roster polling or composer alias expansion in this change. A paused +queue requires a person in Squad. Send retries are not automatic/idempotent; +after a lost receipt inspect the queue before resending. Read history is +bounded to bridge chat turns; it does not load archived chat files or execute +artifact HTML. The vendored protocol needs deliberate synchronized updates; +no shared package or automatic Squad/CLI release is introduced. diff --git a/docs/config-reference.md b/docs/config-reference.md index 4f0a679a5..8e0e36d8b 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -2491,6 +2491,13 @@ sync: Configure MCP (Model Context Protocol) servers to extend Autohand with external tools. +The effective runtime list also includes the built-in `autohand-squad` server; +it is not written into this configuration or synced to your account. Set +`AUTOHAND_DISABLE_SQUAD=1` to opt out, or `mcp.enabled: false` to disable all +MCP connections. Bare, offline and no-daemon sessions omit it, as do managed +Squad members with an existing identity-scoped connection. See +[built-in Squad commands and bridge discovery](mcp.md#built-in-autohand-squad). + ```json { "mcp": { diff --git a/docs/mcp.md b/docs/mcp.md index 09f858711..26574800d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,6 +4,7 @@ Autohand includes a built-in MCP client that connects to external MCP servers, e ## Table of Contents +- [Built-in Autohand Squad](#built-in-autohand-squad) - [Overview](#overview) - [Quick Start](#quick-start) - [Configuration](#configuration) @@ -27,6 +28,91 @@ MCP is an open protocol for connecting AI agents to external tools. Autohand's M - **Non-blocking startup** -- servers connect in the background without delaying the prompt - **Interactive management** -- toggle servers on/off with the `/mcp` command +## Built-in Autohand Squad + +Autohand Code adds `autohand-squad` to its runtime MCP servers automatically. +It uses this CLI's bundled stdio server, so no separate MCP install or saved +configuration is needed. Tools are available through normal tool discovery +and permission checks, under `mcp__autohand-squad__`. + +Open the Squad app, then in an interactive CLI session: + +```text +/squad choose an agent with the keyboard +/squad agents list agent IDs and presence +/squad select kai select by ID or exact name +/message Please review this message the selected agent +/message squad iris Review this message another agent explicitly +/squad history read the selected agent's replies +/squad queue inspect delivery receipts and pause state +/message team local Check this message a local CLI teammate +/squad clear clear the selected desktop recipient +/squad app open or install the app +``` + +Selection belongs to the current CLI session. An explicit one-off recipient +does not change it. Local `/message ` behavior continues when no +desktop recipient is selected. Escape cancels the picker and restores input. +Top-level `autohand squad` still opens the app; `autohand squad agents`, +`autohand squad message ` and `autohand squad history ` +work without an interactive agent session. Legacy standalone runtime actions +remain available, including top-level `autohand squad queue`. + +The server exposes 37 tools: the existing channel/run/goal tools plus direct messaging +(`message_member`, `get_conversation`, `get_member_queue`, `wait_for_message`), +published artifacts (`list_artifacts`, `get_artifact`) and the knowledge +library (`list_memories`, `read_memory`). These direct messages enter Squad's +durable conversation queue, wait behind busy work and retain saved member +permissions. A receipt means queued, not completed. Paused queues stay paused. +Repeat `wait_for_message` using the same receipt after a timeout; inspect the +queue before retrying a send whose response was lost. Creating members and +changing their models still happens in the app. + +Member management is also built in: `get_member_profile`, +`update_member_profile`, `set_member_abilities`, `add_member_directory`, +`remove_member_directory`, `list_skills` and `set_member_skills`. Read the +saved revision before editing; pass `expected_revision` to reject stale +changes. Folder grants use the existing engine directory settings on later +turns; current work continues. Skill selection is distinct from installation +on the next turn. Policy edits preserve other settings and OS grants. + +Channels support `get_channel`, `update_channel`, `set_channel_members`, +`list_channel_threads`, `create_group_chat` and `send_channel_message`. +Groups are private channels with at least two existing members. Creation +does not dispatch work. Sending dispatches only explicit `member_ids`; +omitting them posts without starting agents. Replies persist on the bridge +even after this CLI disconnects. Check per-recipient receipts before retrying. + +Use `list_routines`, `get_routine`, `preview_routine`, `create_routine`, +`update_routine` and `run_routine` for scheduled work. Schedules require an +explicit IANA timezone. New routines default to disabled; set `enabled:true` +to schedule, `enabled:false` to pause, or run a saved routine manually. +These tools require an updated Squad bridge; older versions return an +upgrade instruction. The CLI never upgrades or restarts the app automatically. +The [canonical tool reference](https://github.com/autohandai/squad/blob/main/docs/integration/control-plane.md) +contains arguments and examples. + +Use a current Squad release for Library tools; older app versions may lack +the artifact or knowledge-library endpoints. Updating the app does not +require reinstalling the CLI's built-in MCP. + +Bridge discovery: explicit `AUTOHAND_SQUAD_BRIDGE_URL`, then +`$AUTOHAND_SQUAD_HOME/web-server.json` (default `~/.autohand/squad`), then +`http://127.0.0.1:19821`. Only loopback origins are accepted; redirects are +refused. MCP initialization succeeds while the app is offline, and calls +explain how to open it. Startup does not install or start Squad. + +Disable with `AUTOHAND_DISABLE_SQUAD=1` or `mcp.enabled: false`. Bare, offline +and no-daemon sessions do not add this built-in server. An existing configured +`autohand-squad`/`autohand_squad` entry wins. A CLI launched as a managed Squad +member keeps its identity-scoped connection and never receives operator +access to another member's private memories. + +For another MCP client, use `autohand squad mcp` as the command/arguments +(`--bridge http://127.0.0.1:19821` is optional). It emits protocol messages +only on stdout. See the [Squad control-plane reference](https://github.com/autohandai/squad/blob/main/docs/integration/control-plane.md) +for the complete tool schemas and scope. + --- ## Quick Start diff --git a/src/commands/README.md b/src/commands/README.md index 1fc9b4c8d..944db6cec 100644 --- a/src/commands/README.md +++ b/src/commands/README.md @@ -40,7 +40,8 @@ Each command is a separate TypeScript file that exports: | `/experiments` | `features.ts` | List and toggle experiments | | `/goal` | `goal.ts` | Manage session-attached persistent goals, budgets, templates, and queued work; bare `/goal` recovers a stranded queue only when no live peer owns it. Enabled by default from 0.9.9; explicit `slash_goal` settings are preserved. | | `/goals` | `goal.ts` | Open the live goal queue with keyboard and mouse editing. Accepts `/goal` subcommands and returns the queue as text without an interactive panel. Shares `slash_goal`; `/experiments enable slash_goals` enables the same local feature. | -| `/squad` | `squad.ts`, `squadDesktop.ts` | Open the Autohand Squad app, installing the latest release when missing; subcommands manage the standalone runtime. | +| `/squad` | `squad.ts`, `squadConnection.ts` | Select an agent in the running Squad app; `/squad agents`, `select`, `history`, `queue`, and `clear` manage the desktop recipient. `/squad app` opens or installs the app. | +| `/message` | `message.ts` | Send text to the selected Squad agent, `/message squad ` to an explicit agent, or `/message team ` to a local CLI teammate. Without a desktop selection, existing `/message ` team behavior remains. | | `/squad view` | `squad.ts` | Inspect recorded independent Squad sessions for the current workspace | | `/usage` | `usage.ts` | Show Autohand plan limits and project token activity | | `/statusline` | `statusline.ts` | Configure composer status-line fields | diff --git a/src/commands/message.ts b/src/commands/message.ts index 6c7a058f4..b7999876a 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -7,18 +7,27 @@ import chalk from 'chalk'; import type { SlashCommand } from '../core/slashCommandTypes.js'; import type { TeamManager } from '../core/teams/TeamManager.js'; +import type { SquadConnection } from '../squad/SquadConnection.js'; export const metadata: SlashCommand = { command: '/message', - description: 'Send a direct message to a teammate', + description: 'Message a local teammate or a selected Squad agent', implemented: true, }; interface MessageCommandContext { - teamManager?: TeamManager; + teamManager?: Pick; + squadConnection?: SquadConnection; } export async function message(ctx: MessageCommandContext, args: string[]): Promise { + if (args[0] === 'squad' || (ctx.squadConnection?.selectedMemberId && args[0] !== 'team')) { + try { + if (!ctx.squadConnection) return 'Squad connection unavailable.'; + return await sendSquadMessage(ctx.squadConnection, args); + } catch (error) { return chalk.red(error instanceof Error ? error.message : String(error)); } + } + if (args[0] === 'team') args = args.slice(1); if (!ctx.teamManager) { return chalk.yellow('Team manager not available.'); } @@ -47,3 +56,12 @@ export async function message(ctx: MessageCommandContext, args: string[]): Promi return chalk.red((err as Error).message); } } + +export async function sendSquadMessage(connection: SquadConnection, args: string[]): Promise { + const explicit = args[0] === 'squad'; + const target = explicit ? args[1] : undefined; + const text = (explicit ? args.slice(2) : args).join(' ').trim(); + if (!text || (explicit && !target)) throw new Error('Usage: /message squad , or select an agent with /squad select then /message .'); + const receipt = target ? await connection.sendTo(target, text) : await connection.send(text); + return `Message queued for Squad agent ${receipt.member_id}. Receipt: ${receipt.message_id}.${receipt.paused ? ` Queue paused: ${receipt.pauseReason} Resume it in Squad when ready.` : ''}`; +} diff --git a/src/commands/squad.ts b/src/commands/squad.ts index e0b3ebd9b..3b126c445 100644 --- a/src/commands/squad.ts +++ b/src/commands/squad.ts @@ -18,6 +18,8 @@ import { readSquadRunSnapshot } from '../core/agents/SquadRunSnapshot.js'; import { openSquadDesktop } from './squadDesktop.js'; import type { SquadDesktopDeps } from './squadDesktop.js'; import type { AgentRunSource } from '../core/agents/AgentRunStore.js'; +import { SquadConnection } from '../squad/SquadConnection.js'; +import { runDesktopSquadCommand, type DesktopSquadContext } from './squadConnection.js'; const DEFAULT_API_BASE_URL = 'https://api.autohand.ai'; const DEFAULT_CHANNEL = 'stable'; @@ -29,6 +31,10 @@ const START_ACTIONS = new Set(['start', 'open', 'restart']); type SquadAction = 'start' | 'status' | 'restart' | 'stop' | 'queue' | 'open' | 'config'; interface SquadContext { + squadConnection?: SquadConnection; + onBeforeModal?: DesktopSquadContext['onBeforeModal']; + onAfterModal?: DesktopSquadContext['onAfterModal']; + isNonInteractive?: boolean; config?: LoadedConfig; workspaceRoot: string; onToggleAgentRunsView?: (visible: boolean, source?: AgentRunSource) => void; @@ -111,9 +117,17 @@ interface RuntimeConfig { export const metadata: SlashCommand = { command: '/squad', - description: 'open the Autohand Squad app, installing it if needed', + description: 'Select a Squad agent, read its conversation, or open the app', implemented: true, - subcommands: [{ name: 'view', description: 'inspect recorded workspace runs from independent Squad sessions' }], + subcommands: [ + { name: 'agents', description: 'list agents in the running Squad app' }, + { name: 'select', description: 'select a Squad message recipient' }, + { name: 'history', description: 'read the selected agent conversation' }, + { name: 'queue', description: 'read the selected agent message queue' }, + { name: 'clear', description: 'clear the selected Squad recipient' }, + { name: 'app', description: 'open or install the Squad app' }, + { name: 'view', description: 'inspect recorded independent Squad runs' }, + ], }; export async function squad( @@ -121,6 +135,10 @@ export async function squad( args: string[] = [], deps: SquadDeps = {}, ): Promise { + if (!args.length && ctx.squadConnection) { + try { return await runDesktopSquadCommand({ ...ctx, squadConnection: ctx.squadConnection }, ['select']); } + catch (error) { return `${error instanceof Error ? error.message : String(error)}\nUse /squad app to open Autohand Squad.`; } + } const result = await runSquadCommand(ctx, args, deps); return result.output; } @@ -130,6 +148,10 @@ export async function runSquadCommand( args: string[] = [], deps: SquadDeps = {}, ): Promise { + if (['agents', 'select', 'history', 'clear', 'help', 'message'].includes(args[0]) || (args[0] === 'queue' && ctx.squadConnection)) { + try { return { code: 0, output: await runDesktopSquadCommand({ ...ctx, squadConnection: ctx.squadConnection ?? new SquadConnection({ env: deps.env }) }, args) }; } + catch (error) { return { code: 1, output: error instanceof Error ? error.message : String(error) }; } + } if (args.length === 1 && args[0] === 'view') { const snapshot = await readSquadRunSnapshot({ workspaceRoot: ctx.workspaceRoot, env: deps.env }); ctx.onToggleAgentRunsView?.(true, 'squad'); diff --git a/src/commands/squadConnection.ts b/src/commands/squadConnection.ts new file mode 100644 index 000000000..8e18355b7 --- /dev/null +++ b/src/commands/squadConnection.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { SquadConnection, type SquadMember } from '../squad/SquadConnection.js'; +import { redactSecrets } from '../squad/vendor/control-plane.mjs'; + +export interface DesktopSquadContext { + squadConnection: SquadConnection; + pickMember?: (members: SquadMember[]) => Promise; + onBeforeModal?: () => Promise | void; + onAfterModal?: () => Promise | void; + isNonInteractive?: boolean; +} + +const HELP = '/squad agents\n/squad select [agent-id|name]\n/squad message \n/squad history [agent-id]\n/squad queue [agent-id]\n/squad clear\n/squad app\n/message (selected Squad agent)\n/message squad \n/message team (local CLI team)'; + +export async function runDesktopSquadCommand(ctx: DesktopSquadContext, args: string[]): Promise { + const [action = 'select', ...rest] = args; + const connection = ctx.squadConnection; + if (action === 'help') return HELP; + if (action === 'message') { + const { sendSquadMessage } = await import('./message.js'); + return sendSquadMessage(connection, ['squad', ...rest]); + } + if (action === 'clear') { connection.clearSelection(); return 'Squad recipient cleared. /message now uses your local CLI team.'; } + if (action === 'agents') { + const members = await connection.listMembers(); + return members.length ? members.map(member => `${member.id === connection.selectedMemberId ? '› ' : ' '}${member.name || member.id} · ${member.id} · ${member.presence}${member.role ? ` · ${member.role}` : ''}`).join('\n') : 'No agents found. Create an agent in Autohand Squad first.'; + } + if (action === 'history') { + const data = await connection.history(rest[0]); + return data.messages.map(message => `${message.role}: ${redactSecrets(message.body)}`).join('\n') || 'No direct messages yet.'; + } + if (action === 'queue') return redactSecrets(JSON.stringify(await connection.queue(rest[0]), null, 2)); + if (action !== 'select') return HELP; + let target = rest.join(' '); + if (!target) { + const members = await connection.listMembers(); + if (!members.length) return 'No agents found. Create an agent in Autohand Squad first.'; + if (ctx.isNonInteractive || (!ctx.pickMember && (!process.stdin.isTTY || !process.stdout.isTTY))) return `Choose an agent with /squad select .\n${await runDesktopSquadCommand(ctx, ['agents'])}`; + await ctx.onBeforeModal?.(); + try { + if (ctx.pickMember) target = await ctx.pickMember(members) || ''; + else { + const { showModal } = await import('../ui/ink/components/Modal.js'); + const choice = await showModal({ title: 'Select a Squad agent', filterable: true, options: members.map(member => ({ label: member.name || member.id, value: member.id, description: `${member.presence} · ${member.role || member.id}` })) }); + target = choice?.value || ''; + } + } finally { await ctx.onAfterModal?.(); } + if (!target) return 'Squad selection cancelled.'; + } + const member = await connection.select(target); + return `Selected Squad agent ${member.name || member.id} (${member.id}).\n/message sends to this agent. Use /message team for a local teammate, or /squad clear to clear the selection.`; +} diff --git a/src/computer/cuaDriver.ts b/src/computer/cuaDriver.ts index fb26bd364..624dde4de 100644 --- a/src/computer/cuaDriver.ts +++ b/src/computer/cuaDriver.ts @@ -3,6 +3,7 @@ * Copyright 2026 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { ensureSquadMcpServer, type SquadMcpOptions } from '../squad/builtin.js'; import { execFile } from 'node:child_process'; import { accessSync, constants as fsConstants, statSync } from 'node:fs'; import os from 'node:os'; @@ -268,7 +269,7 @@ export function ensureCuaMcpServer( /** Build the effective MCP list without persisting Autohand's built-in entry. */ export function resolveRuntimeMcpServers( config: LoadedConfig, - options: Parameters[1] = {}, + options: Parameters[1] & SquadMcpOptions = {}, ): McpServerConfigEntry[] { const runtimeConfig = { ...config, @@ -277,6 +278,8 @@ export function resolveRuntimeMcpServers( : {}, } as LoadedConfig; ensureCuaMcpServer(runtimeConfig, options); + const squad = ensureSquadMcpServer(runtimeConfig, options); + if (squad) runtimeConfig.mcp = { ...runtimeConfig.mcp, servers: [...(runtimeConfig.mcp?.servers ?? []), squad] }; return runtimeConfig.mcp?.servers ?? []; } diff --git a/src/core/agent.ts b/src/core/agent.ts index 478bc676e..bb51f49c6 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -2513,6 +2513,7 @@ export class AutohandAgent { this.runtime.config.mcp = mcp; this.runtimeMcpServers = resolveRuntimeMcpServers(this.runtime.config, { bare: this.runtime.options.bare || this.runtime.options.daemon === false, + offline: this.runtime.options.offline, }); await this.mcpManager.disconnectAll(); if (mcp?.enabled !== false) { diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index a32916458..f2a8e5784 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -484,6 +484,7 @@ export function initializeAgentDependencies( runtime.config = applyNoDaemonConfig(runtime.config, runtime.options); host.runtimeMcpServers = resolveRuntimeMcpServers(runtime.config, { bare: runtime.options.bare || runtime.options.daemon === false, + offline: runtime.options.offline, }); const initialProvider = runtime.config.provider ?? 'openrouter'; const providerSettings = getProviderConfig(runtime.config, initialProvider); diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index 8495a70f1..b97e80d98 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -3,6 +3,7 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { SquadConnection } from '../squad/SquadConnection.js'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; import type { SlashCommand } from './slashCommands.js'; @@ -17,6 +18,7 @@ import { export class SlashCommandHandler { private readonly commandMap = new Map(); + private readonly squadConnection = new SquadConnection(); constructor( private readonly ctx: SlashCommandContext, @@ -786,7 +788,8 @@ export class SlashCommandHandler { } case '/message': { const { message } = await import('../commands/message.js'); - return message({ teamManager: this.ctx.teamManager }, args); + if (this.ctx.backgroundServicesEnabled === false && args[0] === 'squad') return 'Squad messaging requires background services. Use /message team for a local teammate.'; + return message({ teamManager: this.ctx.teamManager, squadConnection: this.ctx.backgroundServicesEnabled === false ? undefined : this.squadConnection }, args); } case '/import': { const { execute } = await import('../commands/import.js'); @@ -863,6 +866,10 @@ export class SlashCommandHandler { case '/squad': { const { squad } = await import('../commands/squad.js'); return squad({ + squadConnection: this.squadConnection, + onBeforeModal: this.ctx.onBeforeModal, + onAfterModal: this.ctx.onAfterModal, + isNonInteractive: this.ctx.isNonInteractive, workspaceRoot: this.ctx.workspaceRoot, config: this.ctx.config, onToggleAgentRunsView: this.ctx.onToggleAgentRunsView, diff --git a/src/index.ts b/src/index.ts index 764141cda..d9ef15bf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ const requestsStructuredCommandOutput = process.argv.some((arg) => ( || arg.startsWith('--output-format=') )); const requestsProtocolOutput = process.argv.some((arg, index, argv) => ( + (arg === 'squad' && argv[index + 1] === 'mcp') + || arg === '--answer-only' || arg === '--setup-only' || arg === '--acp' @@ -364,6 +366,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => { configureRunConfigOverlay({ profile, sets: set, ...(sdkSettings ? { sdkSettings } : {}) }); const commandPath = commandPathOf(actionCommand); + if (commandPath[0] === 'squad' && actionCommand.args[0] === 'mcp') { + if (daemon === false || bare) actionCommand.error(NO_DAEMON_MESSAGE); + return; + } if (daemon === false && (tracesOn || commandPath[0] === 'traces' || commandPath[0] === 'computer' || commandPath[0] === 'squad')) { actionCommand.error(NO_DAEMON_MESSAGE); } @@ -991,10 +997,15 @@ program program .command('squad [args...]') - .description('Open the Autohand Squad app (installs it if missing); subcommands manage the standalone runtime') + .description('Open Squad, list agents, send messages, read history, or serve the built-in MCP') .allowUnknownOption(true) .allowExcessArguments(true) .action(async (args: string[] = []) => { + if (args[0] === 'mcp') { + try { await (await import('./squad/builtin.js')).runSquadMcp(args.slice(1)); } + catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } + process.exit(0); + } const rootOptions = program.opts(); const config = await loadConfig(rootOptions.config, process.cwd()); const workspaceRoot = resolveWorkspaceRoot(config, rootOptions.path); diff --git a/src/squad/SquadConnection.ts b/src/squad/SquadConnection.ts new file mode 100644 index 000000000..d65d15f1e --- /dev/null +++ b/src/squad/SquadConnection.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { resolveAutohandHome } from '../constants.js'; +import { createBridgeClient, createTools, type SquadBridge } from './vendor/runtime.mjs'; +import { DEFAULT_BRIDGE_URL, parseBridgeUrl } from './vendor/control-plane.mjs'; + +const MemberSchema = z.object({ id: z.string(), name: z.string().nullable().optional(), role: z.string().nullable().optional(), presence: z.string(), lastWorkspace: z.string().nullable().optional() }); +export type SquadMember = z.infer; +const ReceiptSchema = z.object({ member_id: z.string(), message_id: z.string(), status: z.string(), paused: z.boolean(), pauseReason: z.string(), workspace: z.string() }); +const ConversationSchema = z.object({ messages: z.array(z.object({ role: z.string(), body: z.string() })), live: z.boolean() }); + +export async function resolveSquadBridgeUrl(env: NodeJS.ProcessEnv = process.env): Promise { + if (env.AUTOHAND_SQUAD_BRIDGE_URL) return parseBridgeUrl(env.AUTOHAND_SQUAD_BRIDGE_URL); + const home = env.AUTOHAND_SQUAD_HOME || path.join(resolveAutohandHome({ environment: env }), 'squad'); + let text: string; + try { text = await readFile(path.join(home, 'web-server.json'), 'utf8'); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + return DEFAULT_BRIDGE_URL; + } + const record = z.object({ url: z.string() }).parse(JSON.parse(text)); + return parseBridgeUrl(record.url); +} + +export class SquadConnection { + private selectedId?: string; + private readonly bridge?: SquadBridge; + private readonly env: NodeJS.ProcessEnv; + + constructor(options: { bridge?: SquadBridge; env?: NodeJS.ProcessEnv } = {}) { + this.bridge = options.bridge; + this.env = options.env ?? process.env; + } + + get selectedMemberId(): string | undefined { return this.selectedId; } + + private async tools() { + if (this.env.AUTOHAND_SQUAD_TEAM_TOKEN || this.env.AUTOHAND_SQUAD_MEMBER || this.env.AUTOHAND_SQUAD_TEAM_URL) throw new Error('This CLI is a managed Squad member. Use its existing member MCP connection.'); + return createTools(this.bridge ?? createBridgeClient(await resolveSquadBridgeUrl(this.env))); + } + + async listMembers(): Promise { + const tools = await this.tools(); + return z.object({ members: z.array(MemberSchema) }).parse(await tools.list_members()).members; + } + + private async resolveMember(query: string): Promise { + const members = await this.listMembers(); + const exact = members.find(member => member.id === query); + const matches = exact ? [exact] : members.filter(member => member.name?.toLowerCase() === query.toLowerCase()); + if (matches.length !== 1) throw new Error(matches.length ? 'Several agents have that name. Select by member ID.' : `Squad agent "${query}" was not found. Use /squad agents.`); + return matches[0]; + } + + async select(query: string): Promise { + const member = await this.resolveMember(query); + this.selectedId = member.id; + return member; + } + + async sendTo(query: string, text: string) { + const member = await this.resolveMember(query); + return this.send(text, member.id); + } + + clearSelection(): void { this.selectedId = undefined; } + + async send(text: string, memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select, or use /message squad .'); + const tools = await this.tools(); + return ReceiptSchema.parse(await tools.message_member({ member_id: memberId, text })); + } + + async history(memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select first.'); + const tools = await this.tools(); + return ConversationSchema.parse(await tools.get_conversation({ member_id: memberId })); + } + + async queue(memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select first.'); + const tools = await this.tools(); + return tools.get_member_queue({ member_id: memberId }); + } +} diff --git a/src/squad/builtin.ts b/src/squad/builtin.ts new file mode 100644 index 000000000..6e60c1a22 --- /dev/null +++ b/src/squad/builtin.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { LoadedConfig, McpServerConfigEntry } from '../types.js'; +import { resolveSquadBridgeUrl } from './SquadConnection.js'; +import { createBridgeClient, createTools, createRpcHandler, serveStdio } from './vendor/runtime.mjs'; + +export interface SquadMcpOptions { + env?: NodeJS.ProcessEnv; + bare?: boolean; + offline?: boolean; + execPath?: string; + entryPath?: string; + compiled?: boolean; +} + +export function ensureSquadMcpServer(config: LoadedConfig, options: SquadMcpOptions = {}): McpServerConfigEntry | undefined { + const env = options.env ?? process.env; + if (options.bare || options.offline || config.mcp?.enabled === false || env.AUTOHAND_SQUAD_TEAM_TOKEN || env.AUTOHAND_SQUAD_MEMBER || env.AUTOHAND_SQUAD_TEAM_URL || /^(1|true|yes)$/i.test(env.AUTOHAND_DISABLE_SQUAD || '')) return undefined; + if (config.mcp?.servers?.some(server => server.name.replace(/_/g, '-').toLowerCase() === 'autohand-squad')) return undefined; + const entry = options.entryPath ?? process.argv[1]; + const compiled = options.compiled ?? /(?:\$bunfs|~BUN)/.test(entry || ''); + if (!compiled && !entry) return undefined; + return { + name: 'autohand-squad', transport: 'stdio', stdioFraming: 'newline', + command: options.execPath ?? process.execPath, + args: [...(!compiled && entry ? [entry] : []), 'squad', 'mcp'], + autoConnect: true, + localOnly: true, + }; +} + +export async function runSquadMcp(argv: string[] = [], env: NodeJS.ProcessEnv = process.env): Promise { + if (env.AUTOHAND_SQUAD_TEAM_TOKEN || env.AUTOHAND_SQUAD_MEMBER || env.AUTOHAND_SQUAD_TEAM_URL) throw new Error('Managed Squad members must use their existing member MCP connection.'); + let bridge: string | undefined; + for (let index = 0; index < argv.length; index++) { + const value = argv[index]; + if (value === '--bridge' && argv[index + 1]) bridge = argv[++index]; + else if (value.startsWith('--bridge=')) bridge = value.slice('--bridge='.length); + else throw new Error('Usage: autohand squad mcp [--bridge http://127.0.0.1:19821]'); + } + const origin = await resolveSquadBridgeUrl({ ...env, ...(bridge ? { AUTOHAND_SQUAD_BRIDGE_URL: bridge } : {}) }); + const handle = createRpcHandler(createTools(createBridgeClient(origin)), { bridgeUrl: origin }); + await serveStdio({ handle }); +} diff --git a/src/squad/vendor/LICENSE b/src/squad/vendor/LICENSE new file mode 100644 index 000000000..e135d88f7 --- /dev/null +++ b/src/squad/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Autohand AI Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/squad/vendor/SOURCE.md b/src/squad/vendor/SOURCE.md new file mode 100644 index 000000000..75de2b38d --- /dev/null +++ b/src/squad/vendor/SOURCE.md @@ -0,0 +1,15 @@ +# Squad MCP source + +Canonical repository: https://github.com/autohandai/squad + +Revision: `4056752878c9319ed31f5ba05466254dfcde5d40` + +`control-plane.mjs`, `runtime.mjs` and `management.mjs` are unchanged copies of +`server/control-plane/` at that revision, under the accompanying MIT license. +The `.d.mts` files are CLI type declarations. The bundle uses the runtime +library, never the app's executable stdio entry point. + +To update, copy all three canonical modules and `LICENSE` together, update this +revision, run Squad's `node scripts/check-control-plane.mjs`, and run the CLI +`bun run proof`. Keep operator and managed-member connections separate. +Do not add an automatic download or execute an app-side server on startup. diff --git a/src/squad/vendor/control-plane.d.mts b/src/squad/vendor/control-plane.d.mts new file mode 100644 index 000000000..8fea9fcfa --- /dev/null +++ b/src/squad/vendor/control-plane.d.mts @@ -0,0 +1,3 @@ +export const DEFAULT_BRIDGE_URL: string; +export function parseBridgeUrl(input: string): string; +export function redactSecrets(text: string): string; diff --git a/src/squad/vendor/control-plane.mjs b/src/squad/vendor/control-plane.mjs new file mode 100644 index 000000000..49300cc91 --- /dev/null +++ b/src/squad/vendor/control-plane.mjs @@ -0,0 +1,366 @@ +// Pure control-plane logic (ADR-0109): tool definitions, argument validation, +// loopback checks, secret redaction, outcome mapping and the two stdio +// framings. No I/O here so scripts/check-control-plane.mjs can import it +// without a bridge. The stdio entry point is mcp-server.mjs. + +import { MANAGEMENT_TOOLS } from './management.mjs'; + +export const SERVER_NAME = 'autohand-squad-control-plane'; +export const SERVER_VERSION = '1'; +export const PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18']; +export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:19821'; +export const NOT_AVAILABLE_CODE = 'not_available_from_bridge'; +export const MAX_MESSAGES = 200; +export const MAX_TAIL_LINES = 60; +export const MAX_WAIT_MS = 600_000; +export const DEFAULT_WAIT_MS = 60_000; +export const POLL_INTERVAL_MS = 400; + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); + +/** Parse and accept only a loopback bridge origin; throws with the reason otherwise. */ +export function parseBridgeUrl(input) { + const raw = String(input || '').trim(); + if (!raw) throw new Error('bridge URL is required'); + let url; + try { url = new URL(raw); } catch { throw new Error(`bridge URL is not a valid URL: ${raw}`); } + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`bridge URL must be http or https: ${raw}`); + const host = url.hostname.toLowerCase(); + if (!LOOPBACK_HOSTS.has(host)) { + throw new Error(`bridge URL must point at this machine (127.0.0.1, localhost or ::1); refusing ${host}. The control plane never exposes a remote bridge.`); + } + if (url.username || url.password) throw new Error('bridge URL must not carry credentials'); + return url.origin; +} + +// Anything that looks like a credential is replaced before it leaves the +// bridge: vendor API keys, GitHub and Slack tokens, AWS access keys, JWTs, +// bearer headers, key=value secrets and long bare hex or base64 runs. Paths +// are kept (the long-run patterns exclude '/'). +const SECRET_PATTERNS = [ + /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, + /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g, + /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi, + /\b[A-Fa-f0-9]{40,}\b/g, + /\b[A-Za-z0-9_-]{48,}={0,2}(?![A-Za-z0-9_\/-])/g, +]; +const KEY_VALUE_SECRET = /\b(api[_-]?key|secret|token|password|passwd|authorization|credential)(s?["']?\s*[:=]\s*["']?)([^\s"',;]{6,})/gi; + +export function redactSecrets(text) { + let value = String(text ?? ''); + for (const pattern of SECRET_PATTERNS) value = value.replace(pattern, '[redacted]'); + value = value.replace(KEY_VALUE_SECRET, (_match, key, separator) => `${key}${separator}[redacted]`); + return value; +} + +/** Last `lines` log lines of a run summary, redacted. */ +export function redactedTail(logs, lines = 20) { + const take = Math.max(1, Math.min(Number(lines) || 20, MAX_TAIL_LINES)); + return (Array.isArray(logs) ? logs : []) + .map((entry) => (typeof entry === 'string' ? entry : `${entry?.source ? `[${entry.source}] ` : ''}${entry?.line || ''}`)) + .filter(Boolean) + .slice(-take) + .map((line) => redactSecrets(line).slice(0, 500)); +} + +/** Map a bridge run summary to a wait outcome, or null while it is still live. */ +export function runOutcome(run) { + const status = String(run?.status || ''); + if (['queued', 'launching', 'running'].includes(status)) return null; + const actions = Array.isArray(run?.channelActions) ? run.channelActions : []; + if (actions.some((action) => action?.status === 'proposed')) { + return { outcome: 'needs-you', reason: 'The member proposed a channel action that is waiting for your approval in the app.' }; + } + if (status === 'completed') return { outcome: 'settled', reason: '' }; + if (status === 'stopped') return { outcome: 'failed', reason: 'The run was stopped.' }; + if (status === 'failed') return { outcome: 'failed', reason: lastErrorLine(run?.logs) || 'The run failed.' }; + return { outcome: 'failed', reason: `Unknown run status ${status || '(empty)'}.` }; +} + +/** Map a bridge goal record to a wait outcome, or null while it is running. */ +export function goalOutcome(goal) { + const status = String(goal?.status || ''); + if (status === 'running') return null; + if (status === 'completed') return { outcome: 'settled', reason: '' }; + if (status === 'paused') return { outcome: 'needs-you', reason: goal?.pauseReason || 'The goal is paused and waits for you to resume it.' }; + if (status === 'blocked') return { outcome: 'needs-you', reason: 'The member reported the goal as blocked.' }; + if (status === 'failed') return { outcome: 'failed', reason: goal?.error || 'The goal failed.' }; + if (status === 'cancelled') return { outcome: 'failed', reason: 'The goal was cancelled.' }; + return { outcome: 'failed', reason: `Unknown goal status ${status || '(empty)'}.` }; +} + +function lastErrorLine(logs) { + const entries = Array.isArray(logs) ? logs : []; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.source === 'stderr' && entry.line) return redactSecrets(entry.line).slice(0, 300); + } + return ''; +} + +// ---- tool schemas --------------------------------------------------------- + +const id = (description) => ({ type: 'string', minLength: 1, maxLength: 200, description }); + +export const TOOLS = [ + { + name: 'list_members', + description: 'List Squad agents by id, name, role, presence, harness and workspace. Uses registered profiles and live presence, supplemented by the app\'s status mirror. Use member ids to address messages unambiguously.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'list_channels', + description: 'Channels with their members, projects and thread and message counts from the bridge\'s channel state.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'get_messages', + description: 'Read a channel\'s threads and messages, or a member\'s bridge-owned run and goal summaries with redacted replies and log tails. Use get_conversation for the actual direct-message transcript.', + inputSchema: { + type: 'object', + properties: { + channel_id: id('Channel to read.'), + thread_id: id('Only this thread of the channel.'), + member_id: id('Member whose bridge-owned runs and goals to read.'), + limit: { type: 'integer', minimum: 1, maximum: MAX_MESSAGES, description: `Newest items to return (default 50, max ${MAX_MESSAGES}).` }, + before: { type: 'string', maxLength: 40, description: 'ISO timestamp; only items created before it.' }, + }, + additionalProperties: false, + }, + }, + { + name: 'search', + description: 'Search the bridge index: channel messages, channels, runs, and whatever the app has pushed (members, tasks, handoffs, canvases, workflow runs). Snippets wrap hits in .', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 500 }, + types: { type: 'string', maxLength: 200, description: 'Comma list of message,channel,member,run,task,handoff,canvas,workflow.' }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + { + name: 'create_member', + description: 'Not available from the bridge: members are created and stored by the Squad app. This tool always returns the documented error so clients learn where to create one.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', maxLength: 120 }, role: { type: 'string', maxLength: 120 } }, + additionalProperties: false, + }, + }, + { + name: 'create_channel', + description: 'Create a channel on the bridge. It appears in the app on its next channel reload.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 80 }, + visibility: { type: 'string', enum: ['public', 'private'] }, + member_ids: { type: 'array', items: id('Member id.'), maxItems: 50 }, + section: { type: 'string', maxLength: 80 }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'send_message', + description: 'Post text to a channel (channel_id) and, when member_id is also given, start a bridge-owned run for that member in the new thread. Or send directly to a member (member_id only): starts a bridge-owned run (mode "run", default) or a durable goal (mode "goal") and returns its id for wait_for_reply. The bridge has no automatic channel responder: a channel message without member_id is posted but nobody is dispatched. Runs need a workspace folder; when omitted, the member\'s last bridge-owned workspace is reused.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', minLength: 1, maxLength: 20000 }, + channel_id: id('Channel to post in.'), + thread_id: id('Reply inside this existing thread instead of opening one.'), + member_id: id('Member to dispatch.'), + workspace: { type: 'string', maxLength: 1000, description: 'Absolute project folder the member works in.' }, + mode: { type: 'string', enum: ['run', 'goal'] }, + author: { type: 'string', maxLength: 80, description: 'Display name recorded on the channel message (default "Control plane").' }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + { + name: 'wait_for_reply', + description: `Wait for a run (run_id) or goal (goal_id) started by send_message. Returns settled, needs-you, failed or timed-out with the reply and a redacted log tail; keys and tokens are replaced with [redacted]. A settled channel run also posts its reply into the thread.`, + inputSchema: { + type: 'object', + properties: { + run_id: id('Run to wait for.'), + goal_id: id('Goal to wait for.'), + timeout_ms: { type: 'integer', minimum: 1000, maximum: MAX_WAIT_MS, description: `Default ${DEFAULT_WAIT_MS}.` }, + tail_lines: { type: 'integer', minimum: 1, maximum: MAX_TAIL_LINES, description: 'Log lines in the tail (default 20).' }, + }, + additionalProperties: false, + }, + }, + { + name: 'interrupt', + description: 'Stop a run (run_id), pause a goal (goal_id), or stop everything the bridge holds for a member (member_id): its warm sessions and live runs, and pause its goals.', + inputSchema: { + type: 'object', + properties: { run_id: id('Run to stop.'), goal_id: id('Goal to pause.'), member_id: id('Member to stop.') }, + additionalProperties: false, + }, + }, + { + name: 'set_model', + description: 'Not available from the bridge: a member\'s harness and model are chosen in the app and sent with each turn; the bridge only holds the global provider settings. This tool always returns the documented error.', + inputSchema: { + type: 'object', + properties: { member_id: id('Member.'), model: { type: 'string', maxLength: 120 }, provider: { type: 'string', maxLength: 60 } }, + additionalProperties: false, + }, + }, + { + name: 'message_member', + description: 'Send a real direct message to an existing Squad member. Returns a durable message_id immediately. Messages run in queue order without interrupting current work, reuse the member\'s saved workspace when omitted, and appear in the app conversation. Does not resume a paused queue. Use wait_for_message for the reply.', + inputSchema: { type: 'object', properties: { member_id: id('Recipient member id from list_members.'), text: { type: 'string', minLength: 1, maxLength: 20000 }, workspace: { type: 'string', minLength: 1, maxLength: 1000, description: 'Explicit project override; otherwise reuse the member workspace.' } }, required: ['member_id', 'text'], additionalProperties: false }, + }, + { + name: 'get_conversation', + description: 'Read recent direct-message turns, including messages sent from the app or CLI, from the bridge history. Up to 60 turns; use since to filter a conversation boundary. Does not read archived chat files.', + inputSchema: { type: 'object', properties: { member_id: id('Member id.'), limit: { type: 'integer', minimum: 1, maximum: 60 }, since: { type: 'string', maxLength: 40, description: 'Only turns after this ISO timestamp.' } }, required: ['member_id'], additionalProperties: false }, + }, + { + name: 'get_member_queue', + description: 'Read a member\'s queued messages and delivery receipts, replies, failures and pause reason. Does not change queue state.', + inputSchema: { type: 'object', properties: { member_id: id('Member id.'), limit: { type: 'integer', minimum: 1, maximum: 200 } }, required: ['member_id'], additionalProperties: false }, + }, + { + name: 'wait_for_message', + description: 'Wait for a direct-message receipt from message_member. Returns settled, failed, needs-you (paused queue), or timed-out; waiting never cancels or resends the message.', + inputSchema: { type: 'object', properties: { member_id: id('Recipient member id.'), message_id: id('Durable message receipt id.'), timeout_ms: { type: 'integer', minimum: 1000, maximum: 25000, description: 'Default 20000; repeat with the same receipt if timed out.' } }, required: ['member_id', 'message_id'], additionalProperties: false }, + }, + { + name: 'list_artifacts', + description: 'Find published agent artifacts in the Squad Library. Returns metadata and local preview/download links, without executing artifact content.', + inputSchema: { type: 'object', properties: { member_id: id('Filter by owner.'), query: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, additionalProperties: false }, + }, + { + name: 'get_artifact', + description: 'Read metadata for one published artifact, including its local preview/download links. Does not read arbitrary workspace files.', + inputSchema: { type: 'object', properties: { artifact_id: id('Artifact id from list_artifacts.') }, required: ['artifact_id'], additionalProperties: false }, + }, + { + name: 'list_memories', + description: 'Search the Squad knowledge library, optionally by member. This operator connection reads memories visible to the person; it is never supplied to a managed Squad member.', + inputSchema: { type: 'object', properties: { member_id: id('Filter by owner.'), query: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100 }, offset: { type: 'integer', minimum: 0, maximum: 100000 } }, additionalProperties: false }, + }, + { + name: 'read_memory', + description: 'Read one entry from the Squad knowledge library. Read-only operator access; does not edit or share memories.', + inputSchema: { type: 'object', properties: { memory_id: id('Memory id from list_memories.') }, required: ['memory_id'], additionalProperties: false }, + }, +]; + +TOOLS.push(...MANAGEMENT_TOOLS); +export const TOOL_NAMES = TOOLS.map((tool) => tool.name); + +/** Small JSON-schema subset validator: types, enums, bounds, required, unknown fields. */ +export function validateArgs(schema, input, path = 'arguments') { + const errors = []; + const check = (node, value, at) => { + if (node.anyOf) { + if (!node.anyOf.some(branch => validateArgs(branch, value, at).length === 0)) errors.push(`${at} must match one of the allowed types`); + return; + } + if (node.type === 'object') { + if (value === undefined || value === null) value = {}; + if (typeof value !== 'object' || Array.isArray(value)) { errors.push(`${at} must be an object`); return; } + const properties = node.properties || {}; + for (const key of Object.keys(value)) { + if (!(key in properties)) { if (node.additionalProperties === false) errors.push(`${at}.${key} is not a known field`); } + else if (value[key] !== undefined) check(properties[key], value[key], `${at}.${key}`); + } + for (const key of node.required || []) { + if (value[key] === undefined || value[key] === null || value[key] === '') errors.push(`${at}.${key} is required`); + } + return; + } + if (node.type === 'array') { + if (!Array.isArray(value)) { errors.push(`${at} must be an array`); return; } + if (node.maxItems !== undefined && value.length > node.maxItems) errors.push(`${at} has more than ${node.maxItems} items`); + value.forEach((item, index) => check(node.items || {}, item, `${at}[${index}]`)); + return; + } + if (node.type === 'string') { + if (typeof value !== 'string') { errors.push(`${at} must be a string`); return; } + if (node.minLength !== undefined && value.trim().length < node.minLength) errors.push(`${at} must not be empty`); + if (node.maxLength !== undefined && value.length > node.maxLength) errors.push(`${at} is longer than ${node.maxLength} characters`); + if (node.enum && !node.enum.includes(value)) errors.push(`${at} must be one of ${node.enum.join(', ')}`); + return; + } + if (node.type === 'integer' || node.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value) || (node.type === 'integer' && !Number.isInteger(value))) { errors.push(`${at} must be ${node.type === 'integer' ? 'an integer' : 'a number'}`); return; } + if (node.minimum !== undefined && value < node.minimum) errors.push(`${at} must be at least ${node.minimum}`); + if (node.maximum !== undefined && value > node.maximum) errors.push(`${at} must be at most ${node.maximum}`); + return; + } + if (node.type === 'boolean' && typeof value !== 'boolean') errors.push(`${at} must be true or false`); + }; + check(schema, input, path); + return errors; +} + +// ---- stdio framing -------------------------------------------------------- + +/** Encode one JSON-RPC message in the given framing ('newline' | 'header'). */ +export function encodeFrame(message, framing) { + const encoded = Buffer.from(JSON.stringify(message)); + return framing === 'header' + ? Buffer.concat([Buffer.from(`Content-Length: ${encoded.length}\r\n\r\n`), encoded]) + : Buffer.concat([encoded, Buffer.from('\n')]); +} + +/** + * Pull every complete frame off `buffer`. Returns `{ frames: [{ body, framing }], rest }`. + * A frame that starts with a Content-Length header is read by length; anything + * else is one JSON document per line. Blank lines are skipped. + */ +export function decodeFrames(buffer) { + const frames = []; + let rest = buffer; + for (;;) { + while (rest.length && (rest[0] === 0x0a || rest[0] === 0x0d || rest[0] === 0x20 || rest[0] === 0x09)) rest = rest.subarray(1); + if (!rest.length) break; + if (/^content-length:/i.test(rest.subarray(0, 15).toString())) { + const headerEnd = rest.indexOf('\r\n\r\n'); + const looseEnd = headerEnd < 0 ? rest.indexOf('\n\n') : -1; + const end = headerEnd >= 0 ? headerEnd : looseEnd; + const separator = headerEnd >= 0 ? 4 : 2; + if (end < 0) break; + const match = rest.subarray(0, end).toString().match(/content-length:\s*(\d+)/i); + const size = Number(match?.[1]); + if (!Number.isFinite(size)) { rest = rest.subarray(end + separator); continue; } + if (rest.length < end + separator + size) break; + frames.push({ body: rest.subarray(end + separator, end + separator + size), framing: 'header' }); + rest = rest.subarray(end + separator + size); + continue; + } + const lineEnd = rest.indexOf('\n'); + if (lineEnd < 0) break; + const body = rest.subarray(0, lineEnd); + rest = rest.subarray(lineEnd + 1); + if (body.toString().trim()) frames.push({ body, framing: 'newline' }); + } + return { frames, rest }; +} + +/** Choose the protocol version to answer initialize with. */ +export function negotiateProtocol(requested) { + return PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS.at(-1); +} + +/** Documented refusal for tools the bridge cannot honour. */ +export function notAvailable(tool, why, next) { + return Object.assign(new Error(`${tool} is not available from the bridge: ${why} ${next}`), { code: NOT_AVAILABLE_CODE }); +} diff --git a/src/squad/vendor/management.mjs b/src/squad/vendor/management.mjs new file mode 100644 index 000000000..d3b9ac387 --- /dev/null +++ b/src/squad/vendor/management.mjs @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; + +const text = (maxLength = 2000) => ({ type: 'string', minLength: 1, maxLength }); +const id = () => text(200); +const ids = (minimum = 0) => ({ type: 'array', items: id(), minItems: minimum, maxItems: 20 }); +const revision = { type: 'integer', minimum: 0 }; +const mode = { type: 'string', enum: ['allow', 'ask', 'block'] }; +const policies = { type: 'array', maxItems: 200, items: { type: 'object', properties: { id: id(), mode }, required: ['id', 'mode'], additionalProperties: false } }; +const schedule = { + type: 'object', additionalProperties: false, required: ['kind', 'timeZone'], + properties: { + kind: { type: 'string', enum: ['once', 'weekdays', 'interval', 'monthly', 'yearly', 'cron'] }, + timeZone: text(100), time: text(5), date: text(10), + days: { type: 'array', maxItems: 7, items: { type: 'integer', minimum: 0, maximum: 6 } }, + everyMinutes: { type: 'integer', minimum: 1, maximum: 1440 }, startDate: text(10), startTime: text(5), + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { anyOf: [{ type: 'integer', minimum: 1, maximum: 31 }, { type: 'string', enum: ['last'] }] }, + cron: text(120), + }, +}; +const tool = (name, description, properties, required = [], readOnly = false) => ({ name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, openWorldHint: ['send_channel_message', 'run_routine'].includes(name) } }); +const member = { member_id: id(), expected_revision: revision }; +const routineFields = { title: text(160), instructions: text(20000), workspace: text(1000), schedule, enabled: { type: 'boolean' }, max_run_minutes: { type: 'integer', minimum: 1, maximum: 1440 } }; + +export const MANAGEMENT_TOOLS = [ + tool('get_member_profile', 'Read a member\'s saved name, personality, operating instructions, skills, folders and abilities, with its revision and available ability IDs. Operator access.', { member_id: id() }, ['member_id'], true), + tool('update_member_profile', 'Change a saved member name, role, personality or operating instructions. Preserves other fields and current work. expected_revision rejects stale edits; changes apply to later turns.', { ...member, name: text(60), role: text(80), description: { type: 'string', maxLength: 600 }, instructions: { type: 'string', maxLength: 1200 }, personality: { type: 'string', maxLength: 600 } }, ['member_id']), + tool('set_member_abilities', 'Set specific capability/tool policies to allow, ask or block. Get supported IDs from get_member_profile. Preserves unmentioned policies and workspace overrides. Optional workspace edits that folder\'s override. Does not grant OS permissions or disable safety guards.', { ...member, capabilities: policies, tool_policies: policies, workspace: text(1000) }, ['member_id']), + tool('add_member_directory', 'Grant an existing project folder to a member, equivalent to --add-dir on later turns. Persists the folder on the member, validates its real path, and preserves other folders. Does not restart current work.', { ...member, path: text(1000) }, ['member_id', 'path']), + tool('remove_member_directory', 'Remove an extra folder grant for later turns. Does not delete files or interrupt current work. The current primary workspace cannot be removed here.', { ...member, path: text(1000) }, ['member_id', 'path']), + tool('list_skills', 'Discover local and registry skill packages available for a member\'s workspace. Choose package IDs for set_member_skills; offset supports catalog pagination.', { member_id: id(), query: { type: 'string', maxLength: 500 }, offset: { type: 'integer', minimum: 0, maximum: 100000 } }, ['member_id'], true), + tool('set_member_skills', 'Replace a member\'s selected skill-package IDs (empty clears selections). Validates references against the catalog. Packages are installed/pinned by the existing engine preparation on the next turn; this receipt is assignment, not installation.', { ...member, skill_ids: { type: 'array', maxItems: 32, items: text(500) } }, ['member_id', 'skill_ids']), + tool('get_channel', 'Read one channel\'s saved settings, member IDs and projects.', { channel_id: id() }, ['channel_id'], true), + tool('update_channel', 'Change a channel name, purpose or section without replacing its members or history.', { channel_id: id(), name: text(80), purpose: { type: 'string', maxLength: 20000 }, section: { type: 'string', maxLength: 80 } }, ['channel_id']), + tool('set_channel_members', 'Add/remove existing members atomically from a channel roster. Does not dispatch work or delete messages.', { channel_id: id(), add: ids(), remove: ids() }, ['channel_id']), + tool('list_channel_threads', 'List a channel\'s newest conversation threads. Use get_messages with thread_id to read replies.', { channel_id: id(), limit: { type: 'integer', minimum: 1, maximum: 100 } }, ['channel_id'], true), + tool('create_group_chat', 'Create a persistent private channel for two or more existing agents. Does not send a message or start work until explicitly requested.', { name: text(80), member_ids: ids(2) }, ['name', 'member_ids']), + tool('send_channel_message', 'Post into a channel/group thread and optionally dispatch the explicitly named channel members. Omit member_ids to publish only. Replies persist in the thread even if this MCP client disconnects. Returns per-member run receipts/errors for wait_for_reply.', { channel_id: id(), thread_id: id(), text: text(20000), member_ids: ids() }, ['channel_id', 'text']), + tool('list_routines', 'Read scheduled routines, pause/enabled state, next occurrences and recent runs; optionally filter by member.', { member_id: id() }, [], true), + tool('get_routine', 'Read one routine and its run history.', { routine_id: id() }, ['routine_id'], true), + tool('preview_routine', 'Validate an explicit IANA-timezone schedule and show its next three occurrences without saving or running it.', { schedule }, ['schedule'], true), + tool('create_routine', 'Save a routine for an existing member. Disabled by default; enabled:true explicitly schedules it. Uses the member workspace unless overridden. Does not run it immediately.', { member_id: id(), ...routineFields }, ['member_id', 'title', 'instructions', 'schedule']), + tool('update_routine', 'Edit a routine or set enabled:false/true to pause/resume scheduling. Preserves unmentioned fields, member ownership and history; does not stop an active run.', { routine_id: id(), ...routineFields }, ['routine_id']), + tool('run_routine', 'Explicitly run a saved routine now using its saved member, workspace and permissions. Returns a run receipt; refuses overlap with an already active run.', { routine_id: id() }, ['routine_id']), +]; + +export function createManagementTools(bridge, legacy) { + const profile = memberId => bridge.get(`/api/control-plane/members/${encodeURIComponent(memberId)}`); + async function patchMember(memberId, patch, expectedRevision) { + const current = await profile(memberId); + return bridge.patch(`/api/control-plane/members/${encodeURIComponent(memberId)}`, { ...patch, expectedRevision: expectedRevision ?? current.revision }); + } + async function known(idsToCheck) { + const roster = await legacy.list_members(); + for (const memberId of idsToCheck) if (!roster.members.some(item => item.id === memberId)) throw new Error(`Member ${memberId} was not found. Choose an ID from list_members.`); + } + async function channelFor(channelId) { + const state = await bridge.get('/api/channels'); + const channel = (state.channels || []).find(item => item.id === channelId); + if (!channel) throw Object.assign(new Error('Channel not found.'), { status: 404 }); + return { channel, state }; + } + const routineBody = args => Object.fromEntries(Object.entries(args).filter(([key]) => !['member_id', 'routine_id', 'max_run_minutes'].includes(key)).concat(args.max_run_minutes !== undefined ? [['maxRunMinutes', args.max_run_minutes]] : [])); + + return { + get_member_profile: ({ member_id }) => profile(member_id), + update_member_profile: ({ member_id, expected_revision, ...patch }) => patchMember(member_id, patch, expected_revision), + set_member_abilities: ({ member_id, expected_revision, ...abilities }) => patchMember(member_id, { abilities }, expected_revision), + async add_member_directory({ member_id, path, expected_revision }) { + const current = await profile(member_id); + return bridge.post(`/api/control-plane/members/${encodeURIComponent(member_id)}/directories`, { operation: 'add', path, expectedRevision: expected_revision ?? current.revision }); + }, + async remove_member_directory({ member_id, path, expected_revision }) { + const current = await profile(member_id); + return bridge.post(`/api/control-plane/members/${encodeURIComponent(member_id)}/directories`, { operation: 'remove', path, expectedRevision: expected_revision ?? current.revision }); + }, + async list_skills({ member_id, query = '', offset = 0 }) { + const current = await profile(member_id); + return bridge.get(`/api/skills/catalog?${new URLSearchParams({ q: query, workspace: current.workspace || '', offset: String(offset) })}`); + }, + set_member_skills: ({ member_id, expected_revision, skill_ids }) => patchMember(member_id, { skills: skill_ids }, expected_revision), + async get_channel({ channel_id }) { return (await channelFor(channel_id)).channel; }, + async update_channel({ channel_id, ...patch }) { + if (!Object.keys(patch).length) throw new Error('Provide at least one channel setting to change.'); + return bridge.patch(`/api/channels/${encodeURIComponent(channel_id)}`, patch); + }, + async set_channel_members({ channel_id, add = [], remove = [] }) { + await channelFor(channel_id); + if (!add.length && !remove.length) throw new Error('Choose members to add or remove.'); + if (add.some(id => remove.includes(id))) throw new Error('A member cannot be both added and removed.'); + await known(add); + return bridge.post(`/api/channels/${encodeURIComponent(channel_id)}/members`, { add, remove }); + }, + async list_channel_threads({ channel_id, limit = 50 }) { + await channelFor(channel_id); + const threads = await bridge.get(`/api/channels/${encodeURIComponent(channel_id)}/threads`); + return { threads: [...threads].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, limit) }; + }, + async create_group_chat({ name, member_ids }) { + const members = [...new Set(member_ids)]; + if (members.length < 2) throw new Error('Choose at least two different members.'); + await known(members); + return { channel: await bridge.post('/api/channels', { name, visibility: 'private', memberIds: members }) }; + }, + async send_channel_message({ channel_id, thread_id, text, member_ids = [] }) { + const { channel, state } = await channelFor(channel_id); + const recipients = [...new Set(member_ids)]; + if (recipients.some(id => !channel.memberIds.includes(id))) throw new Error('Every recipient must belong to the channel. Add them with set_channel_members first.'); + await known(recipients); + if (thread_id && !(state.threads || []).some(thread => thread.id === thread_id && thread.channelId === channel_id)) throw new Error('The thread does not belong to this channel.'); + const profiles = await Promise.all(recipients.map(id => profile(id))); + if (profiles.some(item => !item.workspace)) throw new Error('Each recipient needs a saved workspace before dispatch.'); + const messageId = `operator-${randomUUID()}`; + const threadId = thread_id || `thread-${randomUUID()}`; + const at = new Date().toISOString(); + await bridge.put('/api/channels/sync', { + ...(!thread_id ? { threads: [{ id: threadId, channelId: channel_id, parentMessageId: messageId, title: text.slice(0, 160), creatorId: 'person', memberIds: channel.memberIds, createdAt: at, updatedAt: at }] } : {}), + messages: { [channel_id]: [{ id: messageId, role: 'user', body: text, authorName: 'You', threadId, status: 'complete', createdAt: at, updatedAt: at }] }, + }); + const deliveries = await Promise.all(recipients.map(async (memberId, index) => { + try { + const run = await bridge.post('/api/runs', { agentId: memberId, prompt: text, workspace: profiles[index].workspace, title: text.slice(0, 120), channel: { channelId: channel_id, channelName: channel.name, threadId, parentMessageId: messageId, operatorReply: true } }); + return { member_id: memberId, run_id: run.id, status: run.status }; + } catch (error) { return { member_id: memberId, status: 'failed', error: String(error.message || error) }; } + })); + return { channel_id, thread_id: threadId, message_id: messageId, deliveries, note: recipients.length ? 'Dispatched only the requested members. Check each run receipt; retrying this send creates a new message.' : 'Published without dispatching any member.' }; + }, + list_routines: ({ member_id } = {}) => bridge.get(`/api/routines${member_id ? `?memberId=${encodeURIComponent(member_id)}` : ''}`), + get_routine: ({ routine_id }) => bridge.get(`/api/routines/${encodeURIComponent(routine_id)}`), + preview_routine: ({ schedule }) => bridge.post('/api/routines/preview', { schedule }), + async create_routine({ member_id, ...args }) { + const current = await profile(member_id); + return bridge.post('/api/routines', { ...routineBody(args), memberId: member_id, workspace: args.workspace || current.workspace, enabled: args.enabled === true }); + }, + async update_routine({ routine_id, ...args }) { + if (!Object.keys(args).length) throw new Error('Provide at least one routine setting to change.'); + return bridge.patch(`/api/routines/${encodeURIComponent(routine_id)}`, routineBody(args)); + }, + run_routine: ({ routine_id }) => bridge.post(`/api/routines/${encodeURIComponent(routine_id)}/run`, {}), + }; +} diff --git a/src/squad/vendor/runtime.d.mts b/src/squad/vendor/runtime.d.mts new file mode 100644 index 000000000..6df113e40 --- /dev/null +++ b/src/squad/vendor/runtime.d.mts @@ -0,0 +1,11 @@ +import type { Readable, Writable } from 'node:stream'; +export interface SquadBridge { + get(path: string): Promise; + post(path: string, body?: Record): Promise; + patch(path: string, body?: Record): Promise; + put(path: string, body?: Record): Promise; +} +export function createBridgeClient(origin: string, options?: { fetchImpl?: typeof fetch }): SquadBridge; +export function createTools(bridge: SquadBridge): Record, options?: { signal?: AbortSignal }) => Promise>; +export function createRpcHandler(tools: ReturnType, options?: { bridgeUrl?: string }): (request: unknown) => Promise; +export function serveStdio(options: { input?: Readable; output?: Writable; handle: ReturnType }): Promise; diff --git a/src/squad/vendor/runtime.mjs b/src/squad/vendor/runtime.mjs new file mode 100644 index 000000000..483f1bbd0 --- /dev/null +++ b/src/squad/vendor/runtime.mjs @@ -0,0 +1,525 @@ +#!/usr/bin/env node +// Autohand Squad control plane (ADR-0109): a dependency-free stdio MCP server +// that lets Claude Desktop, Cursor or any MCP client coordinate this Squad +// through the local bridge. JSON-RPC 2.0 over stdin/stdout in either newline +// or Content-Length framing; each reply uses the framing of its request. +// +// node server/control-plane/mcp-server.mjs [--bridge http://127.0.0.1:19821] +// AUTOHAND_SQUAD_BRIDGE_URL=http://127.0.0.1:19821 node server/control-plane/mcp-server.mjs +// +// Only a loopback bridge is accepted. Approvals, deletion, credentials and +// computer lifecycle are never exposed; see docs/integration/control-plane.md. + +import { randomUUID } from 'node:crypto'; +import { createManagementTools } from './management.mjs'; + +import { + DEFAULT_WAIT_MS, MAX_MESSAGES, MAX_WAIT_MS, NOT_AVAILABLE_CODE, POLL_INTERVAL_MS, SERVER_NAME, SERVER_VERSION, TOOLS, + decodeFrames, encodeFrame, goalOutcome, negotiateProtocol, notAvailable, parseBridgeUrl, redactSecrets, redactedTail, runOutcome, validateArgs, +} from './control-plane.mjs'; + +const HTTP_TIMEOUT_MS = 30_000; +const REPLY_CHARS = 8000; +const BODY_CHARS = 4000; +const DEFAULT_AUTHOR = 'Control plane'; + +// ---- bridge client -------------------------------------------------------- + +export function createBridgeClient(origin, { fetchImpl = fetch } = {}) { + async function call(path, { method = 'GET', body, timeoutMs = HTTP_TIMEOUT_MS, signal } = {}) { + let response; + try { + response = await fetchImpl(`${origin}${path}`, { + method, + redirect: 'error', + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + throw new Error(`The Squad bridge at ${origin} did not answer (${error?.message || error}). Start Autohand Squad or pass --bridge.`); + } + let envelope = null; + try { envelope = await response.json(); } catch { envelope = null; } + if (!envelope || envelope.success !== true) { + const unavailableManagement = response.status === 404 && path.startsWith('/api/control-plane/') && envelope?.error !== 'Member not found.'; + const message = unavailableManagement ? 'This Squad bridge does not support member management. Update Autohand Squad, then reconnect.' : envelope?.error || `${method} ${path} returned HTTP ${response.status}`; + throw Object.assign(new Error(message), { status: response.status }); + } + return envelope.data; + } + return { + get: (path, options) => call(path, options), + post: (path, body = {}) => call(path, { method: 'POST', body }), + put: (path, body = {}) => call(path, { method: 'PUT', body }), + patch: (path, body = {}) => call(path, { method: 'PATCH', body }), + }; +} + +// ---- tools ---------------------------------------------------------------- + +const clip = (text, limit) => { const value = String(text ?? ''); return value.length > limit ? `${value.slice(0, limit)}…` : value; }; +const byTime = (key) => (a, b) => String(a[key] || '').localeCompare(String(b[key] || '')); + +function latestByMember(runs, goals) { + const latest = new Map(); + const consider = (memberId, record) => { + if (!memberId) return; + const previous = latest.get(memberId); + if (!previous || String(record.at || '') > String(previous.at || '')) latest.set(memberId, record); + }; + for (const run of runs) consider(run.agentId, { at: run.startedAt, harness: run.harness || null, workspace: run.workspace || '' }); + for (const goal of goals) consider(goal.agentId, { at: goal.createdAt || goal.updatedAt, harness: goal.harness || null, workspace: goal.workspace || '' }); + return latest; +} + +export function createTools(bridge) { + async function channelsState() { + return bridge.get('/api/channels'); + } + + async function runsFor(memberId = '') { + const runs = await bridge.get('/api/runs'); + return (Array.isArray(runs) ? runs : []).filter((run) => !memberId || run.agentId === memberId); + } + + async function goalsFor(memberId = '') { + const goals = await bridge.get(`/api/goals${memberId ? `?agentId=${encodeURIComponent(memberId)}` : ''}`); + return Array.isArray(goals) ? goals : []; + } + + async function postChannelRecords(channelId, { messages = [], threads = [] }) { + await bridge.put('/api/channels/sync', { + ...(threads.length ? { threads } : {}), + ...(messages.length ? { messages: { [channelId]: messages } } : {}), + }); + } + + async function lastWorkspace(memberId) { + const [runs, goals, state, queue] = await Promise.all([runsFor(memberId), goalsFor(memberId), channelsState(), bridge.get(`/api/queues/${encodeURIComponent(memberId)}`)]); + const agent = state?.agentChannelAccess?.[memberId]?.agent || {}; + const latestQueue = [...(queue?.items || [])].reverse().find(item => item.workspace); + const last = latestByMember(runs, goals).get(memberId); + return (latestQueue && (!last || latestQueue.createdAt > last.at) ? latestQueue.workspace : last?.workspace) + || agent.workspace || agent.projects?.[0]?.path || ''; + } + + async function startWork({ memberId, text, workspace, mode, channel }) { + const folder = String(workspace || '').trim() || await lastWorkspace(memberId); + if (!folder) { + throw new Error(`workspace is required: pass the absolute project folder ${memberId} should work in. The bridge has no earlier run or goal for this member to reuse one from.`); + } + const title = clip(text.replace(/\s+/g, ' ').trim(), 120); + if (mode === 'goal') { + if (channel) throw new Error('mode "goal" is only available for a direct message to a member, not inside a channel.'); + const data = await bridge.post('/api/chat', { agentId: memberId, prompt: text, workspace: folder, mode: 'goal', title }); + const goal = data?.goal || {}; + return { kind: 'goal', goal_id: goal.id || data?.goalId || '', status: goal.status || 'running', workspace: folder }; + } + const run = await bridge.post('/api/runs', { + agentId: memberId, + prompt: text, + title, + workspace: folder, + ...(channel ? { channelId: channel.channelId, threadId: channel.threadId, parentMessageId: channel.parentMessageId, channelName: channel.channelName } : {}), + }); + return { kind: 'run', run_id: run?.id || '', status: run?.status || 'running', workspace: folder, transport: run?.transport || '', harness: run?.harness || '' }; + } + + async function mirrorChannelReply(run, outcome, reply) { + const channel = run?.channel; + if (!channel?.channelId || !channel.threadId) return false; + const text = reply || (outcome.outcome === 'failed' ? `Run ${outcome.reason}` : ''); + if (!text) return false; + const at = run.finishedAt || new Date().toISOString(); + try { + await postChannelRecords(channel.channelId, { + messages: [{ + id: `${run.id}-reply`, + channelId: channel.channelId, + role: 'agent', + agentId: run.agentId, + authorName: run.agentId, + body: text, + status: outcome.outcome === 'failed' ? 'error' : 'complete', + threadId: channel.threadId, + parentMessageId: channel.parentMessageId || '', + workspace: run.workspace || '', + transport: run.transport || '', + runId: run.id, + createdAt: at, + updatedAt: at, + }], + }); + return true; + } catch { + return false; + } + } + + const tools = { + async list_members() { + const [snapshot, presence, runs, goals, state] = await Promise.all([ + bridge.get('/api/status/snapshot').catch(() => null), + bridge.get('/api/members/presence').catch(() => ({ presence: {} })), + runsFor(), + goalsFor(), + channelsState(), + ]); + const latest = latestByMember(runs, goals); + const rows = new Map(); + for (const member of Array.isArray(snapshot?.members) ? snapshot.members : []) { + if (!member?.id) continue; + rows.set(member.id, { id: member.id, name: member.name || member.id, role: member.role || null, status: member.status || 'online', lastActivityAt: member.lastActivityAt || null }); + } + for (const memberId of [...Object.keys(presence?.presence || {}), ...latest.keys()]) { + if (!rows.has(memberId)) rows.set(memberId, { id: memberId, name: null, role: null, status: null, lastActivityAt: null }); + } + for (const [memberId, access] of Object.entries(state?.agentChannelAccess || {})) { + if (access?.removed) { rows.delete(memberId); continue; } + if (!access?.agent) continue; + const agent = access.agent; + rows.set(memberId, { ...rows.get(memberId), id: memberId, name: agent.name || memberId, role: agent.role || null, status: rows.get(memberId)?.status || 'online' }); + } + const members = [...rows.values()].map((row) => { + const current = presence?.presence?.[row.id] || {}; + const agent = state?.agentChannelAccess?.[row.id]?.agent || {}; + const last = latest.get(row.id) || {}; + return { ...row, presence: current.state || 'unknown', since: current.since || null, activeRuns: current.activeRuns || 0, harness: last.harness || agent.harness?.id || null, lastWorkspace: last.workspace || agent.workspace || agent.projects?.[0]?.path || null }; + }); + return { + members, + snapshotAt: snapshot?.generatedAt || snapshot?.at || null, + source: { + roster: 'registered member profiles, supplemented by web-status.json and live presence', + harness: 'the member\'s latest bridge-owned work, falling back to its registered profile', + }, + }; + }, + + async list_channels() { + const state = await channelsState(); + const channels = (state?.channels || []).map((channel) => ({ + id: channel.id, + name: channel.name, + visibility: channel.visibility, + memberIds: channel.memberIds || [], + projects: (channel.projects || []).map((project) => project.name || project.path), + section: channel.section || '', + threads: (state.threads || []).filter((thread) => thread.channelId === channel.id).length, + messages: (state.messages || []).filter((message) => message.channelId === channel.id).length, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt, + })); + return { channels, revision: state?.revision ?? null }; + }, + + async get_messages({ channel_id, thread_id, member_id, limit, before } = {}) { + const take = Math.min(Math.max(Number(limit) || 50, 1), MAX_MESSAGES); + const cutoff = before ? String(before) : ''; + if (channel_id && member_id) throw new Error('pass channel_id or member_id, not both'); + if (channel_id) { + const state = await channelsState(); + const channel = (state?.channels || []).find((item) => item.id === channel_id); + if (!channel) throw Object.assign(new Error(`channel ${channel_id} was not found`), { status: 404 }); + const threads = (state.threads || []).filter((thread) => thread.channelId === channel_id && (!thread_id || thread.id === thread_id)) + .map((thread) => ({ id: thread.id, title: thread.title, creatorId: thread.creatorId, memberIds: thread.memberIds || [], replyCount: thread.replyCount || 0, createdAt: thread.createdAt, updatedAt: thread.updatedAt })); + const all = (state.messages || []) + .filter((message) => message.channelId === channel_id && (!thread_id || message.threadId === thread_id) && (!cutoff || String(message.createdAt) < cutoff)) + .sort(byTime('createdAt')); + const messages = all.slice(-take).map((message) => ({ + id: message.id, threadId: message.threadId || '', parentMessageId: message.parentMessageId || '', role: message.role, agentId: message.agentId || '', + authorName: message.authorName || '', body: clip(message.body, BODY_CHARS), status: message.status, createdAt: message.createdAt, updatedAt: message.updatedAt, + })); + return { channel: { id: channel.id, name: channel.name }, threads, messages, total: all.length, truncated: all.length > messages.length }; + } + if (member_id) { + const [runs, goals] = await Promise.all([runsFor(member_id), goalsFor(member_id)]); + const items = [ + ...runs.map((run) => ({ + kind: 'run', id: run.id, title: run.title, status: run.status, harness: run.harness, workspace: run.workspace, channel: run.channel || null, + reply: clip(redactSecrets(run.reply || ''), REPLY_CHARS), tail: redactedTail(run.logs, 10), createdAt: run.startedAt, finishedAt: run.finishedAt || null, + })), + ...goals.map((goal) => ({ + kind: 'goal', id: goal.id, title: goal.title || goal.prompt || '', status: goal.status, harness: goal.harness, workspace: goal.workspace, + summary: clip(redactSecrets(goal.summary || ''), REPLY_CHARS), latestReply: clip(redactSecrets(goal.latestReply || ''), REPLY_CHARS), turnCount: goal.turnCount || 0, + createdAt: goal.createdAt, updatedAt: goal.updatedAt, finishedAt: goal.finishedAt || null, + })), + ].filter((item) => !cutoff || String(item.createdAt) < cutoff).sort(byTime('createdAt')); + const page = items.slice(-take); + return { + member_id, + items: page, + total: items.length, + truncated: items.length > page.length, + note: 'These are run and goal summaries. Use get_conversation for the actual direct-message transcript.', + }; + } + throw new Error('channel_id or member_id is required'); + }, + + async search({ query, types, limit } = {}) { + const params = new URLSearchParams({ q: String(query || '') }); + if (types) params.set('types', String(types)); + if (limit) params.set('limit', String(Math.min(Number(limit) || 40, 100))); + const data = await bridge.get(`/api/search?${params}`); + return { query: data?.query ?? query, mode: data?.mode || '', results: (data?.results || []).slice(0, 100), tookMs: data?.tookMs ?? null }; + }, + + async create_member() { + throw notAvailable('create_member', 'members are created and stored by the Squad app and only mirrored to the bridge as a read-only status snapshot.', 'Create the member in the app (New member); it then appears in list_members.'); + }, + + async create_channel({ name, visibility, member_ids, section } = {}) { + const channel = await bridge.post('/api/channels', { + name, + ...(visibility ? { visibility } : {}), + ...(Array.isArray(member_ids) ? { memberIds: member_ids } : {}), + ...(section ? { section } : {}), + creatorId: '', + }); + return { channel: { id: channel.id, name: channel.name, visibility: channel.visibility, memberIds: channel.memberIds || [], createdAt: channel.createdAt } }; + }, + + async send_message({ text, channel_id, thread_id, member_id, workspace, mode, author } = {}) { + const body = String(text || '').trim(); + if (!channel_id && !member_id) throw new Error('channel_id or member_id is required'); + if (!channel_id && thread_id) throw new Error('thread_id needs a channel_id'); + if (!channel_id) { + const work = await startWork({ memberId: member_id, text: body, workspace, mode: mode || 'run' }); + return { ...work, note: `The ${work.kind} is bridge-owned: it appears in the app's Mission Control${work.kind === 'goal' ? ' and the member\'s goals' : ''}, not as a direct-chat message. Use wait_for_reply with its id.` }; + } + const state = await channelsState(); + const channel = (state?.channels || []).find((item) => item.id === channel_id); + if (!channel) throw Object.assign(new Error(`channel ${channel_id} was not found`), { status: 404 }); + const parentThread = thread_id ? (state.threads || []).find((thread) => thread.id === thread_id && thread.channelId === channel_id) : null; + if (thread_id && !parentThread) throw Object.assign(new Error(`thread ${thread_id} was not found in channel ${channel_id}`), { status: 404 }); + const at = new Date().toISOString(); + const messageId = `cp-${randomUUID()}`; + const threadId = thread_id || messageId; + const message = { + id: messageId, channelId: channel_id, role: 'user', agentId: '', authorName: String(author || '').trim() || DEFAULT_AUTHOR, body, + status: 'complete', threadId, parentMessageId: parentThread ? parentThread.parentMessageId || parentThread.id : '', createdAt: at, updatedAt: at, + }; + const threads = thread_id ? [] : [{ id: threadId, channelId: channel_id, title: body, creatorId: '', memberIds: member_id ? [member_id] : [], createdAt: at, updatedAt: at }]; + await postChannelRecords(channel_id, { messages: [message], threads }); + let dispatched = null; + if (member_id) { + dispatched = await startWork({ memberId: member_id, text: body, workspace, mode: mode || 'run', channel: { channelId: channel_id, threadId, parentMessageId: messageId, channelName: channel.name } }); + } + return { + message: { id: messageId, channel_id, thread_id: threadId }, + ...(dispatched ? { run_id: dispatched.run_id, status: dispatched.status, workspace: dispatched.workspace, harness: dispatched.harness } : {}), + dispatched: Boolean(dispatched), + note: dispatched + ? 'A bridge-owned run was started for the member in this thread; wait_for_reply posts its reply into the thread when it settles.' + : 'The message was posted. Nobody was dispatched: the bridge has no automatic channel responder; pass member_id to start a run for a member.', + }; + }, + + async wait_for_reply({ run_id, goal_id, timeout_ms, tail_lines } = {}, { signal } = {}) { + if (!run_id && !goal_id) throw new Error('run_id or goal_id is required'); + if (run_id && goal_id) throw new Error('pass run_id or goal_id, not both'); + const budget = Math.min(Math.max(Number(timeout_ms) || DEFAULT_WAIT_MS, 1000), MAX_WAIT_MS); + const started = Date.now(); + for (;;) { + if (signal?.aborted) throw new Error('wait_for_reply was cancelled'); + if (run_id) { + const run = await bridge.get(`/api/runs/${encodeURIComponent(run_id)}`); + const outcome = runOutcome(run); + if (outcome) { + const reply = clip(redactSecrets(run.reply || ''), REPLY_CHARS); + const mirrored = outcome.outcome === 'settled' || outcome.outcome === 'failed' ? await mirrorChannelReply(run, outcome, reply) : false; + return { kind: 'run', id: run.id, ...outcome, status: run.status, reply, tail: redactedTail(run.logs, tail_lines), finishedAt: run.finishedAt || null, waitedMs: Date.now() - started, ...(run.channel ? { channel: run.channel, replyPostedToThread: mirrored } : {}) }; + } + if (Date.now() - started >= budget) { + return { kind: 'run', id: run.id, outcome: 'timed-out', reason: `Still ${run.status} after ${budget} ms.`, status: run.status, reply: '', tail: redactedTail(run.logs, tail_lines), finishedAt: null, waitedMs: Date.now() - started }; + } + } else { + const goal = await bridge.get(`/api/goals/${encodeURIComponent(goal_id)}`); + const outcome = goalOutcome(goal); + const reply = clip(redactSecrets(goal.summary || goal.latestReply || ''), REPLY_CHARS); + const tail = (Array.isArray(goal.evidence) ? goal.evidence : []).slice(-(Number(tail_lines) || 20)).map((line) => clip(redactSecrets(typeof line === 'string' ? line : JSON.stringify(line)), 500)); + if (outcome) return { kind: 'goal', id: goal.id, ...outcome, status: goal.status, reply, tail, turnCount: goal.turnCount || 0, finishedAt: goal.finishedAt || null, waitedMs: Date.now() - started }; + if (Date.now() - started >= budget) return { kind: 'goal', id: goal.id, outcome: 'timed-out', reason: `Still running after ${budget} ms.`, status: goal.status, reply, tail, turnCount: goal.turnCount || 0, finishedAt: null, waitedMs: Date.now() - started }; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + }, + + async interrupt({ run_id, goal_id, member_id } = {}) { + const given = [run_id, goal_id, member_id].filter(Boolean).length; + if (given !== 1) throw new Error('pass exactly one of run_id, goal_id or member_id'); + if (run_id) { + const run = await bridge.post(`/api/runs/${encodeURIComponent(run_id)}/stop`, {}); + return { kind: 'run', id: run_id, stopped: run?.stopped === true, status: run?.status || '' }; + } + if (goal_id) { + const goal = await bridge.post(`/api/goals/${encodeURIComponent(goal_id)}/pause`, {}); + return { kind: 'goal', id: goal_id, status: goal?.status || '', note: 'The goal is paused, not cancelled; resume it in the app.' }; + } + const result = await bridge.post(`/api/members/${encodeURIComponent(member_id)}/stop`, {}); + return { kind: 'member', id: member_id, stoppedRuns: result?.stoppedRuns || 0, closedSessions: result?.closedSessions || 0, pausedGoals: Array.isArray(result?.pausedGoals) ? result.pausedGoals.length : 0 }; + }, + + async set_model() { + throw notAvailable('set_model', 'a member\'s harness and model are chosen in the app and sent with each turn; the bridge only holds the global provider settings.', 'Change the model in the member\'s settings in the app.'); + }, + + async message_member({ member_id, text, workspace } = {}) { + const roster = await tools.list_members(); + if (!roster.members.some(member => member.id === member_id)) throw Object.assign(new Error(`Member ${member_id} was not found. Use list_members to choose a recipient.`), { status: 404 }); + if (!text.trim()) throw new Error('Message text is required.'); + const folder = workspace || await lastWorkspace(member_id); + if (!folder) throw new Error(`Choose a workspace for ${member_id} in Squad, or pass workspace explicitly.`); + const item = await bridge.post(`/api/queues/${encodeURIComponent(member_id)}/items`, { text, workspace: folder }); + const queue = await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`); + return { member_id, message_id: item.id, status: item.status, workspace: folder, paused: queue.paused === true, pauseReason: queue.pauseReason || '' }; + }, + + async get_conversation({ member_id, limit = 30, since = '' } = {}) { + const query = new URLSearchParams({ limit: String(limit), ...(since ? { since } : {}) }); + return bridge.get(`/api/members/${encodeURIComponent(member_id)}/transcript?${query}`); + }, + + async get_member_queue({ member_id, limit = 50 } = {}) { + const queue = await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`); + return { ...queue, items: (queue.items || []).slice(-limit) }; + }, + + async wait_for_message({ member_id, message_id, timeout_ms = 20000 } = {}, { signal } = {}) { + const deadline = Date.now() + Math.min(timeout_ms, 25000); + let previousQueue; + while (true) { + if (signal?.aborted) throw new Error('Wait cancelled; the message remains queued.'); + const remaining = deadline - Date.now(); + const queue = remaining <= 0 && previousQueue ? previousQueue : await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`, { signal, timeoutMs: Math.max(1, Math.min(HTTP_TIMEOUT_MS, remaining)) }); + previousQueue = queue; + const item = (queue.items || []).find(item => item.id === message_id); + if (!item) throw Object.assign(new Error('Message receipt not found for this member.'), { status: 404 }); + const outcome = item.status === 'done' ? 'settled' : ['failed', 'cancelled'].includes(item.status) ? 'failed' : queue.paused ? 'needs-you' : Date.now() >= deadline ? 'timed-out' : ''; + if (outcome) return { member_id, message_id, outcome, status: item.status, reply: clip(item.reply, REPLY_CHARS), reason: item.error || queue.pauseReason || '', finishedAt: item.finishedAt }; + await new Promise(resolve => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())))); + } + }, + + async list_artifacts({ member_id, query = '', limit = 50 } = {}) { + const data = await bridge.get(`/api/artifacts?${new URLSearchParams({ owner: member_id || '', query })}`); + return { artifacts: (data.artifacts || []).slice(0, limit) }; + }, + + async get_artifact({ artifact_id } = {}) { return bridge.get(`/api/artifacts/${encodeURIComponent(artifact_id)}`); }, + + async list_memories({ member_id, query = '', limit = 50, offset = 0 } = {}) { + return bridge.get(`/api/memory?${new URLSearchParams({ owner: member_id || '', query, limit: String(limit), offset: String(offset) })}`); + }, + + async read_memory({ memory_id } = {}) { return bridge.get(`/api/memory/${encodeURIComponent(memory_id)}`); }, + }; + return { ...tools, ...createManagementTools(bridge, tools) }; +} + +// ---- JSON-RPC ------------------------------------------------------------- + +const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool])); + +export function createRpcHandler(tools, { bridgeUrl } = {}) { + const inflight = new Map(); + async function callTool(params, id) { + const name = String(params?.name || ''); + const tool = TOOL_BY_NAME.get(name); + if (!tool) throw Object.assign(new Error(`unknown tool ${name || '(empty)'}`), { rpc: -32602 }); + const args = params?.arguments ?? {}; + const problems = validateArgs(tool.inputSchema, args); + if (problems.length) throw Object.assign(new Error(`invalid arguments: ${problems.join('; ')}`), { rpc: -32602 }); + if (inflight.size >= 64) throw Object.assign(new Error('Too many active tool requests; wait for an existing request to finish.'), { rpc: -32000 }); + const controller = new AbortController(); + if (id !== undefined && id !== null) inflight.set(id, controller); + try { + const redact = value => typeof value === 'string' ? redactSecrets(value) : Array.isArray(value) ? value.map(redact) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redact(entry)])) : value; + const result = redact(await tools[name](args, { signal: controller.signal })); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: false }; + } catch (error) { + const message = redactSecrets(error?.code === NOT_AVAILABLE_CODE ? error.message : `${name} failed: ${error?.message || error}`); + const payload = { error: message, ...(error?.code ? { code: error.code } : {}), ...(error?.status ? { status: error.status } : {}) }; + return { content: [{ type: 'text', text: JSON.stringify(payload) }], structuredContent: payload, isError: true }; + } finally { + if (id !== undefined && id !== null) inflight.delete(id); + } + } + + return async function handle(request) { + if (!request || typeof request !== 'object' || request.jsonrpc !== '2.0' || typeof request.method !== 'string') { + return { jsonrpc: '2.0', id: request?.id ?? null, error: { code: -32600, message: 'invalid request' } }; + } + const { id, method, params } = request; + const notification = id === undefined; + if (method === 'notifications/cancelled') { + inflight.get(params?.requestId)?.abort(); + return null; + } + if (method.startsWith('notifications/')) return null; + try { + let result; + if (method === 'initialize') { + result = { + protocolVersion: negotiateProtocol(params?.protocolVersion), + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + instructions: `Coordinates the Autohand Squad bridge at ${bridgeUrl}. Use list_members to select an agent, message_member for a queued direct conversation, get_conversation for history and wait_for_message for its reply. Use get_member_profile before revision-checked profile, ability, folder or skill edits. Channels support private groups and explicit recipient dispatch; routines support timezone previews, saved schedules and manual runs. Also reads Library artifacts and memories and independent runs/goals. Approvals, deletion, credentials and computer lifecycle stay in the app.`, + }; + } else if (method === 'ping') { + result = {}; + } else if (method === 'tools/list') { + result = { tools: TOOLS }; + } else if (method === 'tools/call') { + result = await callTool(params, id); + } else { + throw Object.assign(new Error(`method not found: ${method}`), { rpc: -32601 }); + } + return notification ? null : { jsonrpc: '2.0', id, result }; + } catch (error) { + if (notification) return null; + return { jsonrpc: '2.0', id, error: { code: error?.rpc || -32000, message: String(error?.message || error) } }; + } + }; +} + +// ---- stdio loop ----------------------------------------------------------- + +export async function serveStdio({ input = process.stdin, output = process.stdout, handle }) { + let buffer = Buffer.alloc(0); + const pending = new Set(); + const write = (message, framing) => { if (message) output.write(encodeFrame(message, framing)); }; + const receive = async (body, framing) => { + let parsed; + try { parsed = JSON.parse(body.toString()); } + catch { write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }, framing); return; } + if (Array.isArray(parsed)) { + const responses = (await Promise.all(parsed.map((item) => handle(item)))).filter(Boolean); + if (responses.length) write(responses, framing); + return; + } + write(await handle(parsed), framing); + }; + await new Promise((resolve) => { + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const { frames, rest } = decodeFrames(buffer); + buffer = rest; + for (const frame of frames) { + const task = receive(frame.body, frame.framing).catch(error => write({ jsonrpc: '2.0', id: null, error: { code: -32000, message: redactSecrets(String(error?.message || error)) } }, frame.framing)); + pending.add(task); + task.finally(() => pending.delete(task)); + } + }; + const finish = () => { + input.off('data', onData); input.off('end', finish); input.off('error', finish); + Promise.allSettled([...pending]).then(resolve); + }; + input.on('data', onData); + input.once('end', finish); + input.once('error', finish); + }); +} + diff --git a/src/testing/scenarios/squadConnectionScenario.ts b/src/testing/scenarios/squadConnectionScenario.ts new file mode 100644 index 000000000..87c8fb180 --- /dev/null +++ b/src/testing/scenarios/squadConnectionScenario.ts @@ -0,0 +1,22 @@ +import type { Session } from 'tuistory'; + +export async function selectSquadAgentAndMessage(session: Session): Promise { + await session.type('/squad'); + await session.press('enter'); + await session.waitForText('Select a Squad agent', { timeout: 15000 }); + await session.type('Kai'); + await session.press('enter'); + await session.waitForText('Selected Squad agent Kai (kai)', { timeout: 10000 }); + await session.type('/message Review the CLI fixture'); + await session.press('enter'); + await session.waitForText('Receipt: receipt-1', { timeout: 10000 }); + await session.type('/squad history'); + await session.press('enter'); + return session.text({ timeout: 10000, waitFor: text => text.includes('SQUAD_DIRECT_REPLY') }); +} + +export async function invokeBuiltinSquadMcp(session: Session): Promise { + await session.type("Read Kai's saved Squad profile using the built-in MCP."); + await session.press('enter'); + return session.text({ timeout: 20000, waitFor: text => text.includes('BUILTIN_SQUAD_MCP_VERIFIED') }); +} diff --git a/tests/commands/squadConnection.test.ts b/tests/commands/squadConnection.test.ts new file mode 100644 index 000000000..3e792d764 --- /dev/null +++ b/tests/commands/squadConnection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SquadConnection } from '../../src/squad/SquadConnection.js'; +import { message } from '../../src/commands/message.js'; +import { runDesktopSquadCommand } from '../../src/commands/squadConnection.js'; +import type { Team } from '../../src/core/teams/types.js'; + +function fixture() { + const post = vi.fn(async () => ({ id: 'receipt-1', status: 'queued' })); + const get = vi.fn(async (path: string) => { + if (path === '/api/status/snapshot') return { members: [{ id: 'kai', name: 'Kai', role: 'Engineer' }] }; + if (path === '/api/members/presence') return { presence: { kai: { state: 'working' } } }; + if (path === '/api/runs') return [{ agentId: 'kai', workspace: '/member/project', startedAt: '2026-11-01' }]; + if (path.startsWith('/api/goals')) return []; + if (path === '/api/channels') return {}; + if (path.startsWith('/api/queues/')) return { items: [], paused: false }; + if (path.includes('/transcript')) return { messages: [{ role: 'agent', body: 'Ready for review.' }], live: false }; + throw new Error(`Unexpected request ${path}`); + }); + return { connection: new SquadConnection({ bridge: { get, post, put: vi.fn(), patch: vi.fn() } }), post, get }; +} + +describe('desktop Squad commands', () => { + it('selects a running Squad agent and sends /message to its durable direct queue', async () => { + const { connection, post } = fixture(); + const pickMember = vi.fn(async () => 'kai'); + const selected = await runDesktopSquadCommand({ squadConnection: connection, pickMember }, ['select']); + expect(selected).toContain('Kai'); + expect(pickMember.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'kai', presence: 'working' })])); + expect(await message({ squadConnection: connection }, ['Please', 'review', 'this.'])).toContain('receipt-1'); + expect(post).toHaveBeenCalledWith('/api/queues/kai/items', { text: 'Please review this.', workspace: '/member/project' }); + }); + + it('keeps explicit local team messages working while Squad is selected', async () => { + const { connection } = fixture(); + await connection.select('Kai'); + const sendMessageTo = vi.fn(); + const teamManager = { getTeam: (): Team => ({ name: 'fixture', leadSessionId: 'lead', createdAt: '', status: 'active', members: [] }), sendMessageTo }; + const response = await message({ squadConnection: connection, teamManager }, ['team', 'local', 'Review', 'this']); + expect(response).toContain('Message sent to local'); + expect(sendMessageTo).toHaveBeenCalledWith('local', 'lead', 'Review this'); + }); + + it('sends to an explicit desktop recipient without requiring a local team', async () => { + const { connection, post } = fixture(); + expect(await message({ squadConnection: connection }, ['squad', 'kai', 'Hello'])).toContain('receipt-1'); + expect(post).toHaveBeenCalledOnce(); + expect(await runDesktopSquadCommand({ squadConnection: connection }, ['history', 'kai'])).toContain('Ready for review.'); + }); + + it('rejects an unknown recipient without queuing anything', async () => { + const { connection, post } = fixture(); + await expect(connection.select('unknown')).rejects.toThrow(/not found/i); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/mcp/squadBuiltin.test.ts b/tests/mcp/squadBuiltin.test.ts new file mode 100644 index 000000000..2e30e1cb9 --- /dev/null +++ b/tests/mcp/squadBuiltin.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRpcHandler, createTools } from '../../src/squad/vendor/runtime.mjs'; +import { ensureSquadMcpServer } from '../../src/squad/builtin.js'; +import type { LoadedConfig } from '../../src/types.js'; + +describe('built-in Squad MCP', () => { + it('advertises and calls the bundled profile, directory, channel and routine tools', async () => { + const get = vi.fn(async (path: string) => path.startsWith('/api/control-plane/') ? { id: 'kai', revision: 7 } : {}); + const patch = vi.fn(async (_path: string, body?: Record) => body); + const tools = createTools({ get, patch, post: vi.fn(), put: vi.fn() }); + const handle = createRpcHandler(tools); + const listed = await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(listed).toEqual(expect.objectContaining({ result: expect.objectContaining({ tools: expect.arrayContaining(['get_member_profile', 'add_member_directory', 'send_channel_message', 'set_member_skills', 'run_routine'].map(name => expect.objectContaining({ name }))) }) })); + await tools.update_member_profile({ member_id: 'kai', personality: 'Precise' }); + expect(patch).toHaveBeenCalledWith('/api/control-plane/members/kai', { personality: 'Precise', expectedRevision: 7 }); + }); + + it('adds a runtime connection using this installed Node CLI without persisting config', () => { + const config: LoadedConfig = { configPath: '/fixture/config.json' }; + expect(ensureSquadMcpServer(config, { env: {}, execPath: '/runtime/node', entryPath: '/package/dist/index.js' })).toEqual(expect.objectContaining({ name: 'autohand-squad', command: '/runtime/node', args: ['/package/dist/index.js', 'squad', 'mcp'], autoConnect: true })); + expect(config.mcp).toBeUndefined(); + }); + + it('launches a compiled binary directly, without passing its virtual entrypoint', () => { + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json' }, { env: {}, execPath: '/usr/local/bin/autohand', entryPath: '/$bunfs/root/autohand', compiled: true })?.args).toEqual(['squad', 'mcp']); + }); + + it('honors disabled, bare, offline and managed-member boundaries', () => { + for (const options of [{ bare: true }, { offline: true }, { env: { AUTOHAND_DISABLE_SQUAD: '1' } }, { env: { AUTOHAND_SQUAD_TEAM_TOKEN: 'private-member-token' } }]) { + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json' }, options)).toBeUndefined(); + } + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json', mcp: { enabled: false } }, { env: {} })).toBeUndefined(); + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json', mcp: { servers: [{ name: 'autohand_squad', transport: 'stdio', command: 'managed-proxy' }] } }, { env: {} })).toBeUndefined(); + }); +}); diff --git a/tests/slashCommandHandler.spec.ts b/tests/slashCommandHandler.spec.ts index 0fb12fe36..cfa8433eb 100644 --- a/tests/slashCommandHandler.spec.ts +++ b/tests/slashCommandHandler.spec.ts @@ -444,7 +444,7 @@ describe('SlashCommandHandler', () => { expect(result).toBe('Autohand Squad is ready.'); expect(mockSquad).toHaveBeenCalledWith( - { workspaceRoot: '/tmp/workspace', config: ctx.config }, + expect.objectContaining({ workspaceRoot: '/tmp/workspace', config: ctx.config, squadConnection: expect.anything() }), ['--port', '19999'], ); }); diff --git a/tests/tuistory/helpers/autohandTuistory.ts b/tests/tuistory/helpers/autohandTuistory.ts index 0d1d2b20b..111cf1c95 100644 --- a/tests/tuistory/helpers/autohandTuistory.ts +++ b/tests/tuistory/helpers/autohandTuistory.ts @@ -33,7 +33,7 @@ export interface LaunchBuiltAutohandOptions { runtimeCommand?: string; runtimeArgs?: string[]; inputCommand?: string[]; - entrypoint?: string; + entrypoint?: string | null; autohandHome?: string; cwd?: string; env?: Record; @@ -1275,6 +1275,7 @@ export async function launchBuiltAutohand( AUTOHAND_OFFLINE: '1', AUTOHAND_NO_BROWSER: '1', AUTOHAND_DISABLE_COMPUTER_USE: '1', + AUTOHAND_DISABLE_SQUAD: '1', // Hermetic version resolution: an ambient AUTOHAND_VERSION_SOURCE (e.g. // when the suite runs inside an Autohand session) would otherwise make // the built CLI report a git-derived version instead of the manifest one. @@ -1288,7 +1289,7 @@ export async function launchBuiltAutohand( : launchTuistorySession; return await launch({ command: options.runtimeCommand ?? process.execPath, - args: [...options.runtimeArgs ?? [], options.entrypoint ?? path.join(root, 'dist/index.js'), ...args], + args: [...options.runtimeArgs ?? [], ...(options.entrypoint === null ? [] : [options.entrypoint ?? path.join(root, 'dist/index.js')]), ...args], cwd: options.cwd ?? root, env, cols: options.cols ?? 120, diff --git a/tests/tuistory/squad-connection.tuistory.test.ts b/tests/tuistory/squad-connection.tuistory.test.ts new file mode 100644 index 000000000..287434716 --- /dev/null +++ b/tests/tuistory/squad-connection.tuistory.test.ts @@ -0,0 +1,66 @@ +import { createServer } from 'node:http'; +import type { Session } from 'tuistory'; +import { expect, it } from 'vitest'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; +import { invokeBuiltinSquadMcp, selectSquadAgentAndMessage } from '../../src/testing/scenarios/squadConnectionScenario.js'; + +it.each([false, true])('selects a desktop agent, sends and reads messages, and calls the built-in MCP (full screen: %s)', async fullScreenMode => { + const messages: unknown[] = []; + let modelCalls = 0; + const advertisedTools = new Set(); + let squadToolReply = false; + let squadDiscovered = false; + const server = createServer(async (request, response) => { + const url = new URL(request.url || '/', 'http://localhost'); + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) as Record : {}; + let data: unknown; + if (url.pathname === '/chat/completions') { + modelCalls++; + for (const tool of body.tools as Array<{ function?: { name?: string } }> || []) if (tool.function?.name) advertisedTools.add(tool.function.name); + squadToolReply ||= (body.messages as Array<{ role?: string; content?: string }> || []).some(message => message.role === 'tool' && message.content?.includes('Kai')); + squadDiscovered ||= (body.messages as Array<{ role?: string; content?: string }> || []).some(message => message.role === 'tool' && message.content?.includes('mcp__autohand-squad__get_member_profile')); + response.writeHead(200, { 'content-type': 'application/json' }); + const tool = modelCalls === 1 ? { name: 'tool_search', arguments: JSON.stringify({ query: 'mcp__autohand-squad__get_member_profile' }) } : { name: 'run_tool_script', arguments: JSON.stringify({ script: 'return await tools["mcp__autohand-squad__get_member_profile"]({member_id: "kai"});', description: 'List Squad agents' }) }; + response.end(JSON.stringify({ id: 'squad-fixture', created: 1, choices: [{ index: 0, finish_reason: modelCalls < 3 ? 'tool_calls' : 'stop', message: { role: 'assistant', content: modelCalls < 3 ? '' : 'BUILTIN_SQUAD_MCP_VERIFIED', ...(modelCalls < 3 ? { tool_calls: [{ id: `list-squad-${modelCalls}`, type: 'function', function: tool }] } : {}) } }], usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 } })); + return; + } + if (url.pathname === '/api/status/snapshot') data = { members: [{ id: 'kai', name: 'Kai', role: 'Engineer' }] }; + else if (url.pathname === '/api/control-plane/members/kai') data = { id: 'kai', name: 'Kai', personality: 'Precise', revision: 4 }; + else if (url.pathname === '/api/members/presence') data = { presence: { kai: { state: 'working' } } }; + else if (url.pathname === '/api/runs' || url.pathname === '/api/goals') data = []; + else if (url.pathname === '/api/channels') data = { agentChannelAccess: { kai: { agent: { id: 'kai', name: 'Kai', role: 'Engineer', workspace: '/fixture/member-project' } } } }; + else if (url.pathname === '/api/queues/kai/items' && request.method === 'POST') { messages.push(body); data = { id: 'receipt-1', status: 'queued' }; } + else if (url.pathname === '/api/queues/kai') data = { items: [], paused: false }; + else if (url.pathname === '/api/members/kai/transcript') data = { messages: [{ role: 'agent', body: 'SQUAD_DIRECT_REPLY' }], live: false }; + else { response.writeHead(404).end(); return; } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ success: true, data })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture server unavailable'); + const origin = `http://127.0.0.1:${address.port}`; + const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: origin, model: 'openai/gpt-4o-mini' }, mcp: { enabled: true, servers: [] }, agent: { autoMemory: false, maxIterations: 4 }, ui: { promptSuggestions: false }, features: { automaticSpecialists: false, fullScreenMode } } }); + let session: Session | undefined; + try { + const binary = process.env.AUTOHAND_SQUAD_TEST_BINARY; + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--yes'], { ...(binary ? { runtimeCommand: binary, entrypoint: null } : {}), autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 30, env: { AUTOHAND_DISABLE_SQUAD: '0', AUTOHAND_SQUAD_BRIDGE_URL: origin, AUTOHAND_SQUAD_TEAM_TOKEN: undefined, AUTOHAND_SQUAD_TEAM_URL: undefined, AUTOHAND_SQUAD_MEMBER: undefined } }); + await session.waitForText('❯', { timeout: 20000 }); + const frame = await selectSquadAgentAndMessage(session); + expect(frame).toContain('SQUAD_DIRECT_REPLY'); + expect(messages).toEqual([{ text: 'Review the CLI fixture', workspace: '/fixture/member-project' }]); + await invokeBuiltinSquadMcp(session); + expect([...advertisedTools]).toContain('tool_search'); + expect(squadDiscovered).toBe(true); + expect(squadToolReply).toBe(true); + expect(modelCalls).toBe(3); + await exitInteractive(session); + } finally { + session?.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await state.cleanup(); + } +}, 90000); diff --git a/tsup.config.ts b/tsup.config.ts index 813226af5..5a9e42942 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ onSuccess: async () => { mkdirSync('dist/assets', { recursive: true }); cpSync('assets/icon.png', 'dist/assets/icon.png'); + cpSync('src/squad/vendor/LICENSE', 'dist/assets/squad-mcp-LICENSE'); cpSync('assets/review', 'dist/assets/review', { recursive: true }); mkdirSync('dist/agents/builtin', { recursive: true }); cpSync('src/agents/builtin', 'dist/agents/builtin', { recursive: true }); diff --git a/vitest.setup.ts b/vitest.setup.ts index 55242eb15..11be9aa0b 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -9,6 +9,7 @@ * - Keeps the developer's Axo state file out of tests */ import { vi } from 'vitest'; +process.env.AUTOHAND_DISABLE_SQUAD = '1'; // Mock node:sqlite globally to avoid test isolation issues // CursorImporter uses dynamic import which can conflict with per-file mocks diff --git a/vitest.tuistory.config.ts b/vitest.tuistory.config.ts index 0e810e683..aca239736 100644 --- a/vitest.tuistory.config.ts +++ b/vitest.tuistory.config.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { defineConfig } from 'vitest/config'; +process.env.AUTOHAND_DISABLE_SQUAD = '1'; + const workerCount = process.env.AUTOHAND_TUISTORY_WORKERS === '2' ? 2 : 1; const distEntry = path.resolve(import.meta.dirname, 'dist/index.js');