Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@ export const PROTOCOL_VERSION = 1;
* can produce, and either side simply doesn't use what the other didn't
* declare. Unknown capability strings MUST be ignored, never rejected.
*/
/**
* The conductor's fleet MCP server key, and its verb vocabulary split by
* class. Shared because BOTH sides need it and they must not disagree: the
* daemon uses the read set to build the provider's `allowedTools` and the send
* set as the hard "never auto-approve" gate, while a client uses the same split
* to render a fleet call as an observation or as an act.
*
* It lives here rather than in the daemon because a browser bundle cannot
* import daemon code — and a duplicated copy in the web client would let the
* two drift, with a newly-added send-class verb quietly rendering as a
* harmless read until somebody noticed.
*/
export const FLEET_TOOL_PREFIX = "mcp__codeoid_fleet__";

/** Read-class: observe only. Safe to auto-approve. */
export const FLEET_READ_TOOLS = [
"fleet_list",
"fleet_find",
"fleet_summary",
"fleet_recall",
"fleet_tasks",
"machine_map",
] as const;

/**
* Send-class: acts on the fleet. NEVER auto-approved — the owner confirms each
* one with the full tool input visible (conductor-design R3).
*/
export const FLEET_SEND_TOOLS = [
"fleet_send",
"fleet_interrupt",
"fleet_spawn",
"fleet_panel",
] as const;

export type FleetReadTool = (typeof FLEET_READ_TOOLS)[number];
export type FleetSendTool = (typeof FLEET_SEND_TOOLS)[number];

export const CAPABILITIES = {
/** Client renders rich `parts[]` content (vs the plain `content` fallback). */
PARTS: "parts",
Expand Down
28 changes: 8 additions & 20 deletions src/daemon/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import {
type McpSdkServerConfigWithInstance,
} from "@anthropic-ai/claude-agent-sdk";
import type { MemoryEngine } from "./memory/index.js";
import {
FLEET_READ_TOOLS,
FLEET_SEND_TOOLS,
FLEET_TOOL_PREFIX,
} from "../protocol/types.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -122,14 +127,7 @@ export interface FleetDeps {
* READ-class tool names — these (and only these) go into the provider's
* `allowedTools`, so they run silently. (Server key `codeoid_fleet`.)
*/
export const FLEET_TOOL_NAMES = [
"fleet_list",
"fleet_find",
"fleet_summary",
"fleet_recall",
"fleet_tasks",
"machine_map",
] as const;
export const FLEET_TOOL_NAMES = FLEET_READ_TOOLS;

/**
* SEND-class tool names (P4). Deliberately a SEPARATE list that must NEVER
Expand All @@ -138,15 +136,7 @@ export const FLEET_TOOL_NAMES = [
* makes every dispatch ride the existing approvalId flow, with the full tool
* input shown to the owner (design R3).
*/
export const FLEET_SEND_TOOL_NAMES = [
"fleet_send",
"fleet_interrupt",
"fleet_spawn",
// A panel is N sends at once, so it is send-class by definition. Being on
// this list is what makes it ride the R3 approval flow (and, for a
// collaborative session, carry the goal's cost roll-up into that prompt).
"fleet_panel",
] as const;
export const FLEET_SEND_TOOL_NAMES = FLEET_SEND_TOOLS;

/**
* True for the fully-qualified MCP name of a send-class fleet tool. Session
Expand All @@ -155,9 +145,7 @@ export const FLEET_SEND_TOOL_NAMES = [
* invariant, not a mode default.
*/
export function isFleetSendTool(toolName: string): boolean {
return FLEET_SEND_TOOL_NAMES.some(
(t) => toolName === `mcp__codeoid_fleet__${t}`,
);
return FLEET_SEND_TOOL_NAMES.some((t) => toolName === `${FLEET_TOOL_PREFIX}${t}`);
}

/**
Expand Down
77 changes: 77 additions & 0 deletions web/src/components/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
identityLabel,
shortSub,
} from "../../lib/identity";
import { classifyFleetTool, type FleetCard } from "../../lib/fleet-cards";
import { safeImageUri, safeLinkUri } from "../../lib/sanitize-url";
import {
createFrameThrottled,
Expand Down Expand Up @@ -269,7 +270,12 @@ const ToolBlock: Component<{ msg: SessionMessage }> = (props) => {
const candidate = t0.input ?? fromState;
return isWriteInput(candidate) ? candidate : null;
};
// A fleet call is the conductor's whole vocabulary, so it gets a card that
// says what it DOES instead of a raw-JSON `<details>` (conductor-frontends
// §5). Everything else keeps the generic rendering unchanged.
const fleet = () => classifyFleetTool(t());
return (
<Show when={fleet()} fallback={
<div class="space-y-1">
<div class="flex items-center gap-2 font-mono text-xs">
<span class="font-semibold text-role-tool">{t().name}</span>
Expand Down Expand Up @@ -302,6 +308,77 @@ const ToolBlock: Component<{ msg: SessionMessage }> = (props) => {
</div>
</Show>
</div>
}>
{(card) => <FleetActionCard card={card()} state={t().state} toolId={t().toolId} />}
</Show>
);
};

/**
* A conductor fleet call, rendered as what it does.
*
* The left border carries the read/act distinction, because that is the thing
* you scan a conductor transcript for: `dispatch` is accented (it changed
* something, and the owner approved it), `resolve` is warn-toned because a
* wrong resolution is what silently misroutes a later dispatch, and plain
* observes recede. `unknown` deliberately looks like nothing familiar rather
* than borrowing a colour it has not earned — see `classifyFleetTool`.
*/
const FleetActionCard: Component<{
card: FleetCard;
state: ToolState;
toolId: string;
}> = (props) => {
const accent = () => {
switch (props.card.kind) {
case "dispatch":
return "border-l-accent";
case "resolve":
return "border-l-warn/60";
case "unknown":
return "border-l-danger/50";
default:
return "border-l-role-tool/40";
}
};
return (
<div class={`space-y-1 border-l-2 pl-2 ${accent()}`}>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="font-medium text-fg">{props.card.summary}</span>
<Show when={props.card.sendClass}>
<span
class="rounded border border-accent/50 bg-accent/10 px-1 font-mono text-[10px] uppercase tracking-wider text-accent"
title="Send-class: this acts on the fleet and can never be auto-approved"
>
act
</span>
</Show>
<PhaseBadge state={props.state} />
<span class="ml-auto font-mono text-[10px] text-fg-faint">
{props.card.verb} · {shortSub(props.toolId)}
</span>
</div>
<Show when={props.card.fields.length > 0}>
<dl class="space-y-0.5 text-xs">
<Index each={props.card.fields}>
{(f) => (
<div class={f().block ? "flex flex-col gap-0.5" : "flex gap-2"}>
<dt class="shrink-0 font-mono text-[11px] text-fg-muted">{f().label}</dt>
<dd
class={
f().block
? "whitespace-pre-wrap break-words rounded bg-bg px-2 py-1 text-fg"
: "min-w-0 break-all text-fg"
}
>
{f().value}
</dd>
</div>
)}
</Index>
</dl>
</Show>
</div>
);
};

Expand Down
Loading
Loading