forked from EduCraft/curriculum-project-hub
feat(hub): live TodoWrite checklist on Feishu agent cards
Always expose Claude Agent SDK TodoWrite (todoFeatureEnabled) so multi-step runs can plan in the open. Parse TodoWrite payloads into a progress panel on the streaming Feishu card (completed/in_progress/pending) and filter raw TodoWrite noise out of the tool-use list.
This commit is contained in:
@@ -6,6 +6,7 @@ export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
@@ -40,6 +41,8 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>>
|
||||
cph_build: ["Bash"],
|
||||
web_fetch: ["WebFetch"],
|
||||
web_search: ["WebSearch"],
|
||||
todo: ["TodoWrite"],
|
||||
TodoWrite: ["TodoWrite"],
|
||||
Read: ["Read"],
|
||||
Write: ["Write"],
|
||||
Bash: ["Bash"],
|
||||
|
||||
+20
-3
@@ -160,8 +160,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
cwd: security.cwd,
|
||||
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
||||
// list still has to expose the Skill dispatcher itself.
|
||||
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
// TodoWrite is always available so multi-step runs can surface a live checklist
|
||||
// on the Feishu card (Manus-style progress), even when a role whitelists tools.
|
||||
tools: uniqueTools([
|
||||
...toolConfig.tools,
|
||||
"TodoWrite",
|
||||
...(hasSkills ? ["Skill"] : []),
|
||||
]),
|
||||
allowedTools: uniqueTools([...toolConfig.allowedTools, "TodoWrite"]),
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
@@ -178,7 +184,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
// The project workspace is untrusted input. Do not load user/project
|
||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
settings: {
|
||||
disableBundledSkills: true,
|
||||
todoFeatureEnabled: true,
|
||||
},
|
||||
...(hasSkills && security.skillPluginRoot !== undefined
|
||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||
: {}),
|
||||
@@ -405,3 +414,11 @@ function extractToolResultText(content: unknown): string {
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function uniqueTools(tools: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const tool of tools) {
|
||||
if (!out.includes(tool)) out.push(tool);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Parse Claude Agent SDK TodoWrite tool input into a stable checklist model
|
||||
* for Feishu card progress rendering (Manus-style live todos).
|
||||
*/
|
||||
|
||||
export type AgentTodoStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface AgentTodoItem {
|
||||
readonly content: string;
|
||||
readonly status: AgentTodoStatus;
|
||||
/** Present-tense label while the item is active, when the model supplies it. */
|
||||
readonly activeForm: string | undefined;
|
||||
}
|
||||
|
||||
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
||||
|
||||
/** True when the tool name is the SDK TodoWrite dispatcher. */
|
||||
export function isTodoWriteTool(toolName: string): boolean {
|
||||
return toolName === "TodoWrite" || toolName.endsWith("__TodoWrite");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full todo list from a TodoWrite tool_use input.
|
||||
* Returns null when the payload is not a usable TodoWrite body.
|
||||
*/
|
||||
export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
if (!("todos" in input)) return null;
|
||||
const rawTodos = (input as { todos?: unknown }).todos;
|
||||
if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null;
|
||||
|
||||
const todos: AgentTodoItem[] = [];
|
||||
for (const raw of rawTodos) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const content = typeof record.content === "string" ? record.content.trim() : "";
|
||||
if (content === "") continue;
|
||||
const statusRaw = typeof record.status === "string" ? record.status : "pending";
|
||||
const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus)
|
||||
? (statusRaw as AgentTodoStatus)
|
||||
: "pending";
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
todos.push({ content, status, activeForm });
|
||||
}
|
||||
return todos.length === 0 ? null : todos;
|
||||
}
|
||||
|
||||
export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly inProgress: number;
|
||||
} {
|
||||
let completed = 0;
|
||||
let inProgress = 0;
|
||||
for (const todo of todos) {
|
||||
if (todo.status === "completed") completed += 1;
|
||||
else if (todo.status === "in_progress") inProgress += 1;
|
||||
}
|
||||
return { completed, total: todos.length, inProgress };
|
||||
}
|
||||
Reference in New Issue
Block a user