fix(hub): ship checklist via cph_hub todo_write MCP tool

Native Claude TodoWrite is not registered in headless agent mode even with
--tools default. Add mcp__cph_hub__todo_write (always enabled), mirror the
TodoWrite schema, instruct multi-step runs to use it, and keep the Feishu
progress panel parsing both native and hub tool names.
This commit is contained in:
2026-07-23 23:08:42 +08:00
parent ceaf64c4f9
commit e3b463d390
9 changed files with 99 additions and 11 deletions
+11 -2
View File
@@ -19,6 +19,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
"todo_write",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -62,6 +63,9 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"],
todo: ["todo_write"],
TodoWrite: ["todo_write"],
todo_write: ["todo_write"],
"mcp__cph_hub__send_file": ["send_file"],
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
@@ -70,6 +74,7 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
"mcp__cph_hub__todo_write": ["todo_write"],
};
const SUPPORTED_ROLE_TOOLS = new Set([
@@ -109,9 +114,13 @@ export function claudeSdkToolConfigForRole(
export function cphHubMcpToolsForRole(
roleTools: readonly string[] | null | undefined,
): readonly CphHubMcpToolId[] {
if (roleTools === undefined || roleTools === null) return [...CPH_HUB_MCP_TOOL_IDS];
// Always expose hub-side todo_write so progress cards work even when the
// native Claude TodoWrite tool is not registered in headless agent mode.
if (roleTools === undefined || roleTools === null) {
return [...CPH_HUB_MCP_TOOL_IDS];
}
const tools: CphHubMcpToolId[] = [];
const tools: CphHubMcpToolId[] = ["todo_write"];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
+1
View File
@@ -169,6 +169,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const allowedToolsOption = uniqueTools([
...toolConfig.allowedTools,
"TodoWrite",
"mcp__cph_hub__todo_write",
...skillExtras,
]);
+8 -2
View File
@@ -14,9 +14,15 @@ export interface AgentTodoItem {
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
/** True when the tool name is the SDK TodoWrite dispatcher. */
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
export function isTodoWriteTool(toolName: string): boolean {
return toolName === "TodoWrite" || toolName.endsWith("__TodoWrite");
const lower = toolName.toLowerCase();
return (
toolName === "TodoWrite" ||
toolName.endsWith("__TodoWrite") ||
lower === "todo_write" ||
lower.endsWith("__todo_write")
);
}
/**
+14 -3
View File
@@ -43,6 +43,7 @@ const TOOL_ICONS: Record<string, string> = {
grep: "search-filled",
edit: "edit-filled",
todowrite: "todo-filled",
todo_write: "todo-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
@@ -52,10 +53,20 @@ const TOOL_ICONS: Record<string, string> = {
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
function isTodoToolName(toolName: string): boolean {
const lower = toolName.toLowerCase();
return (
lower === "todowrite" ||
lower === "todo_write" ||
lower.endsWith("__todo_write") ||
lower.endsWith("__todowrite")
);
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
@@ -80,8 +91,8 @@ export function buildAgentCard(params: {
elements.push(buildTodoPanel(todos, phase !== "complete"));
}
// Tool-use panel (exclude TodoWrite itself — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => step.toolName.toLowerCase() !== "todowrite");
// Tool-use panel (exclude todo tools — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
if (visibleToolSteps.length > 0) {
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
} else if (
+50
View File
@@ -399,6 +399,50 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
}
if (enabledTools.has("todo_write")) {
tools.push(
tool(
"todo_write",
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
{
todos: z
.array(
z.object({
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("pending | in_progress | completed"),
activeForm: z
.string()
.optional()
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
}),
)
.min(1)
.max(32)
.describe("Full replacement list for the checklist (not a patch)."),
},
async (args) => {
const completed = args.todos.filter((t) => t.status === "completed").length;
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
const lines = args.todos.map((t, i) => {
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
return `${i + 1}. [${mark}] ${label}`;
});
return {
content: [
{
type: "text",
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
},
],
};
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
@@ -507,6 +551,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
);
}
if (enabledTools.has("todo_write")) {
instructions.push(
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
);
}
instructions.push(
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
);