@kolisachint/hoocode-agent 0.4.156 → 0.4.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/dist/core/agent-session-stats.d.ts +17 -1
- package/dist/core/agent-session-stats.d.ts.map +1 -1
- package/dist/core/agent-session-stats.js +20 -0
- package/dist/core/agent-session-stats.js.map +1 -1
- package/dist/core/context-files.d.ts +14 -4
- package/dist/core/context-files.d.ts.map +1 -1
- package/dist/core/context-files.js +7 -4
- package/dist/core/context-files.js.map +1 -1
- package/dist/core/format-tokens.d.ts +9 -0
- package/dist/core/format-tokens.d.ts.map +1 -0
- package/dist/core/format-tokens.js +19 -0
- package/dist/core/format-tokens.js.map +1 -0
- package/dist/core/mcp-status.d.ts +32 -0
- package/dist/core/mcp-status.d.ts.map +1 -0
- package/dist/core/mcp-status.js +27 -0
- package/dist/core/mcp-status.js.map +1 -0
- package/dist/core/resource-loader.d.ts +6 -16
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +2 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -0
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/tools/todo.d.ts +24 -0
- package/dist/core/tools/todo.d.ts.map +1 -1
- package/dist/core/tools/todo.js +50 -9
- package/dist/core/tools/todo.js.map +1 -1
- package/dist/core/wordmark.d.ts +2 -2
- package/dist/core/wordmark.d.ts.map +1 -1
- package/dist/core/wordmark.js +4 -4
- package/dist/core/wordmark.js.map +1 -1
- package/dist/extensions/core/hoo-core.d.ts +3 -0
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +3 -0
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
- package/dist/extensions/core/mcp-loader.js +27 -3
- package/dist/extensions/core/mcp-loader.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +7 -26
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +15 -2
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +20 -12
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +171 -233
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +56 -7
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +174 -20
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/model-controller.d.ts +6 -0
- package/dist/modes/interactive/model-controller.d.ts.map +1 -1
- package/dist/modes/interactive/model-controller.js +33 -14
- package/dist/modes/interactive/model-controller.js.map +1 -1
- package/dist/modes/interactive/resource-display.d.ts +22 -0
- package/dist/modes/interactive/resource-display.d.ts.map +1 -1
- package/dist/modes/interactive/resource-display.js +73 -16
- package/dist/modes/interactive/resource-display.js.map +1 -1
- package/docs/settings.md +2 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"todo.d.ts","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAoCzE,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AAoCD,qFAAqF;AACrF,wBAAgB,6BAA6B,IAAI,cAAc,CAuG9D","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * Current main-agent tasks, in stable creation order. Filters to root tasks the\n * main agent itself owns: no `source` (excludes \"subagent\"/MCP rows), no `agent`\n * (excludes delegated rows), and no `parentTaskId` (excludes merged child trees).\n * `taskOwnerId()` would fold MCP-sourced and delegated rows under \"main\", so\n * reconciling against it could overwrite or drop those rows when the TodoWrite\n * list is shorter than the combined count.\n */\nfunction mainTasks(): Task[] {\n\treturn taskStore\n\t\t.list()\n\t\t.filter((t) => t.source === undefined && t.agent === undefined && t.parentTaskId === undefined);\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"todo.d.ts","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAa,KAAK,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAmCzE,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AA2CD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,WAAW,CAAC,GAAG,MAAM,CAWlG;AAED,qFAAqF;AACrF,wBAAgB,6BAA6B,IAAI,cAAc,CAuG9D","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
package/dist/core/tools/todo.js
CHANGED
|
@@ -58,17 +58,58 @@ function displayTitle(item) {
|
|
|
58
58
|
return item.content.trim();
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
61
|
+
* A root task the main agent itself owns, i.e. a TodoWrite plan item: no
|
|
62
|
+
* `source` (excludes "subagent"/MCP rows), no `agent` (excludes delegated rows),
|
|
63
|
+
* and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would
|
|
64
|
+
* fold MCP-sourced and delegated rows under "main", so reconciling against it
|
|
65
|
+
* could overwrite or drop those rows when the TodoWrite list is shorter than the
|
|
66
|
+
* combined count.
|
|
67
67
|
*/
|
|
68
|
+
function isMainPlanTask(task) {
|
|
69
|
+
return task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Current main-agent plan tasks, in stable creation order. */
|
|
68
72
|
function mainTasks() {
|
|
69
|
-
return taskStore
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
return taskStore.list().filter(isMainPlanTask);
|
|
74
|
+
}
|
|
75
|
+
function isActive(task) {
|
|
76
|
+
return task.status === "pending" || task.status === "in_progress";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Settle plan items the model left pinned at `in_progress` when a request ends.
|
|
80
|
+
*
|
|
81
|
+
* TodoWrite is bookkeeping the model performs by hand, and even strong models
|
|
82
|
+
* routinely drop the final call that flips the last item to completed. Nothing
|
|
83
|
+
* else writes main-plan rows, so without this the panel would keep claiming the
|
|
84
|
+
* work is in flight until the next user message triggers `taskStore.reset()`.
|
|
85
|
+
* The request is over, so the row is wrong either way — settle it to the honest
|
|
86
|
+
* outcome instead of leaving it lying.
|
|
87
|
+
*
|
|
88
|
+
* Scope is deliberately narrow:
|
|
89
|
+
* - Only `in_progress` main-plan items. `pending` rows are left alone: "never
|
|
90
|
+
* started" is already an accurate reading of an item the model skipped.
|
|
91
|
+
* - Only main-plan items. Subagent- and MCP-sourced rows settle through their
|
|
92
|
+
* own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed
|
|
93
|
+
* here.
|
|
94
|
+
* - Nothing settles while any delegated task is still pending/in_progress: a
|
|
95
|
+
* subagent outliving the parent's agent_end is still working the plan, so its
|
|
96
|
+
* plan item is genuinely in progress.
|
|
97
|
+
*
|
|
98
|
+
* Returns the number of tasks settled.
|
|
99
|
+
*/
|
|
100
|
+
export function settleDanglingMainTasks(outcome) {
|
|
101
|
+
const all = taskStore.list();
|
|
102
|
+
if (all.some((t) => !isMainPlanTask(t) && isActive(t)))
|
|
103
|
+
return 0;
|
|
104
|
+
const dangling = all.filter((t) => isMainPlanTask(t) && t.status === "in_progress");
|
|
105
|
+
if (dangling.length === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
taskStore.batch(() => {
|
|
108
|
+
for (const task of dangling) {
|
|
109
|
+
taskStore.update(task.id, { status: outcome });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return dangling.length;
|
|
72
113
|
}
|
|
73
114
|
/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */
|
|
74
115
|
export function createTodoWriteToolDefinition() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"todo.js","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * Current main-agent tasks, in stable creation order. Filters to root tasks the\n * main agent itself owns: no `source` (excludes \"subagent\"/MCP rows), no `agent`\n * (excludes delegated rows), and no `parentTaskId` (excludes merged child trees).\n * `taskOwnerId()` would fold MCP-sourced and delegated rows under \"main\", so\n * reconciling against it could overwrite or drop those rows when the TodoWrite\n * list is shorter than the combined count.\n */\nfunction mainTasks(): Task[] {\n\treturn taskStore\n\t\t.list()\n\t\t.filter((t) => t.source === undefined && t.agent === undefined && t.parentTaskId === undefined);\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"todo.js","sourceRoot":"","sources":["../../../src/core/tools/todo.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * TodoWrite tool: let the main agent maintain a visible todo list for the\n * current task.\n *\n * hoocode already has all the infrastructure this needs — the task store models\n * `{ title, status }` items with a per-turn lifecycle, and the TUI task panel\n * renders them. The only missing piece was a tool the model can call; this is\n * that thin adapter over `taskStore`.\n *\n * Semantics mirror Claude Code's TodoWrite: each call sends the FULL list and\n * REPLACES the previous one. Because the store is incremental (numeric ids), we\n * reconcile the incoming list against the existing main-agent tasks by position:\n * update items that are still there, create new ones, and drop the tail that was\n * removed. Reconciling (rather than clear-and-recreate) keeps ids stable so the\n * panel does not flicker and in-progress rows stay put.\n *\n * It is an optional, opt-in tool (enabled via the `enableTodoWrite` setting) and\n * is never registered inside a spawned subagent, so a subagent's todos cannot\n * leak into the parent's \"main\" task group.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport { TODO_WRITE_TOOL_NAME } from \"../agent-frontmatter.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\nimport { type Task, type TaskStatus, taskStore } from \"../task-store.js\";\n\nconst todoStatusSchema = Type.Union([Type.Literal(\"pending\"), Type.Literal(\"in_progress\"), Type.Literal(\"completed\")], {\n\tdescription: \"pending = not started, in_progress = actively being worked on, completed = finished.\",\n});\n\nconst todoItemSchema = Type.Object(\n\t{\n\t\tcontent: Type.String({\n\t\t\tdescription: \"The task, in imperative form (e.g. 'Add tests for the parser').\",\n\t\t}),\n\t\tstatus: todoStatusSchema,\n\t\tactiveForm: Type.Optional(\n\t\t\tType.String({\n\t\t\t\tdescription:\n\t\t\t\t\t\"Optional present-tense form shown while the item is in_progress (e.g. 'Adding tests for the parser').\",\n\t\t\t}),\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\nconst todoWriteParams = Type.Object(\n\t{\n\t\ttodos: Type.Array(todoItemSchema, {\n\t\t\tdescription:\n\t\t\t\t\"The complete todo list. This REPLACES the previous list on every call, so always send every item with its current status — omitting an item removes it.\",\n\t\t}),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype TodoWriteParams = Static<typeof todoWriteParams>;\ntype IncomingStatus = TodoWriteParams[\"todos\"][number][\"status\"];\n\nexport interface TodoWriteDetails {\n\ttotal: number;\n\tpending: number;\n\tinProgress: number;\n\tcompleted: number;\n}\n\n/** Map the model-facing status vocabulary onto the task store's. */\nfunction toTaskStatus(status: IncomingStatus): TaskStatus {\n\treturn status === \"completed\" ? \"done\" : status;\n}\n\nconst STATUS_GLYPH: Record<TaskStatus, string> = {\n\tpending: \"[ ]\",\n\tin_progress: \"[~]\",\n\tdone: \"[x]\",\n\tfailed: \"[!]\",\n\t// TodoWrite never produces cancelled items; present for Record exhaustiveness.\n\tcancelled: \"[-]\",\n};\n\n/** Title to display: the active-form while in progress, otherwise the content. */\nfunction displayTitle(item: TodoWriteParams[\"todos\"][number]): string {\n\tif (item.status === \"in_progress\" && item.activeForm?.trim()) return item.activeForm.trim();\n\treturn item.content.trim();\n}\n\n/**\n * A root task the main agent itself owns, i.e. a TodoWrite plan item: no\n * `source` (excludes \"subagent\"/MCP rows), no `agent` (excludes delegated rows),\n * and no `parentTaskId` (excludes merged child trees). `taskOwnerId()` would\n * fold MCP-sourced and delegated rows under \"main\", so reconciling against it\n * could overwrite or drop those rows when the TodoWrite list is shorter than the\n * combined count.\n */\nfunction isMainPlanTask(task: Task): boolean {\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/** Current main-agent plan tasks, in stable creation order. */\nfunction mainTasks(): Task[] {\n\treturn taskStore.list().filter(isMainPlanTask);\n}\n\nfunction isActive(task: Task): boolean {\n\treturn task.status === \"pending\" || task.status === \"in_progress\";\n}\n\n/**\n * Settle plan items the model left pinned at `in_progress` when a request ends.\n *\n * TodoWrite is bookkeeping the model performs by hand, and even strong models\n * routinely drop the final call that flips the last item to completed. Nothing\n * else writes main-plan rows, so without this the panel would keep claiming the\n * work is in flight until the next user message triggers `taskStore.reset()`.\n * The request is over, so the row is wrong either way — settle it to the honest\n * outcome instead of leaving it lying.\n *\n * Scope is deliberately narrow:\n * - Only `in_progress` main-plan items. `pending` rows are left alone: \"never\n * started\" is already an accurate reading of an item the model skipped.\n * - Only main-plan items. Subagent- and MCP-sourced rows settle through their\n * own lifecycles (subagent.ts, mcp-loader.ts) and must not be second-guessed\n * here.\n * - Nothing settles while any delegated task is still pending/in_progress: a\n * subagent outliving the parent's agent_end is still working the plan, so its\n * plan item is genuinely in progress.\n *\n * Returns the number of tasks settled.\n */\nexport function settleDanglingMainTasks(outcome: Extract<TaskStatus, \"done\" | \"cancelled\">): number {\n\tconst all = taskStore.list();\n\tif (all.some((t) => !isMainPlanTask(t) && isActive(t))) return 0;\n\tconst dangling = all.filter((t) => isMainPlanTask(t) && t.status === \"in_progress\");\n\tif (dangling.length === 0) return 0;\n\ttaskStore.batch(() => {\n\t\tfor (const task of dangling) {\n\t\t\ttaskStore.update(task.id, { status: outcome });\n\t\t}\n\t});\n\treturn dangling.length;\n}\n\n/** Create the TodoWrite tool definition. Registered as a customTool when enabled. */\nexport function createTodoWriteToolDefinition(): ToolDefinition {\n\treturn defineTool<typeof todoWriteParams, TodoWriteDetails>({\n\t\tname: TODO_WRITE_TOOL_NAME,\n\t\tlabel: TODO_WRITE_TOOL_NAME,\n\t\tdescription: [\n\t\t\t\"Maintain a structured todo list for the current task, shown live in the task panel.\",\n\t\t\t\"Use it PROACTIVELY: at the start of any multi-step or non-trivial task, write the full plan as todos before you begin, then keep it current as you work.\",\n\t\t\t\"Mark exactly ONE item in_progress at a time, and flip an item to completed immediately after finishing it — do not batch completions or leave finished work marked in_progress.\",\n\t\t\t\"Each call sends the FULL list and REPLACES the previous one — always include every item with its current status; omitting an item removes it.\",\n\t\t\t\"Skip it only for trivial, single-step tasks where a list adds no value. When in doubt on multi-step work, use it — it keeps you from losing track of steps.\",\n\t\t].join(\"\\n\"),\n\t\tpromptSnippet:\n\t\t\t\"Plan and track multi-step work as a live todo list (use proactively; replaces the whole list each call)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use TodoWrite proactively for any multi-step or non-trivial task: write the plan as todos up front, keep exactly one item in_progress, and mark items completed immediately as you finish them.\",\n\t\t\t\"TodoWrite replaces the entire list each call — always send all items with their current status.\",\n\t\t\t\"Skip TodoWrite for trivial single-step tasks where a checklist adds no value.\",\n\t\t],\n\t\tparameters: todoWriteParams,\n\t\tasync execute(_toolCallId, params: TodoWriteParams) {\n\t\t\tconst todos = params.todos ?? [];\n\t\t\tconst existing = mainTasks();\n\n\t\t\t// Reconcile by item identity first, position second, batched so the panel\n\t\t\t// renders once. Each task stores its item's canonical `content`\n\t\t\t// (todoContent) — the display title flips between content and activeForm\n\t\t\t// with status, so it can't identify an item. Matching by content keeps a\n\t\t\t// task's id pinned to the same plan item when the list is reordered or\n\t\t\t// shrunk; a purely positional reconcile re-labeled the surviving slots,\n\t\t\t// which silently re-pointed the subagent runs linked to those ids\n\t\t\t// (linkedTaskId) at the wrong plan items. Unmatched incoming items then\n\t\t\t// consume the leftover slots in order (a rename keeps its id and its\n\t\t\t// linked runs); any remaining leftovers were removed from the plan.\n\t\t\ttaskStore.batch(() => {\n\t\t\t\tconst content = (item: TodoWriteParams[\"todos\"][number]) => item.content.trim();\n\t\t\t\tconst matchedExisting = new Set<number>();\n\t\t\t\tconst assigned = new Array<Task | undefined>(todos.length);\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst idx = existing.findIndex(\n\t\t\t\t\t\t(t, j) => !matchedExisting.has(j) && (t.todoContent ?? t.title) === content(todos[i]!),\n\t\t\t\t\t);\n\t\t\t\t\tif (idx !== -1) {\n\t\t\t\t\t\tmatchedExisting.add(idx);\n\t\t\t\t\t\tassigned[i] = existing[idx];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst leftovers = existing.filter((_, j) => !matchedExisting.has(j));\n\t\t\t\tlet nextLeftover = 0;\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tif (!assigned[i]) assigned[i] = leftovers[nextLeftover++];\n\t\t\t\t}\n\n\t\t\t\tconst finalIds: number[] = [];\n\t\t\t\tfor (let i = 0; i < todos.length; i++) {\n\t\t\t\t\tconst item = todos[i]!;\n\t\t\t\t\tconst status = toTaskStatus(item.status);\n\t\t\t\t\tconst title = displayTitle(item);\n\t\t\t\t\tconst current = assigned[i];\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\ttaskStore.update(current.id, { title, status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(current.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst created = taskStore.create(title);\n\t\t\t\t\t\ttaskStore.update(created.id, { status, todoContent: content(item) });\n\t\t\t\t\t\tfinalIds.push(created.id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (let j = nextLeftover; j < leftovers.length; j++) {\n\t\t\t\t\ttaskStore.remove(leftovers[j]!.id);\n\t\t\t\t}\n\t\t\t\t// Identity matching keeps ids, but the panel must still show the plan\n\t\t\t\t// in the list's order — permute the plan tasks into it.\n\t\t\t\ttaskStore.arrange(finalIds);\n\t\t\t});\n\n\t\t\tconst counts = todos.reduce(\n\t\t\t\t(acc, t) => {\n\t\t\t\t\tif (t.status === \"in_progress\") acc.inProgress++;\n\t\t\t\t\telse if (t.status === \"completed\") acc.completed++;\n\t\t\t\t\telse acc.pending++;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{ pending: 0, inProgress: 0, completed: 0 },\n\t\t\t);\n\n\t\t\tconst lines = todos.map((t) => `${STATUS_GLYPH[toTaskStatus(t.status)]} ${displayTitle(t)}`);\n\t\t\tconst header =\n\t\t\t\ttodos.length === 0\n\t\t\t\t\t? \"Todo list cleared.\"\n\t\t\t\t\t: `Todos updated (${counts.inProgress} in progress, ${counts.pending} pending, ${counts.completed} completed):`;\n\t\t\tconst text = todos.length === 0 ? header : `${header}\\n${lines.join(\"\\n\")}`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text }],\n\t\t\t\tdetails: {\n\t\t\t\t\ttotal: todos.length,\n\t\t\t\t\tpending: counts.pending,\n\t\t\t\t\tinProgress: counts.inProgress,\n\t\t\t\t\tcompleted: counts.completed,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t});\n}\n"]}
|
package/dist/core/wordmark.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const WORDMARK: string;
|
|
7
7
|
/** Compact one-line logo for tight spaces. */
|
|
8
|
-
export declare const WORDMARK_COMPACT = "hoo \u2014
|
|
8
|
+
export declare const WORDMARK_COMPACT = "hoo \u2014 coding agent";
|
|
9
9
|
export declare const WORDMARK_SYMBOL: string;
|
|
10
10
|
/** Compact three-line owl glyph rendered beside the brand text in the banner. */
|
|
11
11
|
export declare const WORDMARK_GLYPH: string[];
|
|
@@ -31,7 +31,7 @@ export interface CompactWordmarkOptions {
|
|
|
31
31
|
* tagline + version, and the working directory.
|
|
32
32
|
*
|
|
33
33
|
* ▟▀▀▀▀▀▙ hoocode
|
|
34
|
-
* ▌▟▙ ▟▙▐
|
|
34
|
+
* ▌▟▙ ▟▙▐ coding agent · v0.1.0
|
|
35
35
|
* ▜▄▄▄▄▄▛ ~/project
|
|
36
36
|
*/
|
|
37
37
|
export declare function buildCompactWordmark(options: CompactWordmarkOptions): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wordmark.d.ts","sourceRoot":"","sources":["../../src/core/wordmark.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,eAAO,MAAM,QAAQ,QAQT,CAAC;AAEb,8CAA8C;AAC9C,eAAO,MAAM,gBAAgB,
|
|
1
|
+
{"version":3,"file":"wordmark.d.ts","sourceRoot":"","sources":["../../src/core/wordmark.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,eAAO,MAAM,QAAQ,QAQT,CAAC;AAEb,8CAA8C;AAC9C,eAAO,MAAM,gBAAgB,4BAAuB,CAAC;AASrD,eAAO,MAAM,eAAe,QAEhB,CAAC;AAEb,iFAAiF;AACjF,eAAO,MAAM,cAAc,UAAoC,CAAC;AAKhE,MAAM,WAAW,sBAAsB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACjC,uDAAuD;IACvD,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,iDAAiD;IACjD,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAClC,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAkB5E","sourcesContent":["/**\n * ASCII wordmark for the startup banner.\n * Rendered in the terminal with accent color on the \"hoo\" portion.\n * Source: design-system/assets/wordmark.txt\n */\n\nimport { WORDMARK_SYMBOL_BLOCKS } from \"./wordmark-symbol.generated.js\";\n\nexport const WORDMARK = [\n\t\" __ __ ______ __\",\n\t\" / / / /___ ____ / ____/___ ____/ /__\",\n\t\" / /_/ / __ \\\\/ __ \\\\/ / / __ \\\\/ __ / _ \\\\\",\n\t\" / __ / /_/ / /_/ / /___/ /_/ / /_/ / __/\",\n\t\" /_/ /_/\\\\____/\\\\____/\\\\____/\\\\____/\\\\__,_/\\\\___/\",\n\t\"\",\n\t\" coding agent > hoo\",\n].join(\"\\n\");\n\n/** Compact one-line logo for tight spaces. */\nexport const WORDMARK_COMPACT = \"hoo — coding agent\";\n\n/**\n * Colored half-block owl symbol, indented to sit centered above the ASCII\n * wordmark. Carries baked-in brand colors (Signal Cyan + white), so it only\n * renders on truecolor terminals; callers fall back to {@link WORDMARK}\n * otherwise.\n */\nconst SYMBOL_INDENT = \" \".repeat(15);\nexport const WORDMARK_SYMBOL = WORDMARK_SYMBOL_BLOCKS.split(\"\\n\")\n\t.map((line) => SYMBOL_INDENT + line)\n\t.join(\"\\n\");\n\n/** Compact three-line owl glyph rendered beside the brand text in the banner. */\nexport const WORDMARK_GLYPH = [\"▟▀▀▀▀▀▙\", \"▌▟▙ ▟▙▐\", \"▜▄▄▄▄▄▛\"];\n\nconst GLYPH_INDENT = \" \".repeat(3);\nconst GLYPH_GAP = \" \".repeat(2);\n\nexport interface CompactWordmarkOptions {\n\tappName: string;\n\tversion: string;\n\tcwd: string;\n\t/** Tagline shown next to the version. */\n\ttagline?: string;\n\t/** Colorize the brand \"hoo\" portion / glyph. */\n\taccent: (text: string) => string;\n\t/** Colorize secondary text (tagline, version, cwd). */\n\tdim: (text: string) => string;\n\t/** Colorize separators and the glyph outline. */\n\tmuted: (text: string) => string;\n\t/** Render the trailing blinking cursor (e.g. blink + accent). Optional. */\n\tcursor?: (text: string) => string;\n\t/** Optional note appended to the cwd line (e.g. a keybinding hint). */\n\tnote?: () => string;\n}\n\n/**\n * Build the compact startup banner: a small owl glyph beside the brand name,\n * tagline + version, and the working directory.\n *\n * ▟▀▀▀▀▀▙ hoocode\n * ▌▟▙ ▟▙▐ coding agent · v0.1.0\n * ▜▄▄▄▄▄▛ ~/project\n */\nexport function buildCompactWordmark(options: CompactWordmarkOptions): string {\n\tconst { appName, version, cwd, accent, dim, muted } = options;\n\tconst tagline = options.tagline ?? \"coding agent\";\n\n\t// Highlight the \"hoo\" prefix when present, otherwise accent the whole name.\n\tconst name = appName.startsWith(\"hoo\") ? accent(\"hoo\") + muted(\"│\") + appName.slice(3) : accent(appName);\n\tconst brand = options.cursor ? name + options.cursor(\"_\") : name;\n\n\tconst right = [\n\t\tbrand,\n\t\t`${dim(tagline)} ${muted(\"·\")} ${dim(`v${version}`)}`,\n\t\toptions.note ? `${dim(cwd)}${options.note()}` : dim(cwd),\n\t];\n\n\treturn WORDMARK_GLYPH.map((glyphLine, index) => {\n\t\tconst text = right[index] ?? \"\";\n\t\treturn `${GLYPH_INDENT}${accent(glyphLine)}${GLYPH_GAP}${text}`;\n\t}).join(\"\\n\");\n}\n"]}
|
package/dist/core/wordmark.js
CHANGED
|
@@ -11,10 +11,10 @@ export const WORDMARK = [
|
|
|
11
11
|
" / __ / /_/ / /_/ / /___/ /_/ / /_/ / __/",
|
|
12
12
|
" /_/ /_/\\____/\\____/\\____/\\____/\\__,_/\\___/",
|
|
13
13
|
"",
|
|
14
|
-
"
|
|
14
|
+
" coding agent > hoo",
|
|
15
15
|
].join("\n");
|
|
16
16
|
/** Compact one-line logo for tight spaces. */
|
|
17
|
-
export const WORDMARK_COMPACT = "hoo —
|
|
17
|
+
export const WORDMARK_COMPACT = "hoo — coding agent";
|
|
18
18
|
/**
|
|
19
19
|
* Colored half-block owl symbol, indented to sit centered above the ASCII
|
|
20
20
|
* wordmark. Carries baked-in brand colors (Signal Cyan + white), so it only
|
|
@@ -34,12 +34,12 @@ const GLYPH_GAP = " ".repeat(2);
|
|
|
34
34
|
* tagline + version, and the working directory.
|
|
35
35
|
*
|
|
36
36
|
* ▟▀▀▀▀▀▙ hoocode
|
|
37
|
-
* ▌▟▙ ▟▙▐
|
|
37
|
+
* ▌▟▙ ▟▙▐ coding agent · v0.1.0
|
|
38
38
|
* ▜▄▄▄▄▄▛ ~/project
|
|
39
39
|
*/
|
|
40
40
|
export function buildCompactWordmark(options) {
|
|
41
41
|
const { appName, version, cwd, accent, dim, muted } = options;
|
|
42
|
-
const tagline = options.tagline ?? "
|
|
42
|
+
const tagline = options.tagline ?? "coding agent";
|
|
43
43
|
// Highlight the "hoo" prefix when present, otherwise accent the whole name.
|
|
44
44
|
const name = appName.startsWith("hoo") ? accent("hoo") + muted("│") + appName.slice(3) : accent(appName);
|
|
45
45
|
const brand = options.cursor ? name + options.cursor("_") : name;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wordmark.js","sourceRoot":"","sources":["../../src/core/wordmark.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAExE,MAAM,CAAC,MAAM,QAAQ,GAAG;IACvB,yDAAyD;IACzD,2DAA2D;IAC3D,gEAAgE;IAChE,4DAA4D;IAC5D,iEAAiE;IACjE,EAAE;IACF,
|
|
1
|
+
{"version":3,"file":"wordmark.js","sourceRoot":"","sources":["../../src/core/wordmark.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAExE,MAAM,CAAC,MAAM,QAAQ,GAAG;IACvB,yDAAyD;IACzD,2DAA2D;IAC3D,gEAAgE;IAChE,4DAA4D;IAC5D,iEAAiE;IACjE,EAAE;IACF,sCAAsC;CACtC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,8CAA8C;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,sBAAoB,CAAC;AAErD;;;;;GAKG;AACH,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,sBAAsB,CAAC,KAAK,CAAC,IAAI,CAAC;KAC/D,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,GAAG,IAAI,CAAC;KACnC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,iFAAiF;AACjF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,uBAAS,EAAE,qBAAS,EAAE,uBAAS,CAAC,CAAC;AAEhE,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACnC,MAAM,SAAS,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAoBhC;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAA+B,EAAU;IAC7E,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC;IAC9D,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,cAAc,CAAC;IAElD,4EAA4E;IAC5E,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAG,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACzG,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAEjE,MAAM,KAAK,GAAG;QACb,KAAK;QACL,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,IAAG,CAAC,IAAI,GAAG,CAAC,IAAI,OAAO,EAAE,CAAC,EAAE;QACrD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;KACxD,CAAC;IAEF,OAAO,cAAc,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC;QAC/C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAChC,OAAO,GAAG,YAAY,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,SAAS,GAAG,IAAI,EAAE,CAAC;IAAA,CAChE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAAA,CACd","sourcesContent":["/**\n * ASCII wordmark for the startup banner.\n * Rendered in the terminal with accent color on the \"hoo\" portion.\n * Source: design-system/assets/wordmark.txt\n */\n\nimport { WORDMARK_SYMBOL_BLOCKS } from \"./wordmark-symbol.generated.js\";\n\nexport const WORDMARK = [\n\t\" __ __ ______ __\",\n\t\" / / / /___ ____ / ____/___ ____/ /__\",\n\t\" / /_/ / __ \\\\/ __ \\\\/ / / __ \\\\/ __ / _ \\\\\",\n\t\" / __ / /_/ / /_/ / /___/ /_/ / /_/ / __/\",\n\t\" /_/ /_/\\\\____/\\\\____/\\\\____/\\\\____/\\\\__,_/\\\\___/\",\n\t\"\",\n\t\" coding agent > hoo\",\n].join(\"\\n\");\n\n/** Compact one-line logo for tight spaces. */\nexport const WORDMARK_COMPACT = \"hoo — coding agent\";\n\n/**\n * Colored half-block owl symbol, indented to sit centered above the ASCII\n * wordmark. Carries baked-in brand colors (Signal Cyan + white), so it only\n * renders on truecolor terminals; callers fall back to {@link WORDMARK}\n * otherwise.\n */\nconst SYMBOL_INDENT = \" \".repeat(15);\nexport const WORDMARK_SYMBOL = WORDMARK_SYMBOL_BLOCKS.split(\"\\n\")\n\t.map((line) => SYMBOL_INDENT + line)\n\t.join(\"\\n\");\n\n/** Compact three-line owl glyph rendered beside the brand text in the banner. */\nexport const WORDMARK_GLYPH = [\"▟▀▀▀▀▀▙\", \"▌▟▙ ▟▙▐\", \"▜▄▄▄▄▄▛\"];\n\nconst GLYPH_INDENT = \" \".repeat(3);\nconst GLYPH_GAP = \" \".repeat(2);\n\nexport interface CompactWordmarkOptions {\n\tappName: string;\n\tversion: string;\n\tcwd: string;\n\t/** Tagline shown next to the version. */\n\ttagline?: string;\n\t/** Colorize the brand \"hoo\" portion / glyph. */\n\taccent: (text: string) => string;\n\t/** Colorize secondary text (tagline, version, cwd). */\n\tdim: (text: string) => string;\n\t/** Colorize separators and the glyph outline. */\n\tmuted: (text: string) => string;\n\t/** Render the trailing blinking cursor (e.g. blink + accent). Optional. */\n\tcursor?: (text: string) => string;\n\t/** Optional note appended to the cwd line (e.g. a keybinding hint). */\n\tnote?: () => string;\n}\n\n/**\n * Build the compact startup banner: a small owl glyph beside the brand name,\n * tagline + version, and the working directory.\n *\n * ▟▀▀▀▀▀▙ hoocode\n * ▌▟▙ ▟▙▐ coding agent · v0.1.0\n * ▜▄▄▄▄▄▛ ~/project\n */\nexport function buildCompactWordmark(options: CompactWordmarkOptions): string {\n\tconst { appName, version, cwd, accent, dim, muted } = options;\n\tconst tagline = options.tagline ?? \"coding agent\";\n\n\t// Highlight the \"hoo\" prefix when present, otherwise accent the whole name.\n\tconst name = appName.startsWith(\"hoo\") ? accent(\"hoo\") + muted(\"│\") + appName.slice(3) : accent(appName);\n\tconst brand = options.cursor ? name + options.cursor(\"_\") : name;\n\n\tconst right = [\n\t\tbrand,\n\t\t`${dim(tagline)} ${muted(\"·\")} ${dim(`v${version}`)}`,\n\t\toptions.note ? `${dim(cwd)}${options.note()}` : dim(cwd),\n\t];\n\n\treturn WORDMARK_GLYPH.map((glyphLine, index) => {\n\t\tconst text = right[index] ?? \"\";\n\t\treturn `${GLYPH_INDENT}${accent(glyphLine)}${GLYPH_GAP}${text}`;\n\t}).join(\"\\n\");\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hoo-core.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAYnE,iBAAS,OAAO,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAWvC
|
|
1
|
+
{"version":3,"file":"hoo-core.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAYnE,iBAAS,OAAO,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAWvC;;;;;;;AAMD,eAAe,OAAO,CAAC","sourcesContent":["/**\n * hoo-core — HooCode built-in core extension (composition root).\n *\n * Each concern lives in its own module in this directory:\n * - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy\n * - mcp-loader.ts — discovers MCP server configs, connects, registers tools\n * - modes.ts — active mode resolution, mode prompts, /mode /plan /approve\n * - cost.ts — /cost session token + cost totals\n * - scaffold.ts — /new-skill /new-agent /new-command\n * - ask-options.ts — the ask_options tool (inline decision pane)\n * - thinking-escalation.ts — raise thinking after tool errors, then restore\n * - loop.ts — /loop cron scheduling + autonomous continuation\n * - marketplace.ts — /plugin marketplace + install/remove\n * - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)\n * - config.ts — hoo-config.json types, I/O, and merge rules\n *\n * `bin/hoocode.js` loads this module's default export as the built-in\n * extension factory.\n */\n\nimport type { ExtensionAPI } from \"../../core/extensions/types.js\";\nimport { setupAskOptions } from \"./ask-options.js\";\nimport { setupCost } from \"./cost.js\";\nimport { setupLoop } from \"./loop.js\";\nimport { setupMarketplace } from \"./marketplace.js\";\nimport { setupMcpLoader } from \"./mcp-loader.js\";\nimport { setupMode } from \"./modes.js\";\nimport { setupPermissionGate } from \"./permission-gate.js\";\nimport { setupPromptReactiveNudges } from \"./prompt-reactive/nudges.js\";\nimport { setupScaffold } from \"./scaffold.js\";\nimport { setupThinkingEscalation } from \"./thinking-escalation.js\";\n\nfunction hooCore(pi: ExtensionAPI): void {\n\tsetupPermissionGate(pi);\n\tsetupMcpLoader(pi);\n\tsetupMode(pi);\n\tsetupCost(pi);\n\tsetupScaffold(pi);\n\tsetupAskOptions(pi);\n\tsetupThinkingEscalation(pi);\n\tsetupLoop(pi);\n\tsetupMarketplace(pi);\n\tsetupPromptReactiveNudges(pi);\n}\n\nhooCore.displayName = \"hoo-core\";\n// Built-in plumbing, not a user-installed extension: it is always present, so\n// listing it in the startup resource summary is noise, not information.\nhooCore.internal = true;\nexport default hooCore;\n"]}
|
|
@@ -40,5 +40,8 @@ function hooCore(pi) {
|
|
|
40
40
|
setupPromptReactiveNudges(pi);
|
|
41
41
|
}
|
|
42
42
|
hooCore.displayName = "hoo-core";
|
|
43
|
+
// Built-in plumbing, not a user-installed extension: it is always present, so
|
|
44
|
+
// listing it in the startup resource summary is noise, not information.
|
|
45
|
+
hooCore.internal = true;
|
|
43
46
|
export default hooCore;
|
|
44
47
|
//# sourceMappingURL=hoo-core.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hoo-core.js","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,SAAS,OAAO,CAAC,EAAgB,EAAQ;IACxC,mBAAmB,CAAC,EAAE,CAAC,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,CAAC;IACnB,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,aAAa,CAAC,EAAE,CAAC,CAAC;IAClB,eAAe,CAAC,EAAE,CAAC,CAAC;IACpB,uBAAuB,CAAC,EAAE,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACrB,yBAAyB,CAAC,EAAE,CAAC,CAAC;AAAA,CAC9B;AAED,OAAO,CAAC,WAAW,GAAG,UAAU,CAAC;AACjC,eAAe,OAAO,CAAC","sourcesContent":["/**\n * hoo-core — HooCode built-in core extension (composition root).\n *\n * Each concern lives in its own module in this directory:\n * - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy\n * - mcp-loader.ts — discovers MCP server configs, connects, registers tools\n * - modes.ts — active mode resolution, mode prompts, /mode /plan /approve\n * - cost.ts — /cost session token + cost totals\n * - scaffold.ts — /new-skill /new-agent /new-command\n * - ask-options.ts — the ask_options tool (inline decision pane)\n * - thinking-escalation.ts — raise thinking after tool errors, then restore\n * - loop.ts — /loop cron scheduling + autonomous continuation\n * - marketplace.ts — /plugin marketplace + install/remove\n * - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)\n * - config.ts — hoo-config.json types, I/O, and merge rules\n *\n * `bin/hoocode.js` loads this module's default export as the built-in\n * extension factory.\n */\n\nimport type { ExtensionAPI } from \"../../core/extensions/types.js\";\nimport { setupAskOptions } from \"./ask-options.js\";\nimport { setupCost } from \"./cost.js\";\nimport { setupLoop } from \"./loop.js\";\nimport { setupMarketplace } from \"./marketplace.js\";\nimport { setupMcpLoader } from \"./mcp-loader.js\";\nimport { setupMode } from \"./modes.js\";\nimport { setupPermissionGate } from \"./permission-gate.js\";\nimport { setupPromptReactiveNudges } from \"./prompt-reactive/nudges.js\";\nimport { setupScaffold } from \"./scaffold.js\";\nimport { setupThinkingEscalation } from \"./thinking-escalation.js\";\n\nfunction hooCore(pi: ExtensionAPI): void {\n\tsetupPermissionGate(pi);\n\tsetupMcpLoader(pi);\n\tsetupMode(pi);\n\tsetupCost(pi);\n\tsetupScaffold(pi);\n\tsetupAskOptions(pi);\n\tsetupThinkingEscalation(pi);\n\tsetupLoop(pi);\n\tsetupMarketplace(pi);\n\tsetupPromptReactiveNudges(pi);\n}\n\nhooCore.displayName = \"hoo-core\";\nexport default hooCore;\n"]}
|
|
1
|
+
{"version":3,"file":"hoo-core.js","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,SAAS,OAAO,CAAC,EAAgB,EAAQ;IACxC,mBAAmB,CAAC,EAAE,CAAC,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,CAAC;IACnB,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,aAAa,CAAC,EAAE,CAAC,CAAC;IAClB,eAAe,CAAC,EAAE,CAAC,CAAC;IACpB,uBAAuB,CAAC,EAAE,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACrB,yBAAyB,CAAC,EAAE,CAAC,CAAC;AAAA,CAC9B;AAED,OAAO,CAAC,WAAW,GAAG,UAAU,CAAC;AACjC,8EAA8E;AAC9E,wEAAwE;AACxE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;AACxB,eAAe,OAAO,CAAC","sourcesContent":["/**\n * hoo-core — HooCode built-in core extension (composition root).\n *\n * Each concern lives in its own module in this directory:\n * - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy\n * - mcp-loader.ts — discovers MCP server configs, connects, registers tools\n * - modes.ts — active mode resolution, mode prompts, /mode /plan /approve\n * - cost.ts — /cost session token + cost totals\n * - scaffold.ts — /new-skill /new-agent /new-command\n * - ask-options.ts — the ask_options tool (inline decision pane)\n * - thinking-escalation.ts — raise thinking after tool errors, then restore\n * - loop.ts — /loop cron scheduling + autonomous continuation\n * - marketplace.ts — /plugin marketplace + install/remove\n * - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)\n * - config.ts — hoo-config.json types, I/O, and merge rules\n *\n * `bin/hoocode.js` loads this module's default export as the built-in\n * extension factory.\n */\n\nimport type { ExtensionAPI } from \"../../core/extensions/types.js\";\nimport { setupAskOptions } from \"./ask-options.js\";\nimport { setupCost } from \"./cost.js\";\nimport { setupLoop } from \"./loop.js\";\nimport { setupMarketplace } from \"./marketplace.js\";\nimport { setupMcpLoader } from \"./mcp-loader.js\";\nimport { setupMode } from \"./modes.js\";\nimport { setupPermissionGate } from \"./permission-gate.js\";\nimport { setupPromptReactiveNudges } from \"./prompt-reactive/nudges.js\";\nimport { setupScaffold } from \"./scaffold.js\";\nimport { setupThinkingEscalation } from \"./thinking-escalation.js\";\n\nfunction hooCore(pi: ExtensionAPI): void {\n\tsetupPermissionGate(pi);\n\tsetupMcpLoader(pi);\n\tsetupMode(pi);\n\tsetupCost(pi);\n\tsetupScaffold(pi);\n\tsetupAskOptions(pi);\n\tsetupThinkingEscalation(pi);\n\tsetupLoop(pi);\n\tsetupMarketplace(pi);\n\tsetupPromptReactiveNudges(pi);\n}\n\nhooCore.displayName = \"hoo-core\";\n// Built-in plumbing, not a user-installed extension: it is always present, so\n// listing it in the startup resource summary is noise, not information.\nhooCore.internal = true;\nexport default hooCore;\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-loader.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/mcp-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAcH,OAAO,KAAK,EAAE,YAAY,EAAuD,MAAM,gCAAgC,CAAC;AAiBxH,MAAM,WAAW,eAAe;IAC/B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,6FAA6F;IAC7F,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;IAChC,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AAgXD,wBAAgB,cAAc,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAiNrD","sourcesContent":["/**\n * MCP server loader — discovers server configs (standard mcp.json locations,\n * hoocode's per-server JSON files, plugin registrations), connects via JSON-RPC\n * 2.0 over stdio (`command`), Streamable HTTP (`{ \"type\": \"http\", \"url\": ... }`),\n * or legacy SSE (`\"type\": \"sse\"`), and registers each server tool as\n * `mcp_<server>_<tool>`.\n *\n * Config sources (first-wins by server name):\n * 1. ~/.agents/mcp.json (user), ./.agents/mcp.json (project),\n * ~/.config/claude/mcp.json (Claude Desktop)\n * 2. ~/.hoocode/mcp-servers/*.json and ./.hoocode/mcp-servers/*.json\n * 3. MCP servers registered by plugins/extensions during load\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport type { AgentToolResult, AgentToolUpdateCallback, McpRemoteOptions } from \"@kolisachint/hoocode-agent-core\";\nimport { connectHttpMcpServer, summarizeArgs } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { getExtensionMcpServers } from \"../../core/extension-mcp-servers.js\";\nimport type { ExtensionAPI, ExtensionContext, SessionStartEvent, ToolDefinition } from \"../../core/extensions/types.js\";\nimport { deferMcpSchemas, subagentSkipMcp } from \"../../core/subagent-depth.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport { type DeferredMcpToolEntry, formatDeferredCatalog, selectResolvable } from \"./mcp-deferred.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\ninterface McpToolDef {\n\tname: string;\n\tdescription: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\nexport interface McpServerConfig {\n\t/** Unique server identifier used as prefix for registered tool names */\n\tname: string;\n\t/** Executable to spawn (stdio transport). One of command/url is required. */\n\tcommand?: string;\n\t/** Optional arguments passed to the command */\n\targs?: string[];\n\t/** Optional extra environment variables for the server process */\n\tenv?: Record<string, string>;\n\t/** Transport: \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy) */\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\t/** Remote server URL (http/sse transports) */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports */\n\theaders?: Record<string, string>;\n\t/** Run MCP tools in background by default (default: true for MCP servers) */\n\tbackground?: boolean;\n}\n\n/** Standard MCP config format used by Claude Desktop, Claude Code, VS Code / Copilot */\ninterface StandardMcpServerConfig {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\turl?: string;\n\theaders?: Record<string, string>;\n\tbackground?: boolean;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerConfig>;\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nconst mcpConnections = new Map<string, McpConnection>();\n/**\n * Server configs retained by name so a tool call can transparently reconnect a\n * dropped server (process churn between turns, server exit, a racing teardown)\n * instead of permanently failing with \"not connected\".\n */\nconst mcpServerConfigs = new Map<string, McpServerConfig>();\n/** Remote-transport options (OAuth storage, auth callbacks) per server name. */\nconst mcpRemoteOptions = new Map<string, McpRemoteOptions>();\n/**\n * Servers with a browser authorization flow in flight. While a name is here,\n * a failed handshake must NOT tear the connection down (that would kill the\n * OAuth loopback listener); the auth-completion handler reconnects instead.\n */\nconst mcpAuthPending = new Set<string>();\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nfunction spawnMcpServer(config: McpServerConfig & { command: string }): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as {\n\t\t\t\tid?: number;\n\t\t\t\tresult?: unknown;\n\t\t\t\terror?: { message: string };\n\t\t\t};\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\n/** Remote servers use `{ type: \"http\" | \"sse\", url }`; stdio servers use `command`. */\nfunction isRemoteServer(config: McpServerConfig): boolean {\n\treturn config.type === \"http\" || config.type === \"sse\" || (!config.command && typeof config.url === \"string\");\n}\n\nfunction openMcpConnection(config: McpServerConfig): McpConnection {\n\tif (isRemoteServer(config)) {\n\t\tif (typeof config.url !== \"string\") {\n\t\t\tthrow new Error(`MCP server \"${config.name}\" has type \"${config.type}\" but no \"url\"`);\n\t\t}\n\t\treturn connectHttpMcpServer(\n\t\t\t{\n\t\t\t\tname: config.name,\n\t\t\t\turl: config.url,\n\t\t\t\theaders: config.headers,\n\t\t\t\ttype: config.type === \"sse\" ? \"sse\" : \"http\",\n\t\t\t},\n\t\t\tmcpRemoteOptions.get(config.name),\n\t\t);\n\t}\n\tif (typeof config.command !== \"string\") {\n\t\tthrow new Error(`MCP server \"${config.name}\" needs a \"command\" (stdio) or a \"url\" (http/sse)`);\n\t}\n\treturn spawnMcpServer(config as McpServerConfig & { command: string });\n}\n\nasync function connectMcpServer(config: McpServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tmcpConnections.get(config.name)?.terminate();\n\n\tconst conn = openMcpConnection(config);\n\tmcpConnections.set(config.name, conn);\n\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with the\n\t\t// initialized notification before issuing further requests; strict servers gate\n\t\t// tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as {\n\t\t\ttools?: McpToolDef[];\n\t\t};\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\t// Don't leave a half-connected entry behind: remote transports have no\n\t\t// process-exit event to evict them, so getOrConnectMcp would keep handing\n\t\t// out the dead connection instead of reconnecting. Exception: a pending\n\t\t// browser authorization — terminating would kill the OAuth loopback\n\t\t// listener; the auth-completion handler reconnects that server itself.\n\t\tif (!mcpAuthPending.has(config.name)) {\n\t\t\tconn.terminate();\n\t\t\tif (mcpConnections.get(config.name) === conn) mcpConnections.delete(config.name);\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Return a live connection for a server, lazily reconnecting from the retained\n * config when the previous connection was torn down. Returns undefined only when\n * no config is known or a fresh connect attempt fails.\n */\nasync function getOrConnectMcp(name: string): Promise<McpConnection | undefined> {\n\tconst existing = mcpConnections.get(name);\n\tif (existing) return existing;\n\tconst config = mcpServerConfigs.get(name);\n\tif (!config) return undefined;\n\ttry {\n\t\tconst { conn } = await connectMcpServer(config);\n\t\treturn conn;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nlet mcpExitCleanupInstalled = false;\n/** Kill spawned MCP servers when the host process exits so they don't linger as\n * orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installMcpExitCleanup(): void {\n\tif (mcpExitCleanupInstalled) return;\n\tmcpExitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tfor (const conn of mcpConnections.values()) {\n\t\t\ttry {\n\t\t\t\tconn.terminate();\n\t\t\t} catch {\n\t\t\t\t// best-effort cleanup\n\t\t\t}\n\t\t}\n\t\tmcpConnections.clear();\n\t});\n}\n\nfunction buildMcpSchema(tool: McpToolDef): ReturnType<typeof Type.Object> {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\n/**\n * Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)\n * into hoocode's McpServerConfig format.\n */\nfunction parseStandardMcpConfig(config: StandardMcpConfig, _source: string): McpServerConfig[] {\n\tif (!config.mcpServers) return [];\n\n\tconst servers: McpServerConfig[] = [];\n\tfor (const [name, serverConfig] of Object.entries(config.mcpServers)) {\n\t\tservers.push({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t\ttype: serverConfig.type,\n\t\t\turl: serverConfig.url,\n\t\t\theaders: serverConfig.headers,\n\t\t\tbackground: serverConfig.background,\n\t\t});\n\t}\n\treturn servers;\n}\n\n/**\n * Load MCP servers from a standard mcp.json file.\n * Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.\n */\nfunction loadStandardMcpFile(filePath: string): McpServerConfig[] {\n\tif (!existsSync(filePath)) return [];\n\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf8\");\n\t\tconst config = JSON.parse(content) as StandardMcpConfig;\n\t\treturn parseStandardMcpConfig(config, filePath);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Build the full {@link ToolDefinition} for one MCP tool — the complete JSON\n * schema plus the connect/execute machinery. Shared by the eager path (register\n * every tool up front) and the deferred path (materialize on resolve), so both\n * produce identical, callable tools.\n */\nfunction buildMcpToolDefinition(serverConfig: McpServerConfig, tool: McpToolDef): ToolDefinition {\n\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\tconst schema = buildMcpSchema(tool);\n\tconst capturedServer = serverConfig.name;\n\tconst capturedTool = tool.name;\n\t// MCP tools default to background mode since they are external processes with potential high latency\n\tconst isBackground = serverConfig.background !== false;\n\n\treturn {\n\t\tname: toolName,\n\t\tlabel: `[MCP] ${serverConfig.name} › ${tool.name}`,\n\t\tdescription: tool.description,\n\t\tparameters: schema,\n\t\tbackground: isBackground,\n\t\t// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —\n\t\t// parallel to the subagent `Task [type] <desc>` line. Without this the\n\t\t// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.\n\t\t// The args summary reuses the same helper as the background start/finish\n\t\t// messages so the chat title stays in sync with them.\n\t\trenderCall(args, theme) {\n\t\t\tconst summary = summarizeArgs((args ?? {}) as Record<string, unknown>);\n\t\t\tconst text =\n\t\t\t\ttheme.fg(\"toolTitle\", theme.bold(\"MCP \")) +\n\t\t\t\ttheme.fg(\"accent\", `[${capturedServer} › ${capturedTool}]`) +\n\t\t\t\t(summary ? theme.fg(\"dim\", ` ${summary}`) : \"\");\n\t\t\treturn new Text(text, 0, 0);\n\t\t},\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof schema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<undefined>> {\n\t\t\t// Background MCP tools get a task store entry so they appear in the task pane.\n\t\t\t// Foreground tools skip this (their result is awaited inline). The server\n\t\t\t// name rides in subagentMode and becomes the row's `[server]` origin tag;\n\t\t\t// the title carries just the tool.\n\t\t\tconst task = isBackground\n\t\t\t\t? taskStore.create(capturedTool, { source: \"mcp\", subagentMode: capturedServer })\n\t\t\t\t: undefined;\n\t\t\tif (task) taskStore.update(task.id, { status: \"in_progress\" });\n\n\t\t\t// Lazily (re)connect: a dropped connection (server exit, process churn\n\t\t\t// between turns, a racing teardown) should transparently reconnect from\n\t\t\t// the retained config rather than permanently fail with \"not connected\".\n\t\t\tconst activeConn = await getOrConnectMcp(capturedServer);\n\t\t\tif (!activeConn) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `MCP server \"${capturedServer}\" is not connected (reconnect attempt failed)` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")));\n\t\t\t\t});\n\n\t\t\t\tconst result = await Promise.race([\n\t\t\t\t\tactiveConn.rpc(\"tools/call\", { name: capturedTool, arguments: params }),\n\t\t\t\t\tabortPromise,\n\t\t\t\t]);\n\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"done\" });\n\t\t\t\treturn { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }], details: undefined };\n\t\t\t} catch (error) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t} as ToolDefinition;\n}\n\nconst RESOLVE_MCP_TOOLS_NAME = \"ResolveMcpTools\";\n\nexport function setupMcpLoader(pi: ExtensionAPI): void {\n\tpi.on(\"session_start\", async (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// A spawned subagent whose tool allowlist has no MCP tools is told by its\n\t\t// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).\n\t\t// Each connect is a ~15s-timeout handshake; doing it for a subagent that can\n\t\t// never call the tools is pure startup latency.\n\t\tif (subagentSkipMcp()) return;\n\n\t\tinstallMcpExitCleanup();\n\t\tconst allServerConfigs: McpServerConfig[] = [];\n\t\tconst seenNames = new Set<string>();\n\n\t\t// 1. Load from standard mcp.json locations\n\t\t// User-level: ~/.agents/mcp.json\n\t\tconst userAgentsConfig = loadStandardMcpFile(join(homedir(), \".agents\", \"mcp.json\"));\n\t\tfor (const config of userAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Project-level: ./.agents/mcp.json\n\t\tconst projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, \".agents\", \"mcp.json\"));\n\t\tfor (const config of projectAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Claude Desktop: ~/.config/claude/mcp.json\n\t\tconst claudeDesktopConfig = loadStandardMcpFile(join(homedir(), \".config\", \"claude\", \"mcp.json\"));\n\t\tfor (const config of claudeDesktopConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// 2. Load from hoocode's per-server format (existing behavior)\n\t\tconst searchDirs = [join(HOOCODE_DIR, \"mcp-servers\"), join(ctx.cwd, \".hoocode\", \"mcp-servers\")];\n\n\t\tfor (const dir of searchDirs) {\n\t\t\tif (!existsSync(dir)) continue;\n\n\t\t\tlet files: string[];\n\t\t\ttry {\n\t\t\t\tfiles = (await readdir(dir)).filter((f) => f.endsWith(\".json\"));\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst cfgPath = join(dir, file);\n\t\t\t\tlet serverConfig: McpServerConfig;\n\n\t\t\t\ttry {\n\t\t\t\t\tserverConfig = JSON.parse(readFileSync(cfgPath, \"utf8\")) as McpServerConfig;\n\t\t\t\t\tif (!serverConfig.name || (!serverConfig.command && !serverConfig.url)) {\n\t\t\t\t\t\tctx.ui.notify(`MCP: config \"${file}\" is missing required \"name\" or \"command\"/\"url\"`, \"warning\");\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to parse \"${file}\": ${String(err)}`, \"error\");\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Skip if already loaded from standard config\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// 2b. Load from plugins/extensions that registered MCP servers during load.\n\t\tfor (const entry of getExtensionMcpServers()) {\n\t\t\tfor (const serverConfig of parseStandardMcpConfig(\n\t\t\t\t{ mcpServers: entry.mcpServers },\n\t\t\t\t`plugin:${entry.source}`,\n\t\t\t)) {\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// Deferral (spec §2): inject MCP tool names only and materialize each schema\n\t\t// on demand via ResolveMcpTools. Default-on and top-level only — a subagent that\n\t\t// needs MCP has this env cleared, so it eager-registers its allowlisted tools\n\t\t// at dispatch (the dispatch ↔ schema interaction) and they are immediately callable.\n\t\tconst defer = deferMcpSchemas();\n\t\tconst deferredCatalog: DeferredMcpToolEntry[] = [];\n\t\t// Retain each deferred tool's raw definition + config so ResolveMcpTools can\n\t\t// build the full ToolDefinition on request.\n\t\tconst deferredByName = new Map<string, { serverConfig: McpServerConfig; tool: McpToolDef }>();\n\t\tconst resolvedNames = new Set<string>();\n\n\t\t// Remote servers get OAuth support: tokens persist under\n\t\t// ~/.hoocode/mcp-auth, and when a server demands interactive (browser)\n\t\t// authorization the handshake fails fast while the flow keeps running in\n\t\t// the background — on completion the server is reconnected and its tools\n\t\t// registered (eagerly, since the deferred catalog is already sealed).\n\t\tconst buildRemoteOptions = (serverConfig: McpServerConfig): McpRemoteOptions => ({\n\t\t\tauthStorageDir: join(HOOCODE_DIR, \"mcp-auth\"),\n\t\t\tonAuthRequired: (authorizationUrl, completed) => {\n\t\t\t\tmcpAuthPending.add(serverConfig.name);\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: \"${serverConfig.name}\" requires authorization — complete the sign-in opened in your browser` +\n\t\t\t\t\t\t(authorizationUrl ? `: ${authorizationUrl}` : \"\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\tcompleted\n\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\t\t\t\t\t\t\tfor (const tool of tools) pi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" after authorization (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"})`,\n\t\t\t\t\t\t\t\t\"info\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: failed to connect \"${serverConfig.name}\" after authorization: ${String(err)}`,\n\t\t\t\t\t\t\t\t\"error\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\tctx.ui.notify(`MCP: authorization for \"${serverConfig.name}\" failed: ${String(err)}`, \"error\");\n\t\t\t\t\t});\n\t\t\t},\n\t\t});\n\n\t\t// 3. Connect to all servers and register (or defer) tools\n\t\tfor (const serverConfig of allServerConfigs) {\n\t\t\t// Retain the config so a tool call can lazily reconnect a dropped server.\n\t\t\tmcpServerConfigs.set(serverConfig.name, serverConfig);\n\t\t\tmcpRemoteOptions.set(serverConfig.name, buildRemoteOptions(serverConfig));\n\t\t\ttry {\n\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\n\t\t\t\tfor (const tool of tools) {\n\t\t\t\t\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\t\t\t\t\tif (defer) {\n\t\t\t\t\t\tdeferredCatalog.push({ toolName, server: serverConfig.name, description: tool.description });\n\t\t\t\t\t\tdeferredByName.set(toolName, { serverConfig, tool });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst bgMode = serverConfig.background !== false ? \"background\" : \"foreground\";\n\t\t\t\tconst suffix = defer ? \", schemas deferred\" : \"\";\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"}, ${bgMode}${suffix})`,\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t} catch (err) {\n\t\t\t\tif (mcpAuthPending.has(serverConfig.name)) {\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`MCP: \"${serverConfig.name}\" is waiting for browser authorization; tools will register once it completes`,\n\t\t\t\t\t\t\"info\",\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to connect \"${serverConfig.name}\": ${String(err)}`, \"error\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 4. In deferred mode, register the single resolver that materializes schemas on demand.\n\t\tif (defer && deferredCatalog.length > 0) {\n\t\t\tconst resolveParams = Type.Object(\n\t\t\t\t{\n\t\t\t\t\tnames: Type.Array(Type.String(), {\n\t\t\t\t\t\tdescription: \"MCP tool names to make callable (e.g. 'mcp_github_create_pr' or 'create_pr').\",\n\t\t\t\t\t}),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t);\n\t\t\tpi.registerTool({\n\t\t\t\tname: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tlabel: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tdescription:\n\t\t\t\t\t\"MCP tools are connected but their schemas are loaded on demand to keep context small. Call this with the tool name(s) you need to make them callable, then call the tool(s). Available MCP tools:\\n\" +\n\t\t\t\t\tformatDeferredCatalog(deferredCatalog),\n\t\t\t\tpromptSnippet: \"Resolve deferred MCP tool schemas by name before calling them.\",\n\t\t\t\tparameters: resolveParams,\n\t\t\t\tasync execute(_toolCallId: string, params: Static<typeof resolveParams>) {\n\t\t\t\t\tconst matched = selectResolvable(deferredCatalog, params.names ?? []);\n\t\t\t\t\tconst newlyResolved: string[] = [];\n\t\t\t\t\tfor (const entry of matched) {\n\t\t\t\t\t\tif (resolvedNames.has(entry.toolName)) continue;\n\t\t\t\t\t\tconst raw = deferredByName.get(entry.toolName);\n\t\t\t\t\t\tif (!raw) continue;\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(raw.serverConfig, raw.tool));\n\t\t\t\t\t\tresolvedNames.add(entry.toolName);\n\t\t\t\t\t\tnewlyResolved.push(entry.toolName);\n\t\t\t\t\t}\n\t\t\t\t\tconst text = matched.length\n\t\t\t\t\t\t? `Resolved ${newlyResolved.length} MCP tool(s): ${matched.map((m) => m.toolName).join(\", \")}. They are now callable.`\n\t\t\t\t\t\t: `No MCP tools matched: ${(params.names ?? []).join(\", \") || \"(none)\"}. See ResolveMcpTools for the catalog.`;\n\t\t\t\t\treturn { content: [{ type: \"text\" as const, text }], details: undefined };\n\t\t\t\t},\n\t\t\t} as ToolDefinition);\n\t\t}\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"mcp-loader.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/mcp-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAcH,OAAO,KAAK,EAAE,YAAY,EAAuD,MAAM,gCAAgC,CAAC;AAkBxH,MAAM,WAAW,eAAe;IAC/B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,6FAA6F;IAC7F,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;IAChC,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AAgXD,wBAAgB,cAAc,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAqOrD","sourcesContent":["/**\n * MCP server loader — discovers server configs (standard mcp.json locations,\n * hoocode's per-server JSON files, plugin registrations), connects via JSON-RPC\n * 2.0 over stdio (`command`), Streamable HTTP (`{ \"type\": \"http\", \"url\": ... }`),\n * or legacy SSE (`\"type\": \"sse\"`), and registers each server tool as\n * `mcp_<server>_<tool>`.\n *\n * Config sources (first-wins by server name):\n * 1. ~/.agents/mcp.json (user), ./.agents/mcp.json (project),\n * ~/.config/claude/mcp.json (Claude Desktop)\n * 2. ~/.hoocode/mcp-servers/*.json and ./.hoocode/mcp-servers/*.json\n * 3. MCP servers registered by plugins/extensions during load\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport type { AgentToolResult, AgentToolUpdateCallback, McpRemoteOptions } from \"@kolisachint/hoocode-agent-core\";\nimport { connectHttpMcpServer, summarizeArgs } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { getExtensionMcpServers } from \"../../core/extension-mcp-servers.js\";\nimport type { ExtensionAPI, ExtensionContext, SessionStartEvent, ToolDefinition } from \"../../core/extensions/types.js\";\nimport { clearMcpServerStatuses, setMcpServerStatus } from \"../../core/mcp-status.js\";\nimport { deferMcpSchemas, subagentSkipMcp } from \"../../core/subagent-depth.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport { type DeferredMcpToolEntry, formatDeferredCatalog, selectResolvable } from \"./mcp-deferred.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\ninterface McpToolDef {\n\tname: string;\n\tdescription: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\nexport interface McpServerConfig {\n\t/** Unique server identifier used as prefix for registered tool names */\n\tname: string;\n\t/** Executable to spawn (stdio transport). One of command/url is required. */\n\tcommand?: string;\n\t/** Optional arguments passed to the command */\n\targs?: string[];\n\t/** Optional extra environment variables for the server process */\n\tenv?: Record<string, string>;\n\t/** Transport: \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy) */\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\t/** Remote server URL (http/sse transports) */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports */\n\theaders?: Record<string, string>;\n\t/** Run MCP tools in background by default (default: true for MCP servers) */\n\tbackground?: boolean;\n}\n\n/** Standard MCP config format used by Claude Desktop, Claude Code, VS Code / Copilot */\ninterface StandardMcpServerConfig {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\turl?: string;\n\theaders?: Record<string, string>;\n\tbackground?: boolean;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerConfig>;\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nconst mcpConnections = new Map<string, McpConnection>();\n/**\n * Server configs retained by name so a tool call can transparently reconnect a\n * dropped server (process churn between turns, server exit, a racing teardown)\n * instead of permanently failing with \"not connected\".\n */\nconst mcpServerConfigs = new Map<string, McpServerConfig>();\n/** Remote-transport options (OAuth storage, auth callbacks) per server name. */\nconst mcpRemoteOptions = new Map<string, McpRemoteOptions>();\n/**\n * Servers with a browser authorization flow in flight. While a name is here,\n * a failed handshake must NOT tear the connection down (that would kill the\n * OAuth loopback listener); the auth-completion handler reconnects instead.\n */\nconst mcpAuthPending = new Set<string>();\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nfunction spawnMcpServer(config: McpServerConfig & { command: string }): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as {\n\t\t\t\tid?: number;\n\t\t\t\tresult?: unknown;\n\t\t\t\terror?: { message: string };\n\t\t\t};\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\n/** Remote servers use `{ type: \"http\" | \"sse\", url }`; stdio servers use `command`. */\nfunction isRemoteServer(config: McpServerConfig): boolean {\n\treturn config.type === \"http\" || config.type === \"sse\" || (!config.command && typeof config.url === \"string\");\n}\n\nfunction openMcpConnection(config: McpServerConfig): McpConnection {\n\tif (isRemoteServer(config)) {\n\t\tif (typeof config.url !== \"string\") {\n\t\t\tthrow new Error(`MCP server \"${config.name}\" has type \"${config.type}\" but no \"url\"`);\n\t\t}\n\t\treturn connectHttpMcpServer(\n\t\t\t{\n\t\t\t\tname: config.name,\n\t\t\t\turl: config.url,\n\t\t\t\theaders: config.headers,\n\t\t\t\ttype: config.type === \"sse\" ? \"sse\" : \"http\",\n\t\t\t},\n\t\t\tmcpRemoteOptions.get(config.name),\n\t\t);\n\t}\n\tif (typeof config.command !== \"string\") {\n\t\tthrow new Error(`MCP server \"${config.name}\" needs a \"command\" (stdio) or a \"url\" (http/sse)`);\n\t}\n\treturn spawnMcpServer(config as McpServerConfig & { command: string });\n}\n\nasync function connectMcpServer(config: McpServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tmcpConnections.get(config.name)?.terminate();\n\n\tconst conn = openMcpConnection(config);\n\tmcpConnections.set(config.name, conn);\n\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with the\n\t\t// initialized notification before issuing further requests; strict servers gate\n\t\t// tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as {\n\t\t\ttools?: McpToolDef[];\n\t\t};\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\t// Don't leave a half-connected entry behind: remote transports have no\n\t\t// process-exit event to evict them, so getOrConnectMcp would keep handing\n\t\t// out the dead connection instead of reconnecting. Exception: a pending\n\t\t// browser authorization — terminating would kill the OAuth loopback\n\t\t// listener; the auth-completion handler reconnects that server itself.\n\t\tif (!mcpAuthPending.has(config.name)) {\n\t\t\tconn.terminate();\n\t\t\tif (mcpConnections.get(config.name) === conn) mcpConnections.delete(config.name);\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Return a live connection for a server, lazily reconnecting from the retained\n * config when the previous connection was torn down. Returns undefined only when\n * no config is known or a fresh connect attempt fails.\n */\nasync function getOrConnectMcp(name: string): Promise<McpConnection | undefined> {\n\tconst existing = mcpConnections.get(name);\n\tif (existing) return existing;\n\tconst config = mcpServerConfigs.get(name);\n\tif (!config) return undefined;\n\ttry {\n\t\tconst { conn } = await connectMcpServer(config);\n\t\treturn conn;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nlet mcpExitCleanupInstalled = false;\n/** Kill spawned MCP servers when the host process exits so they don't linger as\n * orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installMcpExitCleanup(): void {\n\tif (mcpExitCleanupInstalled) return;\n\tmcpExitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tfor (const conn of mcpConnections.values()) {\n\t\t\ttry {\n\t\t\t\tconn.terminate();\n\t\t\t} catch {\n\t\t\t\t// best-effort cleanup\n\t\t\t}\n\t\t}\n\t\tmcpConnections.clear();\n\t});\n}\n\nfunction buildMcpSchema(tool: McpToolDef): ReturnType<typeof Type.Object> {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\n/**\n * Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)\n * into hoocode's McpServerConfig format.\n */\nfunction parseStandardMcpConfig(config: StandardMcpConfig, _source: string): McpServerConfig[] {\n\tif (!config.mcpServers) return [];\n\n\tconst servers: McpServerConfig[] = [];\n\tfor (const [name, serverConfig] of Object.entries(config.mcpServers)) {\n\t\tservers.push({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t\ttype: serverConfig.type,\n\t\t\turl: serverConfig.url,\n\t\t\theaders: serverConfig.headers,\n\t\t\tbackground: serverConfig.background,\n\t\t});\n\t}\n\treturn servers;\n}\n\n/**\n * Load MCP servers from a standard mcp.json file.\n * Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.\n */\nfunction loadStandardMcpFile(filePath: string): McpServerConfig[] {\n\tif (!existsSync(filePath)) return [];\n\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf8\");\n\t\tconst config = JSON.parse(content) as StandardMcpConfig;\n\t\treturn parseStandardMcpConfig(config, filePath);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Build the full {@link ToolDefinition} for one MCP tool — the complete JSON\n * schema plus the connect/execute machinery. Shared by the eager path (register\n * every tool up front) and the deferred path (materialize on resolve), so both\n * produce identical, callable tools.\n */\nfunction buildMcpToolDefinition(serverConfig: McpServerConfig, tool: McpToolDef): ToolDefinition {\n\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\tconst schema = buildMcpSchema(tool);\n\tconst capturedServer = serverConfig.name;\n\tconst capturedTool = tool.name;\n\t// MCP tools default to background mode since they are external processes with potential high latency\n\tconst isBackground = serverConfig.background !== false;\n\n\treturn {\n\t\tname: toolName,\n\t\tlabel: `[MCP] ${serverConfig.name} › ${tool.name}`,\n\t\tdescription: tool.description,\n\t\tparameters: schema,\n\t\tbackground: isBackground,\n\t\t// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —\n\t\t// parallel to the subagent `Task [type] <desc>` line. Without this the\n\t\t// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.\n\t\t// The args summary reuses the same helper as the background start/finish\n\t\t// messages so the chat title stays in sync with them.\n\t\trenderCall(args, theme) {\n\t\t\tconst summary = summarizeArgs((args ?? {}) as Record<string, unknown>);\n\t\t\tconst text =\n\t\t\t\ttheme.fg(\"toolTitle\", theme.bold(\"MCP \")) +\n\t\t\t\ttheme.fg(\"accent\", `[${capturedServer} › ${capturedTool}]`) +\n\t\t\t\t(summary ? theme.fg(\"dim\", ` ${summary}`) : \"\");\n\t\t\treturn new Text(text, 0, 0);\n\t\t},\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof schema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<undefined>> {\n\t\t\t// Background MCP tools get a task store entry so they appear in the task pane.\n\t\t\t// Foreground tools skip this (their result is awaited inline). The server\n\t\t\t// name rides in subagentMode and becomes the row's `[server]` origin tag;\n\t\t\t// the title carries just the tool.\n\t\t\tconst task = isBackground\n\t\t\t\t? taskStore.create(capturedTool, { source: \"mcp\", subagentMode: capturedServer })\n\t\t\t\t: undefined;\n\t\t\tif (task) taskStore.update(task.id, { status: \"in_progress\" });\n\n\t\t\t// Lazily (re)connect: a dropped connection (server exit, process churn\n\t\t\t// between turns, a racing teardown) should transparently reconnect from\n\t\t\t// the retained config rather than permanently fail with \"not connected\".\n\t\t\tconst activeConn = await getOrConnectMcp(capturedServer);\n\t\t\tif (!activeConn) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `MCP server \"${capturedServer}\" is not connected (reconnect attempt failed)` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")));\n\t\t\t\t});\n\n\t\t\t\tconst result = await Promise.race([\n\t\t\t\t\tactiveConn.rpc(\"tools/call\", { name: capturedTool, arguments: params }),\n\t\t\t\t\tabortPromise,\n\t\t\t\t]);\n\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"done\" });\n\t\t\t\treturn { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }], details: undefined };\n\t\t\t} catch (error) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t} as ToolDefinition;\n}\n\nconst RESOLVE_MCP_TOOLS_NAME = \"ResolveMcpTools\";\n\nexport function setupMcpLoader(pi: ExtensionAPI): void {\n\tpi.on(\"session_start\", async (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// A spawned subagent whose tool allowlist has no MCP tools is told by its\n\t\t// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).\n\t\t// Each connect is a ~15s-timeout handshake; doing it for a subagent that can\n\t\t// never call the tools is pure startup latency.\n\t\tif (subagentSkipMcp()) return;\n\n\t\tinstallMcpExitCleanup();\n\t\t// Rebuild the status registry from scratch: a reload or session switch runs\n\t\t// this pass again and the startup summary must not double-count.\n\t\tclearMcpServerStatuses();\n\t\tconst allServerConfigs: McpServerConfig[] = [];\n\t\tconst seenNames = new Set<string>();\n\n\t\t// 1. Load from standard mcp.json locations\n\t\t// User-level: ~/.agents/mcp.json\n\t\tconst userAgentsConfig = loadStandardMcpFile(join(homedir(), \".agents\", \"mcp.json\"));\n\t\tfor (const config of userAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Project-level: ./.agents/mcp.json\n\t\tconst projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, \".agents\", \"mcp.json\"));\n\t\tfor (const config of projectAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Claude Desktop: ~/.config/claude/mcp.json\n\t\tconst claudeDesktopConfig = loadStandardMcpFile(join(homedir(), \".config\", \"claude\", \"mcp.json\"));\n\t\tfor (const config of claudeDesktopConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// 2. Load from hoocode's per-server format (existing behavior)\n\t\tconst searchDirs = [join(HOOCODE_DIR, \"mcp-servers\"), join(ctx.cwd, \".hoocode\", \"mcp-servers\")];\n\n\t\tfor (const dir of searchDirs) {\n\t\t\tif (!existsSync(dir)) continue;\n\n\t\t\tlet files: string[];\n\t\t\ttry {\n\t\t\t\tfiles = (await readdir(dir)).filter((f) => f.endsWith(\".json\"));\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst cfgPath = join(dir, file);\n\t\t\t\tlet serverConfig: McpServerConfig;\n\n\t\t\t\ttry {\n\t\t\t\t\tserverConfig = JSON.parse(readFileSync(cfgPath, \"utf8\")) as McpServerConfig;\n\t\t\t\t\tif (!serverConfig.name || (!serverConfig.command && !serverConfig.url)) {\n\t\t\t\t\t\tctx.ui.notify(`MCP: config \"${file}\" is missing required \"name\" or \"command\"/\"url\"`, \"warning\");\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to parse \"${file}\": ${String(err)}`, \"error\");\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Skip if already loaded from standard config\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// 2b. Load from plugins/extensions that registered MCP servers during load.\n\t\tfor (const entry of getExtensionMcpServers()) {\n\t\t\tfor (const serverConfig of parseStandardMcpConfig(\n\t\t\t\t{ mcpServers: entry.mcpServers },\n\t\t\t\t`plugin:${entry.source}`,\n\t\t\t)) {\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// Deferral (spec §2): inject MCP tool names only and materialize each schema\n\t\t// on demand via ResolveMcpTools. Default-on and top-level only — a subagent that\n\t\t// needs MCP has this env cleared, so it eager-registers its allowlisted tools\n\t\t// at dispatch (the dispatch ↔ schema interaction) and they are immediately callable.\n\t\tconst defer = deferMcpSchemas();\n\t\tconst deferredCatalog: DeferredMcpToolEntry[] = [];\n\t\t// Retain each deferred tool's raw definition + config so ResolveMcpTools can\n\t\t// build the full ToolDefinition on request.\n\t\tconst deferredByName = new Map<string, { serverConfig: McpServerConfig; tool: McpToolDef }>();\n\t\tconst resolvedNames = new Set<string>();\n\n\t\t// Remote servers get OAuth support: tokens persist under\n\t\t// ~/.hoocode/mcp-auth, and when a server demands interactive (browser)\n\t\t// authorization the handshake fails fast while the flow keeps running in\n\t\t// the background — on completion the server is reconnected and its tools\n\t\t// registered (eagerly, since the deferred catalog is already sealed).\n\t\tconst buildRemoteOptions = (serverConfig: McpServerConfig): McpRemoteOptions => ({\n\t\t\tauthStorageDir: join(HOOCODE_DIR, \"mcp-auth\"),\n\t\t\tonAuthRequired: (authorizationUrl, completed) => {\n\t\t\t\tmcpAuthPending.add(serverConfig.name);\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: \"${serverConfig.name}\" requires authorization — complete the sign-in opened in your browser` +\n\t\t\t\t\t\t(authorizationUrl ? `: ${authorizationUrl}` : \"\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\tcompleted\n\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\t\t\t\t\t\t\tfor (const tool of tools) pi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\t\t\tdeferred: false,\n\t\t\t\t\t\t\t\tstate: \"connected\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" after authorization (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"})`,\n\t\t\t\t\t\t\t\t\"info\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: failed to connect \"${serverConfig.name}\" after authorization: ${String(err)}`,\n\t\t\t\t\t\t\t\t\"error\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\tctx.ui.notify(`MCP: authorization for \"${serverConfig.name}\" failed: ${String(err)}`, \"error\");\n\t\t\t\t\t});\n\t\t\t},\n\t\t});\n\n\t\t// 3. Connect to all servers and register (or defer) tools\n\t\tfor (const serverConfig of allServerConfigs) {\n\t\t\t// Retain the config so a tool call can lazily reconnect a dropped server.\n\t\t\tmcpServerConfigs.set(serverConfig.name, serverConfig);\n\t\t\tmcpRemoteOptions.set(serverConfig.name, buildRemoteOptions(serverConfig));\n\t\t\ttry {\n\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\n\t\t\t\tfor (const tool of tools) {\n\t\t\t\t\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\t\t\t\t\tif (defer) {\n\t\t\t\t\t\tdeferredCatalog.push({ toolName, server: serverConfig.name, description: tool.description });\n\t\t\t\t\t\tdeferredByName.set(toolName, { serverConfig, tool });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// A successful connect is reported by the startup resource summary (one\n\t\t\t\t// `mcp` cell plus a details row), not as a transient line above it.\n\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\tdeferred: defer,\n\t\t\t\t\tstate: \"connected\",\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (mcpAuthPending.has(serverConfig.name)) {\n\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\ttoolCount: 0,\n\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\tdeferred: defer,\n\t\t\t\t\t\tstate: \"authorizing\",\n\t\t\t\t\t});\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`MCP: \"${serverConfig.name}\" is waiting for browser authorization; tools will register once it completes`,\n\t\t\t\t\t\t\"info\",\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to connect \"${serverConfig.name}\": ${String(err)}`, \"error\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 4. In deferred mode, register the single resolver that materializes schemas on demand.\n\t\tif (defer && deferredCatalog.length > 0) {\n\t\t\tconst resolveParams = Type.Object(\n\t\t\t\t{\n\t\t\t\t\tnames: Type.Array(Type.String(), {\n\t\t\t\t\t\tdescription: \"MCP tool names to make callable (e.g. 'mcp_github_create_pr' or 'create_pr').\",\n\t\t\t\t\t}),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t);\n\t\t\tpi.registerTool({\n\t\t\t\tname: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tlabel: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tdescription:\n\t\t\t\t\t\"MCP tools are connected but their schemas are loaded on demand to keep context small. Call this with the tool name(s) you need to make them callable, then call the tool(s). Available MCP tools:\\n\" +\n\t\t\t\t\tformatDeferredCatalog(deferredCatalog),\n\t\t\t\tpromptSnippet: \"Resolve deferred MCP tool schemas by name before calling them.\",\n\t\t\t\tparameters: resolveParams,\n\t\t\t\tasync execute(_toolCallId: string, params: Static<typeof resolveParams>) {\n\t\t\t\t\tconst matched = selectResolvable(deferredCatalog, params.names ?? []);\n\t\t\t\t\tconst newlyResolved: string[] = [];\n\t\t\t\t\tfor (const entry of matched) {\n\t\t\t\t\t\tif (resolvedNames.has(entry.toolName)) continue;\n\t\t\t\t\t\tconst raw = deferredByName.get(entry.toolName);\n\t\t\t\t\t\tif (!raw) continue;\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(raw.serverConfig, raw.tool));\n\t\t\t\t\t\tresolvedNames.add(entry.toolName);\n\t\t\t\t\t\tnewlyResolved.push(entry.toolName);\n\t\t\t\t\t}\n\t\t\t\t\tconst text = matched.length\n\t\t\t\t\t\t? `Resolved ${newlyResolved.length} MCP tool(s): ${matched.map((m) => m.toolName).join(\", \")}. They are now callable.`\n\t\t\t\t\t\t: `No MCP tools matched: ${(params.names ?? []).join(\", \") || \"(none)\"}. See ResolveMcpTools for the catalog.`;\n\t\t\t\t\treturn { content: [{ type: \"text\" as const, text }], details: undefined };\n\t\t\t\t},\n\t\t\t} as ToolDefinition);\n\t\t}\n\t});\n}\n"]}
|
|
@@ -22,6 +22,7 @@ import { Text } from "@kolisachint/hoocode-tui";
|
|
|
22
22
|
import { Type } from "typebox";
|
|
23
23
|
import { getHooCodeDir } from "../../config.js";
|
|
24
24
|
import { getExtensionMcpServers } from "../../core/extension-mcp-servers.js";
|
|
25
|
+
import { clearMcpServerStatuses, setMcpServerStatus } from "../../core/mcp-status.js";
|
|
25
26
|
import { deferMcpSchemas, subagentSkipMcp } from "../../core/subagent-depth.js";
|
|
26
27
|
import { taskStore } from "../../core/task-store.js";
|
|
27
28
|
import { formatDeferredCatalog, selectResolvable } from "./mcp-deferred.js";
|
|
@@ -352,6 +353,9 @@ export function setupMcpLoader(pi) {
|
|
|
352
353
|
if (subagentSkipMcp())
|
|
353
354
|
return;
|
|
354
355
|
installMcpExitCleanup();
|
|
356
|
+
// Rebuild the status registry from scratch: a reload or session switch runs
|
|
357
|
+
// this pass again and the startup summary must not double-count.
|
|
358
|
+
clearMcpServerStatuses();
|
|
355
359
|
const allServerConfigs = [];
|
|
356
360
|
const seenNames = new Set();
|
|
357
361
|
// 1. Load from standard mcp.json locations
|
|
@@ -449,6 +453,13 @@ export function setupMcpLoader(pi) {
|
|
|
449
453
|
const { tools } = await connectMcpServer(serverConfig);
|
|
450
454
|
for (const tool of tools)
|
|
451
455
|
pi.registerTool(buildMcpToolDefinition(serverConfig, tool));
|
|
456
|
+
setMcpServerStatus({
|
|
457
|
+
name: serverConfig.name,
|
|
458
|
+
toolCount: tools.length,
|
|
459
|
+
background: serverConfig.background !== false,
|
|
460
|
+
deferred: false,
|
|
461
|
+
state: "connected",
|
|
462
|
+
});
|
|
452
463
|
ctx.ui.notify(`MCP: connected "${serverConfig.name}" after authorization (${tools.length} tool${tools.length === 1 ? "" : "s"})`, "info");
|
|
453
464
|
}
|
|
454
465
|
catch (err) {
|
|
@@ -478,12 +489,25 @@ export function setupMcpLoader(pi) {
|
|
|
478
489
|
pi.registerTool(buildMcpToolDefinition(serverConfig, tool));
|
|
479
490
|
}
|
|
480
491
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
492
|
+
// A successful connect is reported by the startup resource summary (one
|
|
493
|
+
// `mcp` cell plus a details row), not as a transient line above it.
|
|
494
|
+
setMcpServerStatus({
|
|
495
|
+
name: serverConfig.name,
|
|
496
|
+
toolCount: tools.length,
|
|
497
|
+
background: serverConfig.background !== false,
|
|
498
|
+
deferred: defer,
|
|
499
|
+
state: "connected",
|
|
500
|
+
});
|
|
484
501
|
}
|
|
485
502
|
catch (err) {
|
|
486
503
|
if (mcpAuthPending.has(serverConfig.name)) {
|
|
504
|
+
setMcpServerStatus({
|
|
505
|
+
name: serverConfig.name,
|
|
506
|
+
toolCount: 0,
|
|
507
|
+
background: serverConfig.background !== false,
|
|
508
|
+
deferred: defer,
|
|
509
|
+
state: "authorizing",
|
|
510
|
+
});
|
|
487
511
|
ctx.ui.notify(`MCP: "${serverConfig.name}" is waiting for browser authorization; tools will register once it completes`, "info");
|
|
488
512
|
}
|
|
489
513
|
else {
|