@kolisachint/hoocode-agent 0.4.106 → 0.4.107
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 +2 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +4 -4
- package/dist/cli/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +0 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +1 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +2 -2
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +1 -1
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extension-mcp-servers.d.ts +1 -1
- package/dist/core/extension-mcp-servers.d.ts.map +1 -1
- package/dist/core/extension-mcp-servers.js +1 -1
- package/dist/core/extension-mcp-servers.js.map +1 -1
- package/dist/core/extensions/plugins/index.d.ts.map +1 -1
- package/dist/core/extensions/plugins/index.js +1 -1
- package/dist/core/extensions/plugins/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +1 -3
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/mode-prompts.d.ts +1 -1
- package/dist/core/mode-prompts.d.ts.map +1 -1
- package/dist/core/mode-prompts.js +1 -1
- package/dist/core/mode-prompts.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +1 -1
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +1 -2
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +2 -97
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +4 -65
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +2 -3
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/telemetry.d.ts.map +1 -1
- package/dist/core/telemetry.js +1 -1
- package/dist/core/telemetry.js.map +1 -1
- package/dist/core/timings.d.ts +1 -1
- package/dist/core/timings.d.ts.map +1 -1
- package/dist/core/timings.js +2 -2
- package/dist/core/timings.js.map +1 -1
- package/dist/core/tools/{browser-continue.d.ts → browser/browser-continue.d.ts} +1 -1
- package/dist/core/tools/browser/browser-continue.d.ts.map +1 -0
- package/dist/core/tools/{browser-continue.js → browser/browser-continue.js} +2 -2
- package/dist/core/tools/browser/browser-continue.js.map +1 -0
- package/dist/core/tools/{browser-run.d.ts → browser/browser-run.d.ts} +2 -2
- package/dist/core/tools/browser/browser-run.d.ts.map +1 -0
- package/dist/core/tools/{browser-run.js → browser/browser-run.js} +2 -2
- package/dist/core/tools/browser/browser-run.js.map +1 -0
- package/dist/core/tools/browser/browsertools-shared.d.ts.map +1 -0
- package/dist/core/tools/{browsertools-shared.js → browser/browsertools-shared.js} +1 -1
- package/dist/core/tools/browser/browsertools-shared.js.map +1 -0
- package/dist/core/tools/browser/index.d.ts +8 -0
- package/dist/core/tools/browser/index.d.ts.map +1 -0
- package/dist/core/tools/browser/index.js +8 -0
- package/dist/core/tools/browser/index.js.map +1 -0
- package/dist/core/tools/{docedit.d.ts → doc/docedit.d.ts} +2 -2
- package/dist/core/tools/doc/docedit.d.ts.map +1 -0
- package/dist/core/tools/{docedit.js → doc/docedit.js} +7 -7
- package/dist/core/tools/doc/docedit.js.map +1 -0
- package/dist/core/tools/{docgrep.d.ts → doc/docgrep.d.ts} +2 -2
- package/dist/core/tools/doc/docgrep.d.ts.map +1 -0
- package/dist/core/tools/{docgrep.js → doc/docgrep.js} +6 -6
- package/dist/core/tools/doc/docgrep.js.map +1 -0
- package/dist/core/tools/{docpeek.d.ts → doc/docpeek.d.ts} +2 -2
- package/dist/core/tools/{docpeek.js.map → doc/docpeek.d.ts.map} +1 -1
- package/dist/core/tools/{docpeek.js → doc/docpeek.js} +6 -6
- package/dist/core/tools/doc/docpeek.js.map +1 -0
- package/dist/core/tools/{docread.d.ts → doc/docread.d.ts} +2 -2
- package/dist/core/tools/doc/docread.d.ts.map +1 -0
- package/dist/core/tools/{docread.js → doc/docread.js} +6 -6
- package/dist/core/tools/doc/docread.js.map +1 -0
- package/dist/core/tools/{docscan.d.ts → doc/docscan.d.ts} +2 -2
- package/dist/core/tools/doc/docscan.d.ts.map +1 -0
- package/dist/core/tools/{docscan.js → doc/docscan.js} +6 -6
- package/dist/core/tools/doc/docscan.js.map +1 -0
- package/dist/core/tools/{docwrite.d.ts → doc/docwrite.d.ts} +1 -1
- package/dist/core/tools/doc/docwrite.d.ts.map +1 -0
- package/dist/core/tools/{docwrite.js → doc/docwrite.js} +6 -6
- package/dist/core/tools/doc/docwrite.js.map +1 -0
- package/dist/core/tools/doc/index.d.ts +11 -0
- package/dist/core/tools/doc/index.d.ts.map +1 -0
- package/dist/core/tools/doc/index.js +11 -0
- package/dist/core/tools/doc/index.js.map +1 -0
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +1 -1
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/index.d.ts +42 -28
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +61 -199
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +1 -1
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +1 -1
- package/dist/core/tools/read.js.map +1 -1
- package/dist/extensions/core/ask-options.d.ts +10 -0
- package/dist/extensions/core/ask-options.d.ts.map +1 -0
- package/dist/extensions/core/ask-options.js +83 -0
- package/dist/extensions/core/ask-options.js.map +1 -0
- package/dist/extensions/core/config.d.ts +80 -0
- package/dist/extensions/core/config.d.ts.map +1 -0
- package/dist/extensions/core/config.js +110 -0
- package/dist/extensions/core/config.js.map +1 -0
- package/dist/extensions/core/cost.d.ts +11 -0
- package/dist/extensions/core/cost.d.ts.map +1 -0
- package/dist/extensions/core/cost.js +64 -0
- package/dist/extensions/core/cost.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts +16 -179
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +27 -1663
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/extensions/core/loop.d.ts +15 -0
- package/dist/extensions/core/loop.d.ts.map +1 -0
- package/dist/extensions/core/loop.js +235 -0
- package/dist/extensions/core/loop.js.map +1 -0
- package/dist/extensions/core/marketplace.d.ts +17 -0
- package/dist/extensions/core/marketplace.d.ts.map +1 -0
- package/dist/extensions/core/marketplace.js +167 -0
- package/dist/extensions/core/marketplace.js.map +1 -0
- package/dist/extensions/core/mcp-loader.d.ts +26 -0
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -0
- package/dist/extensions/core/mcp-loader.js +391 -0
- package/dist/extensions/core/mcp-loader.js.map +1 -0
- package/dist/extensions/core/modes.d.ts +56 -0
- package/dist/extensions/core/modes.d.ts.map +1 -0
- package/dist/extensions/core/modes.js +268 -0
- package/dist/extensions/core/modes.js.map +1 -0
- package/dist/extensions/core/permission-gate.d.ts +10 -0
- package/dist/extensions/core/permission-gate.d.ts.map +1 -0
- package/dist/extensions/core/permission-gate.js +167 -0
- package/dist/extensions/core/permission-gate.js.map +1 -0
- package/dist/extensions/core/scaffold.d.ts +9 -0
- package/dist/extensions/core/scaffold.d.ts.map +1 -0
- package/dist/extensions/core/scaffold.js +146 -0
- package/dist/extensions/core/scaffold.js.map +1 -0
- package/dist/extensions/core/thinking-escalation.d.ts +18 -0
- package/dist/extensions/core/thinking-escalation.d.ts.map +1 -0
- package/dist/extensions/core/thinking-escalation.js +70 -0
- package/dist/extensions/core/thinking-escalation.js.map +1 -0
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +2 -2
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/command-executor.d.ts +0 -5
- package/dist/modes/interactive/command-executor.d.ts.map +1 -1
- package/dist/modes/interactive/command-executor.js +0 -7
- package/dist/modes/interactive/command-executor.js.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.d.ts +1 -1
- package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.d.ts +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/components/index.d.ts +0 -2
- package/dist/modes/interactive/components/index.d.ts.map +1 -1
- package/dist/modes/interactive/components/index.js +0 -2
- package/dist/modes/interactive/components/index.js.map +1 -1
- package/dist/modes/interactive/extension-chrome.d.ts +56 -0
- package/dist/modes/interactive/extension-chrome.d.ts.map +1 -0
- package/dist/modes/interactive/extension-chrome.js +172 -0
- package/dist/modes/interactive/extension-chrome.js.map +1 -0
- package/dist/modes/interactive/extension-dialogs.d.ts +82 -0
- package/dist/modes/interactive/extension-dialogs.d.ts.map +1 -0
- package/dist/modes/interactive/extension-dialogs.js +259 -0
- package/dist/modes/interactive/extension-dialogs.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +11 -161
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +261 -1584
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/resource-display.d.ts +59 -0
- package/dist/modes/interactive/resource-display.d.ts.map +1 -0
- package/dist/modes/interactive/resource-display.js +499 -0
- package/dist/modes/interactive/resource-display.js.map +1 -0
- package/dist/modes/interactive/startup-checks.d.ts +17 -0
- package/dist/modes/interactive/startup-checks.d.ts.map +1 -0
- package/dist/modes/interactive/startup-checks.js +102 -0
- package/dist/modes/interactive/startup-checks.js.map +1 -0
- package/dist/modes/interactive/team-focus.d.ts +56 -0
- package/dist/modes/interactive/team-focus.d.ts.map +1 -0
- package/dist/modes/interactive/team-focus.js +167 -0
- package/dist/modes/interactive/team-focus.js.map +1 -0
- package/dist/modes/interactive/voice/voice-controller.d.ts +66 -0
- package/dist/modes/interactive/voice/voice-controller.d.ts.map +1 -0
- package/dist/modes/interactive/voice/voice-controller.js +312 -0
- package/dist/modes/interactive/voice/voice-controller.js.map +1 -0
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -0
- package/dist/modes/interactive/voice/voice-panel.js.map +1 -0
- package/dist/modes/interactive/voice/voice-transcribe.d.ts.map +1 -0
- package/dist/modes/interactive/voice/voice-transcribe.js.map +1 -0
- package/dist/modes/rpc/rpc-client.d.ts +1 -2
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +1 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/resources-cli.d.ts.map +1 -1
- package/dist/resources-cli.js +1 -1
- package/dist/resources-cli.js.map +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +1 -1
- package/dist/utils/tools-manager.js.map +1 -1
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-check.js.map +1 -1
- package/docs/project-local-resources.md +1 -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
- package/dist/core/compaction/branch-summarization.d.ts +0 -88
- package/dist/core/compaction/branch-summarization.d.ts.map +0 -1
- package/dist/core/compaction/branch-summarization.js +0 -243
- package/dist/core/compaction/branch-summarization.js.map +0 -1
- package/dist/core/compaction/compaction.d.ts +0 -123
- package/dist/core/compaction/compaction.d.ts.map +0 -1
- package/dist/core/compaction/compaction.js +0 -635
- package/dist/core/compaction/compaction.js.map +0 -1
- package/dist/core/compaction/index.d.ts +0 -7
- package/dist/core/compaction/index.d.ts.map +0 -1
- package/dist/core/compaction/index.js +0 -7
- package/dist/core/compaction/index.js.map +0 -1
- package/dist/core/compaction/utils.d.ts +0 -38
- package/dist/core/compaction/utils.d.ts.map +0 -1
- package/dist/core/compaction/utils.js +0 -156
- package/dist/core/compaction/utils.js.map +0 -1
- package/dist/core/messages.d.ts +0 -124
- package/dist/core/messages.d.ts.map +0 -1
- package/dist/core/messages.js +0 -221
- package/dist/core/messages.js.map +0 -1
- package/dist/core/tools/browser-continue.d.ts.map +0 -1
- package/dist/core/tools/browser-continue.js.map +0 -1
- package/dist/core/tools/browser-run.d.ts.map +0 -1
- package/dist/core/tools/browser-run.js.map +0 -1
- package/dist/core/tools/browsertools-shared.d.ts.map +0 -1
- package/dist/core/tools/browsertools-shared.js.map +0 -1
- package/dist/core/tools/docedit.d.ts.map +0 -1
- package/dist/core/tools/docedit.js.map +0 -1
- package/dist/core/tools/docgrep.d.ts.map +0 -1
- package/dist/core/tools/docgrep.js.map +0 -1
- package/dist/core/tools/docpeek.d.ts.map +0 -1
- package/dist/core/tools/docread.d.ts.map +0 -1
- package/dist/core/tools/docread.js.map +0 -1
- package/dist/core/tools/docscan.d.ts.map +0 -1
- package/dist/core/tools/docscan.js.map +0 -1
- package/dist/core/tools/docwrite.d.ts.map +0 -1
- package/dist/core/tools/docwrite.js.map +0 -1
- package/dist/core/tools/output-compression.d.ts +0 -53
- package/dist/core/tools/output-compression.d.ts.map +0 -1
- package/dist/core/tools/output-compression.js +0 -392
- package/dist/core/tools/output-compression.js.map +0 -1
- package/dist/modes/interactive/components/armin.d.ts +0 -34
- package/dist/modes/interactive/components/armin.d.ts.map +0 -1
- package/dist/modes/interactive/components/armin.js +0 -333
- package/dist/modes/interactive/components/armin.js.map +0 -1
- package/dist/modes/interactive/components/daxnuts.d.ts +0 -23
- package/dist/modes/interactive/components/daxnuts.d.ts.map +0 -1
- package/dist/modes/interactive/components/daxnuts.js +0 -140
- package/dist/modes/interactive/components/daxnuts.js.map +0 -1
- package/dist/modes/interactive/components/voice-panel.d.ts.map +0 -1
- package/dist/modes/interactive/components/voice-panel.js.map +0 -1
- package/dist/modes/interactive/voice-transcribe.d.ts.map +0 -1
- package/dist/modes/interactive/voice-transcribe.js.map +0 -1
- /package/dist/core/tools/{browsertools-shared.d.ts → browser/browsertools-shared.d.ts} +0 -0
- /package/dist/modes/interactive/{components → voice}/voice-panel.d.ts +0 -0
- /package/dist/modes/interactive/{components → voice}/voice-panel.js +0 -0
- /package/dist/modes/interactive/{voice-transcribe.d.ts → voice/voice-transcribe.d.ts} +0 -0
- /package/dist/modes/interactive/{voice-transcribe.js → voice/voice-transcribe.js} +0 -0
|
@@ -1,1671 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hoo-core — HooCode built-in core extension
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { defineTool, isToolCallEventType } from "../../core/extensions/types.js";
|
|
29
|
-
import { summarizeArgs } from "../../core/messages.js";
|
|
30
|
-
import { DEFAULT_MODE, DEFAULT_MODE_PROMPTS as MODE_DEFAULTS } from "../../core/mode-prompts.js";
|
|
31
|
-
import { TaskScheduler } from "../../core/scheduler.js";
|
|
32
|
-
import { subagentSkipMcp } from "../../core/subagent-depth.js";
|
|
33
|
-
import { taskStore } from "../../core/task-store.js";
|
|
34
|
-
import { blockedHostForUrl } from "../../core/tools/webtools-shared.js";
|
|
35
|
-
// Built-in fallback mode prompts (ask / plan / build / debug) now live in
|
|
36
|
-
// `core/mode-prompts.ts` so downstream apps can import them. Imported above as
|
|
37
|
-
// MODE_DEFAULTS to keep the references in this file unchanged.
|
|
38
|
-
// ============================================================================
|
|
39
|
-
// Shared paths
|
|
40
|
-
// ============================================================================
|
|
41
|
-
const HOOCODE_DIR = getHooCodeDir();
|
|
42
|
-
const GLOBAL_CONFIG_PATH = join(HOOCODE_DIR, "hoo-config.json");
|
|
43
|
-
/**
|
|
44
|
-
* Per-session plan file path. Keying on sessionId lets concurrent or resumed
|
|
45
|
-
* plan sessions keep distinct plans instead of clobbering each other.
|
|
46
|
-
*/
|
|
47
|
-
function getPlanPath(cwd, sessionId) {
|
|
48
|
-
return join(cwd, ".hoocode", "plans", `${sessionId}.md`);
|
|
49
|
-
}
|
|
50
|
-
/** Legacy single-file plan location, retained as a read-only fallback for /approve. */
|
|
51
|
-
function getLegacyPlanPath(cwd) {
|
|
52
|
-
return join(cwd, ".hoocode", "plan.md");
|
|
53
|
-
}
|
|
54
|
-
// ============================================================================
|
|
55
|
-
// Config I/O and merging
|
|
56
|
-
// ============================================================================
|
|
57
|
-
function readConfig() {
|
|
58
|
-
try {
|
|
59
|
-
return JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf8"));
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return {};
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function writeConfig(config) {
|
|
66
|
-
if (!existsSync(HOOCODE_DIR))
|
|
67
|
-
mkdirSync(HOOCODE_DIR, { recursive: true });
|
|
68
|
-
writeFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Deep-merges a project-local config on top of the global config.
|
|
72
|
-
*
|
|
73
|
-
* Merge rules:
|
|
74
|
-
* - active_mode: project wins if set
|
|
75
|
-
* - modes[x].auto_allow: union of global + project arrays
|
|
76
|
-
* - modes[x].allowed_write_paths: union of global + project arrays
|
|
77
|
-
* - modes[x].enabled_tools: project wins if set, else falls back to global
|
|
78
|
-
* - mode_paths: project list is prepended so project paths are searched first
|
|
79
|
-
* - thinking_escalation: project wins as a whole if set, else inherit global
|
|
80
|
-
*/
|
|
81
|
-
export function mergeConfigs(global, project) {
|
|
82
|
-
const merged = { ...global };
|
|
83
|
-
if (project.active_mode !== undefined)
|
|
84
|
-
merged.active_mode = project.active_mode;
|
|
85
|
-
// thinking_escalation: project wins as a whole if set, else inherit global.
|
|
86
|
-
if (project.thinking_escalation !== undefined)
|
|
87
|
-
merged.thinking_escalation = project.thinking_escalation;
|
|
88
|
-
if (project.modes) {
|
|
89
|
-
merged.modes = { ...(global.modes ?? {}) };
|
|
90
|
-
for (const [mode, projectCfg] of Object.entries(project.modes)) {
|
|
91
|
-
const globalCfg = global.modes?.[mode] ?? {};
|
|
92
|
-
merged.modes[mode] = {
|
|
93
|
-
...globalCfg,
|
|
94
|
-
...projectCfg,
|
|
95
|
-
// Union both auto_allow lists so project can extend, not just replace
|
|
96
|
-
auto_allow: Array.from(new Set([...(globalCfg.auto_allow ?? []), ...(projectCfg.auto_allow ?? [])])),
|
|
97
|
-
// Union allowed_write_paths so project can extend
|
|
98
|
-
allowed_write_paths: Array.from(new Set([...(globalCfg.allowed_write_paths ?? []), ...(projectCfg.allowed_write_paths ?? [])])),
|
|
99
|
-
// enabled_tools: project wins if set, else falls back to global
|
|
100
|
-
enabled_tools: projectCfg.enabled_tools ?? globalCfg.enabled_tools,
|
|
101
|
-
// denied_tools: union so project can add more denied tools on top of global
|
|
102
|
-
denied_tools: Array.from(new Set([...(globalCfg.denied_tools ?? []), ...(projectCfg.denied_tools ?? [])])),
|
|
103
|
-
// allowed_bash_commands: project wins if set, else falls back to global
|
|
104
|
-
allowed_bash_commands: projectCfg.allowed_bash_commands ?? globalCfg.allowed_bash_commands,
|
|
105
|
-
// denied_bash_commands: union so project can add more denied patterns on top of global
|
|
106
|
-
denied_bash_commands: Array.from(new Set([...(globalCfg.denied_bash_commands ?? []), ...(projectCfg.denied_bash_commands ?? [])])),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (project.mode_paths || global.mode_paths) {
|
|
111
|
-
// Project paths first so they're searched before global paths
|
|
112
|
-
merged.mode_paths = dedupePaths([...(project.mode_paths ?? []), ...(global.mode_paths ?? [])]);
|
|
113
|
-
}
|
|
114
|
-
return merged;
|
|
115
|
-
}
|
|
116
|
-
function dedupePaths(paths) {
|
|
117
|
-
const seen = new Set();
|
|
118
|
-
const out = [];
|
|
119
|
-
for (const p of paths) {
|
|
120
|
-
if (!seen.has(p)) {
|
|
121
|
-
seen.add(p);
|
|
122
|
-
out.push(p);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return out;
|
|
126
|
-
}
|
|
127
|
-
function mergeSearchPaths(...sources) {
|
|
128
|
-
const merged = [];
|
|
129
|
-
for (const source of sources) {
|
|
130
|
-
if (!source)
|
|
131
|
-
continue;
|
|
132
|
-
merged.push(...source);
|
|
133
|
-
}
|
|
134
|
-
return dedupePaths(merged);
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Reads the global config and optionally overlays the project-local config at
|
|
138
|
-
* `./.hoocode/hoo-config.json`. Project values win on all scalar fields; arrays are
|
|
139
|
-
* unioned (see mergeConfigs for full rules).
|
|
140
|
-
*/
|
|
141
|
-
export function readMergedConfig(cwd) {
|
|
142
|
-
const global = readConfig();
|
|
143
|
-
const projectPath = join(cwd, ".hoocode", "hoo-config.json");
|
|
144
|
-
if (!existsSync(projectPath))
|
|
145
|
-
return global;
|
|
146
|
-
try {
|
|
147
|
-
const project = JSON.parse(readFileSync(projectPath, "utf8"));
|
|
148
|
-
return mergeConfigs(global, project);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return global;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// ============================================================================
|
|
155
|
-
// A. Permission Gate
|
|
156
|
-
// ============================================================================
|
|
157
|
-
const GATED_TOOLS = new Set(["bash", "write", "edit", "webfetch", "websearch"]);
|
|
158
|
-
/**
|
|
159
|
-
* Checks if a file path matches any of the allowed patterns.
|
|
160
|
-
* Supports glob patterns with * and exact paths.
|
|
161
|
-
*/
|
|
162
|
-
function matchesAllowedPath(filePath, allowedPatterns) {
|
|
163
|
-
if (allowedPatterns.length === 0)
|
|
164
|
-
return true;
|
|
165
|
-
for (const pattern of allowedPatterns) {
|
|
166
|
-
// Exact match
|
|
167
|
-
if (pattern === filePath)
|
|
168
|
-
return true;
|
|
169
|
-
// Glob pattern matching for *
|
|
170
|
-
if (pattern.includes("*")) {
|
|
171
|
-
const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
|
|
172
|
-
if (regex.test(filePath))
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Tests a bash command string against a regex pattern string.
|
|
180
|
-
* Returns false (no match) if the pattern is an invalid regex.
|
|
181
|
-
*/
|
|
182
|
-
function matchesBashPattern(pattern, command) {
|
|
183
|
-
try {
|
|
184
|
-
return new RegExp(pattern).test(command);
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
function describeTool(event) {
|
|
191
|
-
if (isToolCallEventType("bash", event)) {
|
|
192
|
-
return `$ ${event.input.command.replace(/\s+/g, " ").slice(0, 100)}`;
|
|
193
|
-
}
|
|
194
|
-
if (isToolCallEventType("edit", event)) {
|
|
195
|
-
const p = event.input.file_path ?? "(unknown)";
|
|
196
|
-
return `edit ${p}`;
|
|
197
|
-
}
|
|
198
|
-
if (isToolCallEventType("write", event)) {
|
|
199
|
-
const p = event.input.file_path ?? "(unknown)";
|
|
200
|
-
return `write ${p}`;
|
|
201
|
-
}
|
|
202
|
-
if (event.toolName === "webfetch") {
|
|
203
|
-
const url = event.input.url ?? "(unknown)";
|
|
204
|
-
return `webfetch ${url}`;
|
|
205
|
-
}
|
|
206
|
-
if (event.toolName === "websearch") {
|
|
207
|
-
const query = event.input.query ?? "(unknown)";
|
|
208
|
-
return `websearch "${query}"`;
|
|
209
|
-
}
|
|
210
|
-
return event.toolName;
|
|
211
|
-
}
|
|
212
|
-
export function setupPermissionGate(pi) {
|
|
213
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
214
|
-
// Use the merged config so project-local entries are respected
|
|
215
|
-
const config = readMergedConfig(ctx.cwd);
|
|
216
|
-
const mode = config.active_mode ?? "build";
|
|
217
|
-
const modeCfg = config.modes?.[mode];
|
|
218
|
-
// ── Hard enforcement (always applies, regardless of UI) ───────────────────
|
|
219
|
-
// Explicitly denied tools are blocked unconditionally
|
|
220
|
-
if (modeCfg?.denied_tools?.includes(event.toolName)) {
|
|
221
|
-
return {
|
|
222
|
-
block: true,
|
|
223
|
-
reason: `Tool "${event.toolName}" is denied in mode "${mode}".`,
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
// enabled_tools acts as a strict allowlist: only listed tools may execute
|
|
227
|
-
if (modeCfg?.enabled_tools &&
|
|
228
|
-
modeCfg.enabled_tools.length > 0 &&
|
|
229
|
-
!modeCfg.enabled_tools.includes(event.toolName)) {
|
|
230
|
-
return {
|
|
231
|
-
block: true,
|
|
232
|
-
reason: `Tool "${event.toolName}" is not enabled in mode "${mode}" ` +
|
|
233
|
-
`(enabled: ${modeCfg.enabled_tools.join(", ")}).`,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
// Bash command-level filtering
|
|
237
|
-
if (isToolCallEventType("bash", event)) {
|
|
238
|
-
const command = event.input.command ?? "";
|
|
239
|
-
// denied_bash_commands: block if any pattern matches
|
|
240
|
-
if (modeCfg?.denied_bash_commands?.length) {
|
|
241
|
-
for (const pattern of modeCfg.denied_bash_commands) {
|
|
242
|
-
if (matchesBashPattern(pattern, command)) {
|
|
243
|
-
return {
|
|
244
|
-
block: true,
|
|
245
|
-
reason: `Bash command matches a denied pattern in mode "${mode}": ${pattern}`,
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
// allowed_bash_commands: block unless at least one pattern matches
|
|
251
|
-
if (modeCfg?.allowed_bash_commands?.length) {
|
|
252
|
-
const permitted = modeCfg.allowed_bash_commands.some((p) => matchesBashPattern(p, command));
|
|
253
|
-
if (!permitted) {
|
|
254
|
-
return {
|
|
255
|
-
block: true,
|
|
256
|
-
reason: `Bash command is not permitted in mode "${mode}". ` +
|
|
257
|
-
`Allowed patterns: ${modeCfg.allowed_bash_commands.join(", ")}`,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
// webfetch host policy (.webtoolsignore). Hard enforcement, always applies:
|
|
263
|
-
// a blocked host is denied even in headless runs. SSRF/private-address
|
|
264
|
-
// blocking lives in the webtools binary; this is host allow/deny policy only.
|
|
265
|
-
if (event.toolName === "webfetch") {
|
|
266
|
-
const url = event.input.url ?? "";
|
|
267
|
-
const blockedHost = url ? blockedHostForUrl(ctx.cwd, url) : undefined;
|
|
268
|
-
if (blockedHost) {
|
|
269
|
-
return {
|
|
270
|
-
block: true,
|
|
271
|
-
reason: `Host "${blockedHost}" is blocked by .webtoolsignore policy.`,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
// ── UI-based permission prompting (interactive sessions only) ─────────────
|
|
276
|
-
if (!GATED_TOOLS.has(event.toolName) || !ctx.hasUI)
|
|
277
|
-
return;
|
|
278
|
-
const autoAllow = modeCfg?.auto_allow ?? [];
|
|
279
|
-
// Check allowed_write_paths for write/edit operations
|
|
280
|
-
if ((event.toolName === "write" || event.toolName === "edit") && modeCfg?.allowed_write_paths) {
|
|
281
|
-
const filePath = event.input.file_path ?? "";
|
|
282
|
-
if (!matchesAllowedPath(filePath, modeCfg.allowed_write_paths)) {
|
|
283
|
-
return {
|
|
284
|
-
block: true,
|
|
285
|
-
reason: `Mode "${mode}" only allows writes to: ${modeCfg.allowed_write_paths.join(", ")}. ` +
|
|
286
|
-
`Attempted to ${event.toolName}: ${filePath}. ` +
|
|
287
|
-
`Switch to "/mode build" to modify source files.`,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if (autoAllow.includes(event.toolName))
|
|
292
|
-
return;
|
|
293
|
-
const choice = await ctx.ui.select(`Allow: ${describeTool(event)}`, [
|
|
294
|
-
"Yes (once)",
|
|
295
|
-
"No (block)",
|
|
296
|
-
"Always (add to auto-allow for this mode)",
|
|
297
|
-
]);
|
|
298
|
-
if (!choice || choice.startsWith("No")) {
|
|
299
|
-
return { block: true, reason: "Denied by permission gate" };
|
|
300
|
-
}
|
|
301
|
-
if (choice.startsWith("Always")) {
|
|
302
|
-
// Write "always" choices to the global config only
|
|
303
|
-
const latest = readConfig();
|
|
304
|
-
const currentMode = latest.active_mode ?? "build";
|
|
305
|
-
latest.modes ??= {};
|
|
306
|
-
latest.modes[currentMode] ??= {};
|
|
307
|
-
latest.modes[currentMode].auto_allow = Array.from(new Set([...(latest.modes[currentMode].auto_allow ?? []), event.toolName]));
|
|
308
|
-
writeConfig(latest);
|
|
309
|
-
ctx.ui.notify(`"${event.toolName}" added to auto-allow for mode "${currentMode}"`, "info");
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
const mcpConnections = new Map();
|
|
314
|
-
/**
|
|
315
|
-
* Server configs retained by name so a tool call can transparently reconnect a
|
|
316
|
-
* dropped server (process churn between turns, server exit, a racing teardown)
|
|
317
|
-
* instead of permanently failing with "not connected".
|
|
318
|
-
*/
|
|
319
|
-
const mcpServerConfigs = new Map();
|
|
320
|
-
/** Timeout for the connection handshake (initialize / tools/list). Tool calls
|
|
321
|
-
* themselves are left untimed since MCP tools can be long-running. */
|
|
322
|
-
const MCP_HANDSHAKE_TIMEOUT_MS = 15000;
|
|
323
|
-
function spawnMcpServer(config) {
|
|
324
|
-
const proc = spawn(config.command, config.args ?? [], {
|
|
325
|
-
env: { ...process.env, ...(config.env ?? {}) },
|
|
326
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
327
|
-
});
|
|
328
|
-
let nextId = 1;
|
|
329
|
-
const pending = new Map();
|
|
330
|
-
const rl = createInterface({ input: proc.stdout });
|
|
331
|
-
rl.on("line", (line) => {
|
|
332
|
-
if (!line.trim())
|
|
333
|
-
return;
|
|
334
|
-
try {
|
|
335
|
-
const msg = JSON.parse(line);
|
|
336
|
-
if (msg.id === undefined)
|
|
337
|
-
return;
|
|
338
|
-
const cb = pending.get(msg.id);
|
|
339
|
-
if (!cb)
|
|
340
|
-
return;
|
|
341
|
-
pending.delete(msg.id);
|
|
342
|
-
if (msg.error)
|
|
343
|
-
cb.reject(new Error(msg.error.message));
|
|
344
|
-
else
|
|
345
|
-
cb.resolve(msg.result);
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
// ignore non-JSON server startup output
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
proc.on("exit", () => {
|
|
352
|
-
for (const cb of pending.values())
|
|
353
|
-
cb.reject(new Error(`MCP server "${config.name}" exited unexpectedly`));
|
|
354
|
-
pending.clear();
|
|
355
|
-
mcpConnections.delete(config.name);
|
|
356
|
-
});
|
|
357
|
-
function rpc(method, params, timeoutMs) {
|
|
358
|
-
const id = nextId++;
|
|
359
|
-
return new Promise((resolve, reject) => {
|
|
360
|
-
let timer;
|
|
361
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
362
|
-
timer = setTimeout(() => {
|
|
363
|
-
if (pending.delete(id)) {
|
|
364
|
-
reject(new Error(`MCP server "${config.name}" timed out after ${timeoutMs}ms on ${method}`));
|
|
365
|
-
}
|
|
366
|
-
}, timeoutMs);
|
|
367
|
-
timer.unref?.();
|
|
368
|
-
}
|
|
369
|
-
pending.set(id, {
|
|
370
|
-
resolve: (r) => {
|
|
371
|
-
if (timer)
|
|
372
|
-
clearTimeout(timer);
|
|
373
|
-
resolve(r);
|
|
374
|
-
},
|
|
375
|
-
reject: (e) => {
|
|
376
|
-
if (timer)
|
|
377
|
-
clearTimeout(timer);
|
|
378
|
-
reject(e);
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
function notify(method, params) {
|
|
385
|
-
proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
386
|
-
}
|
|
387
|
-
return {
|
|
388
|
-
rpc,
|
|
389
|
-
notify,
|
|
390
|
-
terminate: () => {
|
|
391
|
-
rl.close();
|
|
392
|
-
proc.kill();
|
|
393
|
-
},
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
async function connectMcpServer(config) {
|
|
397
|
-
mcpConnections.get(config.name)?.terminate();
|
|
398
|
-
const conn = spawnMcpServer(config);
|
|
399
|
-
mcpConnections.set(config.name, conn);
|
|
400
|
-
await conn.rpc("initialize", {
|
|
401
|
-
protocolVersion: "2024-11-05",
|
|
402
|
-
capabilities: { tools: {} },
|
|
403
|
-
clientInfo: { name: "hoocode", version: "1.0.0" },
|
|
404
|
-
}, MCP_HANDSHAKE_TIMEOUT_MS);
|
|
405
|
-
// Per the MCP spec the client must acknowledge a successful initialize with the
|
|
406
|
-
// initialized notification before issuing further requests; strict servers gate
|
|
407
|
-
// tools/call on it.
|
|
408
|
-
conn.notify("notifications/initialized");
|
|
409
|
-
const toolsResult = (await conn.rpc("tools/list", {}, MCP_HANDSHAKE_TIMEOUT_MS));
|
|
410
|
-
return { conn, tools: toolsResult.tools ?? [] };
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Return a live connection for a server, lazily reconnecting from the retained
|
|
414
|
-
* config when the previous connection was torn down. Returns undefined only when
|
|
415
|
-
* no config is known or a fresh connect attempt fails.
|
|
416
|
-
*/
|
|
417
|
-
async function getOrConnectMcp(name) {
|
|
418
|
-
const existing = mcpConnections.get(name);
|
|
419
|
-
if (existing)
|
|
420
|
-
return existing;
|
|
421
|
-
const config = mcpServerConfigs.get(name);
|
|
422
|
-
if (!config)
|
|
423
|
-
return undefined;
|
|
424
|
-
try {
|
|
425
|
-
const { conn } = await connectMcpServer(config);
|
|
426
|
-
return conn;
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
return undefined;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
let mcpExitCleanupInstalled = false;
|
|
433
|
-
/** Kill spawned MCP servers when the host process exits so they don't linger as
|
|
434
|
-
* orphans (their stdin merely goes idle, which doesn't terminate them). */
|
|
435
|
-
function installMcpExitCleanup() {
|
|
436
|
-
if (mcpExitCleanupInstalled)
|
|
437
|
-
return;
|
|
438
|
-
mcpExitCleanupInstalled = true;
|
|
439
|
-
process.once("exit", () => {
|
|
440
|
-
for (const conn of mcpConnections.values()) {
|
|
441
|
-
try {
|
|
442
|
-
conn.terminate();
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
// best-effort cleanup
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
mcpConnections.clear();
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
function buildMcpSchema(tool) {
|
|
452
|
-
const props = tool.inputSchema?.properties ?? {};
|
|
453
|
-
const required = new Set(tool.inputSchema?.required ?? []);
|
|
454
|
-
const shape = {};
|
|
455
|
-
for (const [key, prop] of Object.entries(props)) {
|
|
456
|
-
let field;
|
|
457
|
-
switch (prop.type) {
|
|
458
|
-
case "number":
|
|
459
|
-
case "integer":
|
|
460
|
-
field = Type.Number({ description: prop.description });
|
|
461
|
-
break;
|
|
462
|
-
case "boolean":
|
|
463
|
-
field = Type.Boolean({ description: prop.description });
|
|
464
|
-
break;
|
|
465
|
-
default:
|
|
466
|
-
field = Type.String({ description: prop.description });
|
|
467
|
-
}
|
|
468
|
-
shape[key] = required.has(key) ? field : Type.Optional(field);
|
|
469
|
-
}
|
|
470
|
-
return Type.Object(shape);
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)
|
|
474
|
-
* into hoocode's McpServerConfig format.
|
|
475
|
-
*/
|
|
476
|
-
function parseStandardMcpConfig(config, _source) {
|
|
477
|
-
if (!config.mcpServers)
|
|
478
|
-
return [];
|
|
479
|
-
const servers = [];
|
|
480
|
-
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
481
|
-
servers.push({
|
|
482
|
-
name,
|
|
483
|
-
command: serverConfig.command,
|
|
484
|
-
args: serverConfig.args,
|
|
485
|
-
env: serverConfig.env,
|
|
486
|
-
background: serverConfig.background,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
return servers;
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* Load MCP servers from a standard mcp.json file.
|
|
493
|
-
* Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.
|
|
494
|
-
*/
|
|
495
|
-
function loadStandardMcpFile(filePath) {
|
|
496
|
-
if (!existsSync(filePath))
|
|
497
|
-
return [];
|
|
498
|
-
try {
|
|
499
|
-
const content = readFileSync(filePath, "utf8");
|
|
500
|
-
const config = JSON.parse(content);
|
|
501
|
-
return parseStandardMcpConfig(config, filePath);
|
|
502
|
-
}
|
|
503
|
-
catch {
|
|
504
|
-
return [];
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
export function setupMcpLoader(pi) {
|
|
508
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
509
|
-
// A spawned subagent whose tool allowlist has no MCP tools is told by its
|
|
510
|
-
// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).
|
|
511
|
-
// Each connect is a ~15s-timeout handshake; doing it for a subagent that can
|
|
512
|
-
// never call the tools is pure startup latency.
|
|
513
|
-
if (subagentSkipMcp())
|
|
514
|
-
return;
|
|
515
|
-
installMcpExitCleanup();
|
|
516
|
-
const allServerConfigs = [];
|
|
517
|
-
const seenNames = new Set();
|
|
518
|
-
// 1. Load from standard mcp.json locations
|
|
519
|
-
// User-level: ~/.agents/mcp.json
|
|
520
|
-
const userAgentsConfig = loadStandardMcpFile(join(homedir(), ".agents", "mcp.json"));
|
|
521
|
-
for (const config of userAgentsConfig) {
|
|
522
|
-
if (!seenNames.has(config.name)) {
|
|
523
|
-
seenNames.add(config.name);
|
|
524
|
-
allServerConfigs.push(config);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
// Project-level: ./.agents/mcp.json
|
|
528
|
-
const projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, ".agents", "mcp.json"));
|
|
529
|
-
for (const config of projectAgentsConfig) {
|
|
530
|
-
if (!seenNames.has(config.name)) {
|
|
531
|
-
seenNames.add(config.name);
|
|
532
|
-
allServerConfigs.push(config);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
// Claude Desktop: ~/.config/claude/mcp.json
|
|
536
|
-
const claudeDesktopConfig = loadStandardMcpFile(join(homedir(), ".config", "claude", "mcp.json"));
|
|
537
|
-
for (const config of claudeDesktopConfig) {
|
|
538
|
-
if (!seenNames.has(config.name)) {
|
|
539
|
-
seenNames.add(config.name);
|
|
540
|
-
allServerConfigs.push(config);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
// 2. Load from hoocode's per-server format (existing behavior)
|
|
544
|
-
const searchDirs = [join(HOOCODE_DIR, "mcp-servers"), join(ctx.cwd, ".hoocode", "mcp-servers")];
|
|
545
|
-
for (const dir of searchDirs) {
|
|
546
|
-
if (!existsSync(dir))
|
|
547
|
-
continue;
|
|
548
|
-
let files;
|
|
549
|
-
try {
|
|
550
|
-
files = (await readdir(dir)).filter((f) => f.endsWith(".json"));
|
|
551
|
-
}
|
|
552
|
-
catch {
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
for (const file of files) {
|
|
556
|
-
const cfgPath = join(dir, file);
|
|
557
|
-
let serverConfig;
|
|
558
|
-
try {
|
|
559
|
-
serverConfig = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
560
|
-
if (!serverConfig.name || !serverConfig.command) {
|
|
561
|
-
ctx.ui.notify(`MCP: config "${file}" is missing required "name" or "command"`, "warning");
|
|
562
|
-
continue;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
catch (err) {
|
|
566
|
-
ctx.ui.notify(`MCP: failed to parse "${file}": ${String(err)}`, "error");
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
// Skip if already loaded from standard config
|
|
570
|
-
if (seenNames.has(serverConfig.name))
|
|
571
|
-
continue;
|
|
572
|
-
seenNames.add(serverConfig.name);
|
|
573
|
-
allServerConfigs.push(serverConfig);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// 2b. Load from plugins/extensions that registered MCP servers during load.
|
|
577
|
-
for (const entry of getExtensionMcpServers()) {
|
|
578
|
-
for (const serverConfig of parseStandardMcpConfig({ mcpServers: entry.mcpServers }, `plugin:${entry.source}`)) {
|
|
579
|
-
if (seenNames.has(serverConfig.name))
|
|
580
|
-
continue;
|
|
581
|
-
seenNames.add(serverConfig.name);
|
|
582
|
-
allServerConfigs.push(serverConfig);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
// 3. Connect to all servers and register tools
|
|
586
|
-
for (const serverConfig of allServerConfigs) {
|
|
587
|
-
// Retain the config so a tool call can lazily reconnect a dropped server.
|
|
588
|
-
mcpServerConfigs.set(serverConfig.name, serverConfig);
|
|
589
|
-
try {
|
|
590
|
-
const { tools } = await connectMcpServer(serverConfig);
|
|
591
|
-
for (const tool of tools) {
|
|
592
|
-
const toolName = `mcp_${serverConfig.name}_${tool.name}`;
|
|
593
|
-
const schema = buildMcpSchema(tool);
|
|
594
|
-
const capturedServer = serverConfig.name;
|
|
595
|
-
const capturedTool = tool.name;
|
|
596
|
-
// MCP tools default to background mode since they are external processes with potential high latency
|
|
597
|
-
const isBackground = serverConfig.background !== false;
|
|
598
|
-
pi.registerTool({
|
|
599
|
-
name: toolName,
|
|
600
|
-
label: `[MCP] ${serverConfig.name} › ${tool.name}`,
|
|
601
|
-
description: tool.description,
|
|
602
|
-
parameters: schema,
|
|
603
|
-
background: isBackground,
|
|
604
|
-
// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —
|
|
605
|
-
// parallel to the subagent `Task [type] <desc>` line. Without this the
|
|
606
|
-
// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.
|
|
607
|
-
// The args summary reuses the same helper as the background start/finish
|
|
608
|
-
// messages so the chat title stays in sync with them.
|
|
609
|
-
renderCall(args, theme) {
|
|
610
|
-
const summary = summarizeArgs((args ?? {}));
|
|
611
|
-
const text = theme.fg("toolTitle", theme.bold("MCP ")) +
|
|
612
|
-
theme.fg("accent", `[${capturedServer} › ${capturedTool}]`) +
|
|
613
|
-
(summary ? theme.fg("dim", ` ${summary}`) : "");
|
|
614
|
-
return new Text(text, 0, 0);
|
|
615
|
-
},
|
|
616
|
-
async execute(_toolCallId, params, signal, _onUpdate) {
|
|
617
|
-
// Background MCP tools get a task store entry so they appear in the task pane.
|
|
618
|
-
// Foreground tools skip this (their result is awaited inline). The server
|
|
619
|
-
// name rides in subagentMode and becomes the row's `[server]` origin tag;
|
|
620
|
-
// the title carries just the tool.
|
|
621
|
-
const task = isBackground
|
|
622
|
-
? taskStore.create(capturedTool, { source: "mcp", subagentMode: capturedServer })
|
|
623
|
-
: undefined;
|
|
624
|
-
if (task)
|
|
625
|
-
taskStore.update(task.id, { status: "in_progress" });
|
|
626
|
-
// Lazily (re)connect: a dropped connection (server exit, process churn
|
|
627
|
-
// between turns, a racing teardown) should transparently reconnect from
|
|
628
|
-
// the retained config rather than permanently fail with "not connected".
|
|
629
|
-
const activeConn = await getOrConnectMcp(capturedServer);
|
|
630
|
-
if (!activeConn) {
|
|
631
|
-
if (task)
|
|
632
|
-
taskStore.update(task.id, { status: "failed" });
|
|
633
|
-
return {
|
|
634
|
-
content: [
|
|
635
|
-
{
|
|
636
|
-
type: "text",
|
|
637
|
-
text: `MCP server "${capturedServer}" is not connected (reconnect attempt failed)`,
|
|
638
|
-
},
|
|
639
|
-
],
|
|
640
|
-
details: undefined,
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
try {
|
|
644
|
-
const abortPromise = new Promise((_, reject) => {
|
|
645
|
-
signal.addEventListener("abort", () => reject(new Error("Aborted")));
|
|
646
|
-
});
|
|
647
|
-
const result = await Promise.race([
|
|
648
|
-
activeConn.rpc("tools/call", {
|
|
649
|
-
name: capturedTool,
|
|
650
|
-
arguments: params,
|
|
651
|
-
}),
|
|
652
|
-
abortPromise,
|
|
653
|
-
]);
|
|
654
|
-
if (task)
|
|
655
|
-
taskStore.update(task.id, { status: "done" });
|
|
656
|
-
return {
|
|
657
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
658
|
-
details: undefined,
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
catch (error) {
|
|
662
|
-
if (task)
|
|
663
|
-
taskStore.update(task.id, { status: "failed" });
|
|
664
|
-
throw error;
|
|
665
|
-
}
|
|
666
|
-
},
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
const bgMode = serverConfig.background !== false ? "background" : "foreground";
|
|
670
|
-
ctx.ui.notify(`MCP: connected "${serverConfig.name}" (${tools.length} tool${tools.length === 1 ? "" : "s"}, ${bgMode})`, "info");
|
|
671
|
-
}
|
|
672
|
-
catch (err) {
|
|
673
|
-
ctx.ui.notify(`MCP: failed to connect "${serverConfig.name}": ${String(err)}`, "error");
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
// ============================================================================
|
|
679
|
-
// C. Mode System
|
|
680
|
-
// ============================================================================
|
|
681
|
-
function tryReadFile(path) {
|
|
682
|
-
if (!existsSync(path))
|
|
683
|
-
return undefined;
|
|
684
|
-
try {
|
|
685
|
-
const text = readFileSync(path, "utf8").trim();
|
|
686
|
-
return text || undefined;
|
|
687
|
-
}
|
|
688
|
-
catch {
|
|
689
|
-
return undefined;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Walks search dirs in precedence order and returns the first existing
|
|
694
|
-
* `modes/{name}/system.md` content. Order: project → user → externalDirs.
|
|
695
|
-
*/
|
|
696
|
-
function resolveModeFile(name, cwd, externalDirs) {
|
|
697
|
-
const candidates = [
|
|
698
|
-
join(cwd, ".hoocode", "modes", name, "system.md"),
|
|
699
|
-
join(HOOCODE_DIR, "modes", name, "system.md"),
|
|
700
|
-
...externalDirs.map((dir) => join(dir, name, "system.md")),
|
|
701
|
-
];
|
|
702
|
-
for (const candidate of candidates) {
|
|
703
|
-
const content = tryReadFile(candidate);
|
|
704
|
-
if (content !== undefined)
|
|
705
|
-
return content;
|
|
706
|
-
}
|
|
707
|
-
return undefined;
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* Returns the system prompt for the active mode.
|
|
711
|
-
*
|
|
712
|
-
* Search order (first hit wins):
|
|
713
|
-
* - `./.hoocode/modes/{mode}/system.md`
|
|
714
|
-
* - `~/.hoocode/modes/{mode}/system.md`
|
|
715
|
-
* - each of `externalDirs` in declared order (config + CLI + extension contributions)
|
|
716
|
-
* - built-in MODE_DEFAULTS for the four known modes
|
|
717
|
-
*/
|
|
718
|
-
export function buildSystemPrompt(mode, cwd, options) {
|
|
719
|
-
const modePaths = options?.modePaths ?? [];
|
|
720
|
-
return resolveModeFile(mode, cwd, modePaths) ?? MODE_DEFAULTS[mode];
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Parses `.hoocode/plan.md` into named sections.
|
|
724
|
-
*
|
|
725
|
-
* Recognises both ATX headings (`## Goal`) and bold labels (`**Goal**`).
|
|
726
|
-
* Section names matched (case-insensitive): Goal, Files to modify, New files,
|
|
727
|
-
* Tests, Verification.
|
|
728
|
-
*/
|
|
729
|
-
export function parsePlanSections(planContent) {
|
|
730
|
-
const result = { raw: planContent };
|
|
731
|
-
// Match `## Heading text` or `**Heading text**` followed by content until
|
|
732
|
-
// the next heading of the same style.
|
|
733
|
-
const sectionPattern = /^(?:#{1,3}\s+(.+?)|(?:\*\*(.+?)\*\*))\s*\n([\s\S]*?)(?=(?:^#{1,3}\s+|\*\*[^*\n]+\*\*\s*\n)|$)/gm;
|
|
734
|
-
for (const match of planContent.matchAll(sectionPattern)) {
|
|
735
|
-
const heading = (match[1] ?? match[2] ?? "").toLowerCase().trim();
|
|
736
|
-
const content = match[3].trim();
|
|
737
|
-
if (!content)
|
|
738
|
-
continue;
|
|
739
|
-
if (/^goal/.test(heading)) {
|
|
740
|
-
result.goal = content;
|
|
741
|
-
}
|
|
742
|
-
else if (/files?\s+to\s+modif|^modif/.test(heading)) {
|
|
743
|
-
result.filesToModify = content;
|
|
744
|
-
}
|
|
745
|
-
else if (/new\s+files?/.test(heading)) {
|
|
746
|
-
result.newFiles = content;
|
|
747
|
-
}
|
|
748
|
-
else if (/^tests?/.test(heading)) {
|
|
749
|
-
result.tests = content;
|
|
750
|
-
}
|
|
751
|
-
else if (/^verif/.test(heading)) {
|
|
752
|
-
result.verification = content;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
return result;
|
|
756
|
-
}
|
|
757
|
-
/**
|
|
758
|
-
* Builds the user message sent to the agent when `/approve` is run.
|
|
759
|
-
*
|
|
760
|
-
* If the plan has recognisable sections, each is presented as a numbered step
|
|
761
|
-
* so the agent works through them sequentially. Otherwise the raw plan is used.
|
|
762
|
-
*
|
|
763
|
-
* Execution order:
|
|
764
|
-
* 1. Modify existing files
|
|
765
|
-
* 2. Create new files
|
|
766
|
-
* 3. Update / add tests
|
|
767
|
-
* 4. Run verification commands
|
|
768
|
-
*/
|
|
769
|
-
export function buildApproveMessage(sections) {
|
|
770
|
-
const steps = [];
|
|
771
|
-
if (sections.goal) {
|
|
772
|
-
steps.push(`**Goal:** ${sections.goal}`);
|
|
773
|
-
}
|
|
774
|
-
if (sections.filesToModify) {
|
|
775
|
-
steps.push(`**Step 1 — Modify existing files:**\n${sections.filesToModify}`);
|
|
776
|
-
}
|
|
777
|
-
if (sections.newFiles) {
|
|
778
|
-
steps.push(`**Step 2 — Create new files:**\n${sections.newFiles}`);
|
|
779
|
-
}
|
|
780
|
-
if (sections.tests) {
|
|
781
|
-
steps.push(`**Step 3 — Update tests:**\n${sections.tests}`);
|
|
782
|
-
}
|
|
783
|
-
if (sections.verification) {
|
|
784
|
-
steps.push(`**Step 4 — Verify:**\n${sections.verification}`);
|
|
785
|
-
}
|
|
786
|
-
if (steps.length === 0) {
|
|
787
|
-
return `Execute the following plan:\n\n${sections.raw}`;
|
|
788
|
-
}
|
|
789
|
-
return `Execute this plan step by step. Complete each step fully before moving to the next.\n\n${steps.join("\n\n")}`;
|
|
790
|
-
}
|
|
791
|
-
// ============================================================================
|
|
792
|
-
// C. setupMode
|
|
793
|
-
// ============================================================================
|
|
794
|
-
export function setupMode(pi) {
|
|
795
|
-
let cachedMode = DEFAULT_MODE;
|
|
796
|
-
let cachedSystemPrompt;
|
|
797
|
-
let cachedPlanPath;
|
|
798
|
-
// ── session_start ─────────────────────────────────────────────────────────
|
|
799
|
-
// Config resolution order:
|
|
800
|
-
// 1. Read global config (~/.hoocode/hoo-config.json)
|
|
801
|
-
// 2. Read project config (./.hoocode/hoo-config.json) if present
|
|
802
|
-
// 3. Merge — project scalars win; arrays are unioned
|
|
803
|
-
// 4. Re-resolve active_mode from the merged result
|
|
804
|
-
pi.on("session_start", (_event, ctx) => {
|
|
805
|
-
// Steps 1–3: merge global + project configs
|
|
806
|
-
const config = readMergedConfig(ctx.cwd);
|
|
807
|
-
// Step 4: resolve mode from the merged config
|
|
808
|
-
cachedMode = config.active_mode ?? DEFAULT_MODE;
|
|
809
|
-
// External search dirs come from two channels:
|
|
810
|
-
// - HooConfig.mode_paths (config-declared)
|
|
811
|
-
// - pi.addModeSearchPath (CLI flags + extension contributions)
|
|
812
|
-
const modePaths = mergeSearchPaths(config.mode_paths, pi.getModeSearchPaths());
|
|
813
|
-
const rawSystemPrompt = buildSystemPrompt(cachedMode, ctx.cwd, { modePaths });
|
|
814
|
-
// Per-session plan path so concurrent sessions don't overwrite each other.
|
|
815
|
-
// The `{{PLAN_PATH}}` token in plan-mode templates is substituted here.
|
|
816
|
-
cachedPlanPath = getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
817
|
-
const relPlanPath = relative(ctx.cwd, cachedPlanPath) || cachedPlanPath;
|
|
818
|
-
cachedSystemPrompt = rawSystemPrompt?.replace(/\{\{PLAN_PATH\}\}/g, relPlanPath);
|
|
819
|
-
// Update footer with active mode
|
|
820
|
-
if (ctx.hasUI) {
|
|
821
|
-
ctx.ui.setMode(cachedMode);
|
|
822
|
-
}
|
|
823
|
-
// Apply tool filter from mode enabled_tools
|
|
824
|
-
const modeCfg = config.modes?.[cachedMode];
|
|
825
|
-
if (modeCfg?.enabled_tools && modeCfg.enabled_tools.length > 0) {
|
|
826
|
-
pi.setActiveTools(modeCfg.enabled_tools);
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
// ── before_agent_start ────────────────────────────────────────────────────
|
|
830
|
-
pi.on("before_agent_start", (event) => {
|
|
831
|
-
if (!cachedSystemPrompt)
|
|
832
|
-
return;
|
|
833
|
-
return {
|
|
834
|
-
systemPrompt: `${event.systemPrompt}\n\n<!-- hoo-core: mode=${cachedMode} -->\n${cachedSystemPrompt}`,
|
|
835
|
-
};
|
|
836
|
-
});
|
|
837
|
-
// ── /mode command ─────────────────────────────────────────────────────────
|
|
838
|
-
const KNOWN_MODES = ["ask", "plan", "build", "debug"];
|
|
839
|
-
pi.registerCommand("mode", {
|
|
840
|
-
description: "Switch active mode. Usage: /mode <ask|plan|build|debug>",
|
|
841
|
-
getArgumentCompletions: (prefix) => KNOWN_MODES.filter((m) => m.startsWith(prefix)).map((m) => ({ value: m, label: m })),
|
|
842
|
-
handler: async (args, ctx) => {
|
|
843
|
-
const name = args.trim();
|
|
844
|
-
if (!name) {
|
|
845
|
-
ctx.ui.notify(`Active mode: ${cachedMode}`, "info");
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
const config = readConfig();
|
|
849
|
-
config.active_mode = name === DEFAULT_MODE ? undefined : name;
|
|
850
|
-
writeConfig(config);
|
|
851
|
-
ctx.ui.notify(`Mode set to "${name}" — reloading…`, "info");
|
|
852
|
-
await ctx.reload();
|
|
853
|
-
},
|
|
854
|
-
});
|
|
855
|
-
// ── /plan command (shorthand for /mode plan) ──────────────────────────────
|
|
856
|
-
pi.registerCommand("plan", {
|
|
857
|
-
description: "Switch to plan mode. Shorthand for /mode plan.",
|
|
858
|
-
getArgumentCompletions: () => [],
|
|
859
|
-
handler: async (_args, ctx) => {
|
|
860
|
-
const config = readConfig();
|
|
861
|
-
config.active_mode = "plan";
|
|
862
|
-
writeConfig(config);
|
|
863
|
-
ctx.ui.notify(`Mode set to "plan" — reloading…`, "info");
|
|
864
|
-
await ctx.reload();
|
|
865
|
-
},
|
|
866
|
-
});
|
|
867
|
-
// ── /approve command ──────────────────────────────────────────────────────
|
|
868
|
-
// Reads .hoocode/plan.md, parses it into named sections (Goal, Files to
|
|
869
|
-
// modify, New files, Tests, Verification), switches to build mode, then
|
|
870
|
-
// injects a step-by-step execution message into the new session.
|
|
871
|
-
pi.registerCommand("approve", {
|
|
872
|
-
description: "Approve the current plan and switch to build mode to execute it.",
|
|
873
|
-
getArgumentCompletions: () => [],
|
|
874
|
-
handler: async (_args, ctx) => {
|
|
875
|
-
if (cachedMode !== "plan") {
|
|
876
|
-
ctx.ui.notify(`/approve is only available in plan mode (current mode: "${cachedMode}")`, "warning");
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
// Prefer the per-session plan file, fall back to the legacy single file.
|
|
880
|
-
const sessionPlanPath = cachedPlanPath ?? getPlanPath(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
881
|
-
const candidatePaths = [sessionPlanPath, getLegacyPlanPath(ctx.cwd)];
|
|
882
|
-
let approveMessage;
|
|
883
|
-
for (const planPath of candidatePaths) {
|
|
884
|
-
if (!existsSync(planPath))
|
|
885
|
-
continue;
|
|
886
|
-
try {
|
|
887
|
-
const raw = readFileSync(planPath, "utf8").trim();
|
|
888
|
-
if (raw) {
|
|
889
|
-
const sections = parsePlanSections(raw);
|
|
890
|
-
approveMessage = buildApproveMessage(sections);
|
|
891
|
-
break;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
catch {
|
|
895
|
-
ctx.ui.notify(`Could not read ${relative(ctx.cwd, planPath) || planPath}`, "error");
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
// Switch global config to build mode
|
|
900
|
-
const config = readConfig();
|
|
901
|
-
config.active_mode = "build";
|
|
902
|
-
writeConfig(config);
|
|
903
|
-
if (approveMessage) {
|
|
904
|
-
// Open a new build-mode session and deliver the parsed plan as the
|
|
905
|
-
// first user message so the agent starts executing immediately
|
|
906
|
-
await ctx.newSession({
|
|
907
|
-
withSession: async (replacedCtx) => {
|
|
908
|
-
await replacedCtx.sendUserMessage(approveMessage, { deliverAs: "followUp" });
|
|
909
|
-
},
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
else {
|
|
913
|
-
const relPlan = relative(ctx.cwd, sessionPlanPath) || sessionPlanPath;
|
|
914
|
-
ctx.ui.notify(`Switched to build mode. No ${relPlan} found — describe what to build.`, "info");
|
|
915
|
-
await ctx.reload();
|
|
916
|
-
}
|
|
917
|
-
},
|
|
918
|
-
});
|
|
919
|
-
// ── /cost command ─────────────────────────────────────────────────────────
|
|
920
|
-
// Walks every assistant message in the current session and sums tokens + cost,
|
|
921
|
-
// then prints a session total followed by a per-model breakdown.
|
|
922
|
-
// Per-tool attribution is intentionally not shown — tokens aren't tracked
|
|
923
|
-
// per-tool, and any heuristic would be misleading.
|
|
924
|
-
pi.registerCommand("cost", {
|
|
925
|
-
description: "Show session token and cost totals, broken down by model.",
|
|
926
|
-
getArgumentCompletions: () => [],
|
|
927
|
-
handler: async (_args, ctx) => {
|
|
928
|
-
const empty = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
929
|
-
const total = empty();
|
|
930
|
-
const perModel = new Map();
|
|
931
|
-
let assistantTurns = 0;
|
|
932
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
933
|
-
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
934
|
-
continue;
|
|
935
|
-
const u = entry.message.usage;
|
|
936
|
-
if (!u)
|
|
937
|
-
continue;
|
|
938
|
-
assistantTurns++;
|
|
939
|
-
total.input += u.input;
|
|
940
|
-
total.output += u.output;
|
|
941
|
-
total.cacheRead += u.cacheRead;
|
|
942
|
-
total.cacheWrite += u.cacheWrite;
|
|
943
|
-
total.cost += u.cost.total;
|
|
944
|
-
const key = `${entry.message.provider}/${entry.message.model}`;
|
|
945
|
-
const t = perModel.get(key) ?? empty();
|
|
946
|
-
t.input += u.input;
|
|
947
|
-
t.output += u.output;
|
|
948
|
-
t.cacheRead += u.cacheRead;
|
|
949
|
-
t.cacheWrite += u.cacheWrite;
|
|
950
|
-
t.cost += u.cost.total;
|
|
951
|
-
perModel.set(key, t);
|
|
952
|
-
}
|
|
953
|
-
if (assistantTurns === 0) {
|
|
954
|
-
ctx.ui.notify("No assistant turns yet — nothing to cost.", "info");
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
const fmt = (n) => n.toLocaleString();
|
|
958
|
-
const fmtCost = (n) => `$${n.toFixed(4)}`;
|
|
959
|
-
const lines = [];
|
|
960
|
-
lines.push(`Session totals (${assistantTurns} assistant turn${assistantTurns === 1 ? "" : "s"})`);
|
|
961
|
-
lines.push(` Input ${fmt(total.input)}`);
|
|
962
|
-
lines.push(` Output ${fmt(total.output)}`);
|
|
963
|
-
lines.push(` Cache read ${fmt(total.cacheRead)}`);
|
|
964
|
-
lines.push(` Cache write ${fmt(total.cacheWrite)}`);
|
|
965
|
-
lines.push(` Cost ${fmtCost(total.cost)}`);
|
|
966
|
-
if (perModel.size > 1) {
|
|
967
|
-
lines.push("");
|
|
968
|
-
lines.push("By model:");
|
|
969
|
-
const sorted = [...perModel.entries()].sort((a, b) => b[1].cost - a[1].cost);
|
|
970
|
-
for (const [key, t] of sorted) {
|
|
971
|
-
lines.push(` ${key}: ${fmt(t.input)} in / ${fmt(t.output)} out ${fmtCost(t.cost)}`);
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
975
|
-
},
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
// ============================================================================
|
|
979
|
-
// Scaffold commands — /new-skill, /new-agent, and /new-command
|
|
980
|
-
// ============================================================================
|
|
981
|
-
/** Validates a resource name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */
|
|
982
|
-
function validateResourceName(name) {
|
|
983
|
-
if (!name)
|
|
984
|
-
return "name is required";
|
|
985
|
-
if (!/^[a-z0-9-]+$/.test(name))
|
|
986
|
-
return "name must be lowercase a-z, 0-9, and hyphens only";
|
|
987
|
-
if (name.startsWith("-") || name.endsWith("-"))
|
|
988
|
-
return "name must not start or end with a hyphen";
|
|
989
|
-
if (name.includes("--"))
|
|
990
|
-
return "name must not contain consecutive hyphens";
|
|
991
|
-
return null;
|
|
992
|
-
}
|
|
993
|
-
function setupScaffold(pi) {
|
|
994
|
-
// ── /new-skill <name> ─────────────────────────────────────────────────────
|
|
995
|
-
// Creates .hoocode/skills/<name>/SKILL.md with a valid Agent Skills frontmatter
|
|
996
|
-
// template so the file is ready to edit and will be picked up on next reload.
|
|
997
|
-
pi.registerCommand("new-skill", {
|
|
998
|
-
description: "Scaffold a new skill. Usage: /new-skill <name>",
|
|
999
|
-
getArgumentCompletions: () => [],
|
|
1000
|
-
handler: async (args, ctx) => {
|
|
1001
|
-
const name = args.trim();
|
|
1002
|
-
const error = validateResourceName(name);
|
|
1003
|
-
if (error) {
|
|
1004
|
-
ctx.ui.notify(`/new-skill: ${error}. Usage: /new-skill <name>`, "warning");
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
const skillDir = join(ctx.cwd, ".hoocode", "skills", name);
|
|
1008
|
-
const skillFile = join(skillDir, "SKILL.md");
|
|
1009
|
-
if (existsSync(skillFile)) {
|
|
1010
|
-
ctx.ui.notify(`/new-skill: ${skillFile} already exists`, "warning");
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
mkdirSync(skillDir, { recursive: true });
|
|
1014
|
-
writeFileSync(skillFile, [
|
|
1015
|
-
"---",
|
|
1016
|
-
`name: ${name}`,
|
|
1017
|
-
"description: |",
|
|
1018
|
-
" TODO: describe when to use this skill — one clear sentence per bullet.",
|
|
1019
|
-
" The model reads this to decide whether to load the skill.",
|
|
1020
|
-
"allowed-tools: read, bash",
|
|
1021
|
-
"---",
|
|
1022
|
-
"",
|
|
1023
|
-
`# ${name}`,
|
|
1024
|
-
"",
|
|
1025
|
-
"TODO: write the skill instructions here.",
|
|
1026
|
-
"",
|
|
1027
|
-
"When relative paths appear below, they are resolved from this file's directory.",
|
|
1028
|
-
"",
|
|
1029
|
-
].join("\n"), "utf8");
|
|
1030
|
-
ctx.ui.notify(`Skill created: ${join(".hoocode", "skills", name, "SKILL.md")}\nEdit the file, then run /reload to activate it.`, "info");
|
|
1031
|
-
},
|
|
1032
|
-
});
|
|
1033
|
-
// ── /new-agent <name> ─────────────────────────────────────────────────────
|
|
1034
|
-
// Creates .hoocode/agents/<name>.md following the Claude Code subagent standard
|
|
1035
|
-
// (name, description, tools comma-string, model alias, optional background).
|
|
1036
|
-
pi.registerCommand("new-agent", {
|
|
1037
|
-
description: "Scaffold a new subagent. Usage: /new-agent <name>",
|
|
1038
|
-
getArgumentCompletions: () => [],
|
|
1039
|
-
handler: async (args, ctx) => {
|
|
1040
|
-
const name = args.trim();
|
|
1041
|
-
const error = validateResourceName(name);
|
|
1042
|
-
if (error) {
|
|
1043
|
-
ctx.ui.notify(`/new-agent: ${error}. Usage: /new-agent <name>`, "warning");
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
const agentsDir = join(ctx.cwd, ".hoocode", "agents");
|
|
1047
|
-
const agentFile = join(agentsDir, `${name}.md`);
|
|
1048
|
-
if (existsSync(agentFile)) {
|
|
1049
|
-
ctx.ui.notify(`/new-agent: ${agentFile} already exists`, "warning");
|
|
1050
|
-
return;
|
|
1051
|
-
}
|
|
1052
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
1053
|
-
writeFileSync(agentFile, [
|
|
1054
|
-
"---",
|
|
1055
|
-
`name: ${name}`,
|
|
1056
|
-
"description: |",
|
|
1057
|
-
" Use this subagent ONLY when:",
|
|
1058
|
-
" - TODO: describe the task(s) to delegate here",
|
|
1059
|
-
"",
|
|
1060
|
-
" DO NOT use for:",
|
|
1061
|
-
" - TODO: describe what this agent should NOT handle",
|
|
1062
|
-
"tools: read, bash",
|
|
1063
|
-
"model: sonnet",
|
|
1064
|
-
"---",
|
|
1065
|
-
`You are a ${name} subagent running inside hoocode.`,
|
|
1066
|
-
"You run in an isolated context and cannot see the parent conversation.",
|
|
1067
|
-
"",
|
|
1068
|
-
"TODO: write the system prompt here.",
|
|
1069
|
-
"",
|
|
1070
|
-
"Your final message must contain ONLY your answer — it is the only output",
|
|
1071
|
-
"the caller receives. Do not include intermediate reasoning or tool logs.",
|
|
1072
|
-
"",
|
|
1073
|
-
].join("\n"), "utf8");
|
|
1074
|
-
ctx.ui.notify(`Agent created: ${join(".hoocode", "agents", `${name}.md`)}\nEdit the file, then run /reload to activate it.`, "info");
|
|
1075
|
-
},
|
|
1076
|
-
});
|
|
1077
|
-
// ── /new-command <name> ───────────────────────────────────────────────────
|
|
1078
|
-
// Creates .hoocode/commands/<name>.md with a slash-command prompt-template
|
|
1079
|
-
// frontmatter (name, description, argument-hint) so it is ready to edit and
|
|
1080
|
-
// picked up on next reload. Body supports $1, $@, $ARGUMENTS placeholders.
|
|
1081
|
-
pi.registerCommand("new-command", {
|
|
1082
|
-
description: "Scaffold a new slash command. Usage: /new-command <name>",
|
|
1083
|
-
getArgumentCompletions: () => [],
|
|
1084
|
-
handler: async (args, ctx) => {
|
|
1085
|
-
const name = args.trim();
|
|
1086
|
-
const error = validateResourceName(name);
|
|
1087
|
-
if (error) {
|
|
1088
|
-
ctx.ui.notify(`/new-command: ${error}. Usage: /new-command <name>`, "warning");
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
const commandsDir = join(ctx.cwd, ".hoocode", "commands");
|
|
1092
|
-
const commandFile = join(commandsDir, `${name}.md`);
|
|
1093
|
-
if (existsSync(commandFile)) {
|
|
1094
|
-
ctx.ui.notify(`/new-command: ${commandFile} already exists`, "warning");
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
mkdirSync(commandsDir, { recursive: true });
|
|
1098
|
-
writeFileSync(commandFile, [
|
|
1099
|
-
"---",
|
|
1100
|
-
`name: ${name}`,
|
|
1101
|
-
"description: |",
|
|
1102
|
-
` TODO: describe what /${name} does and when to use it.`,
|
|
1103
|
-
` Usage: /${name} <args>`,
|
|
1104
|
-
"argument-hint: <args>",
|
|
1105
|
-
"---",
|
|
1106
|
-
`Run the /${name} command with arguments: **$ARGUMENTS**.`,
|
|
1107
|
-
"",
|
|
1108
|
-
"TODO: write the instructions here. Placeholders you can use:",
|
|
1109
|
-
"- $1, $2, ... for positional arguments",
|
|
1110
|
-
"- $@ or $ARGUMENTS for all arguments",
|
|
1111
|
-
`- $${"{"}@:N} / $${"{"}@:N:L} for bash-style slices`,
|
|
1112
|
-
"",
|
|
1113
|
-
].join("\n"), "utf8");
|
|
1114
|
-
ctx.ui.notify(`Command created: ${join(".hoocode", "commands", `${name}.md`)}\nEdit the file, then run /reload to activate it.`, "info");
|
|
1115
|
-
},
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
// ============================================================================
|
|
1119
|
-
// D. Options pane — ask_options tool
|
|
1120
|
-
// ============================================================================
|
|
1121
|
-
// The model calls this tool when it needs the user to make a decision before
|
|
1122
|
-
// continuing. Each question is shown in an inline options pane where the user
|
|
1123
|
-
// moves with up/down, advances with right, and may type a custom answer.
|
|
1124
|
-
const askOptionsSchema = Type.Object({
|
|
1125
|
-
questions: Type.Array(Type.Object({
|
|
1126
|
-
question: Type.String({ description: "The question to ask the user." }),
|
|
1127
|
-
detail: Type.Optional(Type.String({ description: "Optional clarifying sub-text shown under the question." })),
|
|
1128
|
-
options: Type.Array(Type.Object({
|
|
1129
|
-
label: Type.String({ description: "The option text; returned verbatim when chosen." }),
|
|
1130
|
-
description: Type.Optional(Type.String({ description: "Optional short description shown next to the option." })),
|
|
1131
|
-
recommended: Type.Optional(Type.Boolean({
|
|
1132
|
-
description: "When true, the option is marked '(recommended)' to help the user choose.",
|
|
1133
|
-
})),
|
|
1134
|
-
}), { description: "The options the user can choose from." }),
|
|
1135
|
-
allow_custom: Type.Optional(Type.Boolean({
|
|
1136
|
-
description: "When true, the user can type a free-form answer instead of choosing an option.",
|
|
1137
|
-
})),
|
|
1138
|
-
}), { description: "One or more decisions to ask the user, in order." }),
|
|
1139
|
-
});
|
|
1140
|
-
export function setupAskOptions(pi) {
|
|
1141
|
-
// Capture the latest context so the tool can reach the interactive UI.
|
|
1142
|
-
let activeCtx;
|
|
1143
|
-
pi.on("session_start", (_event, ctx) => {
|
|
1144
|
-
activeCtx = ctx;
|
|
1145
|
-
});
|
|
1146
|
-
pi.registerTool({
|
|
1147
|
-
name: "ask_options",
|
|
1148
|
-
label: "Ask the user",
|
|
1149
|
-
description: "Ask the user to make one or more decisions before continuing. Each question is presented " +
|
|
1150
|
-
"in an interactive options pane where the user selects an option (or types a custom answer). " +
|
|
1151
|
-
"Use this when you genuinely need input to proceed and cannot reasonably decide yourself. " +
|
|
1152
|
-
"Returns the user's answer for each question; if the user skips, no answers are returned.",
|
|
1153
|
-
parameters: askOptionsSchema,
|
|
1154
|
-
async execute(_toolCallId, params, signal, _onUpdate) {
|
|
1155
|
-
if (!activeCtx || !activeCtx.hasUI) {
|
|
1156
|
-
return {
|
|
1157
|
-
content: [
|
|
1158
|
-
{
|
|
1159
|
-
type: "text",
|
|
1160
|
-
text: "Cannot ask the user: no interactive UI is available in this session. Proceed using your best judgement.",
|
|
1161
|
-
},
|
|
1162
|
-
],
|
|
1163
|
-
details: undefined,
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
if (!params.questions.length) {
|
|
1167
|
-
return {
|
|
1168
|
-
content: [{ type: "text", text: "No questions were provided." }],
|
|
1169
|
-
details: undefined,
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
const questions = params.questions.map((q) => ({
|
|
1173
|
-
question: q.question,
|
|
1174
|
-
detail: q.detail,
|
|
1175
|
-
options: q.options.map((o) => ({ label: o.label, description: o.description, recommended: o.recommended })),
|
|
1176
|
-
allowCustom: q.allow_custom,
|
|
1177
|
-
}));
|
|
1178
|
-
const answers = await activeCtx.ui.askOptions(questions, { signal });
|
|
1179
|
-
if (!answers) {
|
|
1180
|
-
return {
|
|
1181
|
-
content: [
|
|
1182
|
-
{
|
|
1183
|
-
type: "text",
|
|
1184
|
-
text: "The user skipped the question(s) without answering. Ask how they would like to proceed.",
|
|
1185
|
-
},
|
|
1186
|
-
],
|
|
1187
|
-
details: undefined,
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
const text = questions.map((q, i) => `${q.question}\n \u2192 ${answers[i] ?? "(no answer)"}`).join("\n\n");
|
|
1191
|
-
return {
|
|
1192
|
-
content: [{ type: "text", text }],
|
|
1193
|
-
details: undefined,
|
|
1194
|
-
};
|
|
1195
|
-
},
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
// ============================================================================
|
|
1199
|
-
// D. setupThinkingEscalation — tool-outcome-driven thinking
|
|
1200
|
-
// ============================================================================
|
|
1201
|
-
/**
|
|
1202
|
-
* Raise the thinking level for the turn(s) following a tool error, then restore
|
|
1203
|
-
* it. Opt-in via `thinking_escalation.enabled` in hoo-config.json.
|
|
1204
|
-
*
|
|
1205
|
-
* Timing model (per agent run):
|
|
1206
|
-
* turn N: assistant calls tools → tool_execution_end(s) → turn_end
|
|
1207
|
-
* a failing tool here escalates the level used by turn N+1
|
|
1208
|
-
* turn N+1: runs escalated; its turn_end decrements the cooldown and, at zero,
|
|
1209
|
-
* restores the captured baseline so turn N+2 is fast again
|
|
1210
|
-
*
|
|
1211
|
-
* `escalatedThisTurn` ensures the error turn's own `turn_end` does not consume a
|
|
1212
|
-
* cooldown step — the cooldown counts the turns *after* the failure.
|
|
1213
|
-
*/
|
|
1214
|
-
export function setupThinkingEscalation(pi) {
|
|
1215
|
-
// Captured user level to restore to; null means "not currently escalated".
|
|
1216
|
-
let baseline = null;
|
|
1217
|
-
let remaining = 0;
|
|
1218
|
-
let escalatedThisTurn = false;
|
|
1219
|
-
const restore = () => {
|
|
1220
|
-
if (baseline !== null) {
|
|
1221
|
-
pi.setThinkingLevel(baseline);
|
|
1222
|
-
}
|
|
1223
|
-
baseline = null;
|
|
1224
|
-
remaining = 0;
|
|
1225
|
-
escalatedThisTurn = false;
|
|
1226
|
-
};
|
|
1227
|
-
// A fresh user prompt starts clean — restore any lingering escalation.
|
|
1228
|
-
pi.on("agent_start", (_event) => {
|
|
1229
|
-
restore();
|
|
1230
|
-
});
|
|
1231
|
-
pi.on("tool_execution_end", (event, ctx) => {
|
|
1232
|
-
if (!event.isError)
|
|
1233
|
-
return;
|
|
1234
|
-
const cfg = readMergedConfig(ctx.cwd).thinking_escalation;
|
|
1235
|
-
// On by default: only an explicit `enabled: false` disables it. `cfg` may be
|
|
1236
|
-
// undefined when no thinking_escalation block is configured at all.
|
|
1237
|
-
if (cfg?.enabled === false)
|
|
1238
|
-
return;
|
|
1239
|
-
if (cfg?.tools && cfg.tools.length > 0 && !cfg.tools.includes(event.toolName))
|
|
1240
|
-
return;
|
|
1241
|
-
const target = cfg?.on_error ?? "high";
|
|
1242
|
-
const cooldown = Math.max(1, cfg?.cooldown_turns ?? 1);
|
|
1243
|
-
// Capture the user's level only on the first escalation so repeated errors
|
|
1244
|
-
// extend the window without overwriting the restore point with "high".
|
|
1245
|
-
if (baseline === null) {
|
|
1246
|
-
baseline = pi.getThinkingLevel();
|
|
1247
|
-
}
|
|
1248
|
-
pi.setThinkingLevel(target);
|
|
1249
|
-
remaining = cooldown;
|
|
1250
|
-
escalatedThisTurn = true;
|
|
1251
|
-
});
|
|
1252
|
-
pi.on("turn_end", (_event) => {
|
|
1253
|
-
if (baseline === null)
|
|
1254
|
-
return; // not escalated
|
|
1255
|
-
if (escalatedThisTurn) {
|
|
1256
|
-
// This is the turn_end of the turn that failed; the cooldown applies to
|
|
1257
|
-
// subsequent turns, so don't consume a step here.
|
|
1258
|
-
escalatedThisTurn = false;
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
remaining -= 1;
|
|
1262
|
-
if (remaining <= 0) {
|
|
1263
|
-
restore();
|
|
1264
|
-
}
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
// ============================================================================
|
|
1268
|
-
// /loop — cron scheduler, Cron* tools, and autonomous continuation
|
|
1269
|
-
// ============================================================================
|
|
1270
|
-
const AUTO_LOOP_DONE_TOKEN = "LOOP_DONE";
|
|
1271
|
-
const DEFAULT_AUTO_MAX_TURNS = 10;
|
|
1272
|
-
/** Convert a simple interval token ("5m", "2h", "1d") to a 5-field cron, or null. */
|
|
1273
|
-
function intervalToCron(token) {
|
|
1274
|
-
const m = /^(\d+)(m|h|d)$/.exec(token.trim());
|
|
1275
|
-
if (!m)
|
|
1276
|
-
return null;
|
|
1277
|
-
const n = Number(m[1]);
|
|
1278
|
-
if (n < 1)
|
|
1279
|
-
return null;
|
|
1280
|
-
if (m[2] === "m")
|
|
1281
|
-
return `*/${n} * * * *`;
|
|
1282
|
-
if (m[2] === "h")
|
|
1283
|
-
return `0 */${n} * * *`;
|
|
1284
|
-
return `0 0 */${n} * *`; // days
|
|
1285
|
-
}
|
|
1286
|
-
/** Pull a quoted cron expression off the front of an argument string. */
|
|
1287
|
-
function extractQuotedCron(args) {
|
|
1288
|
-
const m = /^"([^"]+)"\s*(.*)$/.exec(args.trim());
|
|
1289
|
-
return m ? { cron: m[1].trim(), rest: m[2].trim() } : null;
|
|
1290
|
-
}
|
|
1291
|
-
function isFiveFieldCron(expr) {
|
|
1292
|
-
return expr.trim().split(/\s+/).length === 5;
|
|
1293
|
-
}
|
|
1294
|
-
/** Flatten an assistant message's text blocks. */
|
|
1295
|
-
function assistantText(message) {
|
|
1296
|
-
const content = message.content;
|
|
1297
|
-
if (typeof content === "string")
|
|
1298
|
-
return content;
|
|
1299
|
-
if (!Array.isArray(content))
|
|
1300
|
-
return "";
|
|
1301
|
-
return content
|
|
1302
|
-
.filter((b) => !!b && b.type === "text")
|
|
1303
|
-
.map((b) => b.text)
|
|
1304
|
-
.join("\n");
|
|
1305
|
-
}
|
|
1306
|
-
/**
|
|
1307
|
-
* `/loop` schedules prompts via cron and drives autonomous continuation. The same
|
|
1308
|
-
* scheduler backs the agent-callable CronCreate/CronList/CronDelete tools.
|
|
1309
|
-
*
|
|
1310
|
-
* /loop "<cron>" <prompt> schedule recurring (5-field cron, local time)
|
|
1311
|
-
* /loop <5m|2h|1d> <prompt> schedule recurring at a simple interval
|
|
1312
|
-
* /loop once "<cron>" <prompt> schedule a one-shot
|
|
1313
|
-
* /loop list | /loop delete <id> | /loop stop
|
|
1314
|
-
* /loop auto [--max-turns N] <task> keep iterating until the task says LOOP_DONE
|
|
1315
|
-
*/
|
|
1316
|
-
export function setupLoop(pi) {
|
|
1317
|
-
let scheduler;
|
|
1318
|
-
let auto = null;
|
|
1319
|
-
pi.on("session_start", (_event, ctx) => {
|
|
1320
|
-
if (scheduler)
|
|
1321
|
-
return;
|
|
1322
|
-
const isIdle = () => {
|
|
1323
|
-
try {
|
|
1324
|
-
return ctx.isIdle();
|
|
1325
|
-
}
|
|
1326
|
-
catch {
|
|
1327
|
-
return true;
|
|
1328
|
-
}
|
|
1329
|
-
};
|
|
1330
|
-
scheduler = new TaskScheduler({
|
|
1331
|
-
storePath: join(ctx.cwd, ".hoocode", "scheduled_tasks.json"),
|
|
1332
|
-
fire: (prompt) => pi.sendUserMessage(prompt, { deliverAs: "followUp" }),
|
|
1333
|
-
isIdle,
|
|
1334
|
-
});
|
|
1335
|
-
scheduler.start();
|
|
1336
|
-
});
|
|
1337
|
-
pi.on("session_shutdown", () => {
|
|
1338
|
-
scheduler?.stop();
|
|
1339
|
-
auto = null;
|
|
1340
|
-
});
|
|
1341
|
-
// Autonomous continuation: re-prompt on each agent_end until LOOP_DONE or budget.
|
|
1342
|
-
pi.on("agent_end", (event, ctx) => {
|
|
1343
|
-
if (!auto)
|
|
1344
|
-
return;
|
|
1345
|
-
if (ctx.hasPendingMessages())
|
|
1346
|
-
return; // user is steering — yield
|
|
1347
|
-
const last = [...event.messages].reverse().find((m) => m.role === "assistant");
|
|
1348
|
-
const text = last ? assistantText(last) : "";
|
|
1349
|
-
if (text.includes(AUTO_LOOP_DONE_TOKEN)) {
|
|
1350
|
-
auto = null;
|
|
1351
|
-
ctx.ui.notify("Autonomous loop complete.", "info");
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
if (auto.remaining <= 0) {
|
|
1355
|
-
auto = null;
|
|
1356
|
-
ctx.ui.notify("Autonomous loop stopped: max turns reached.", "warning");
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
auto.remaining -= 1;
|
|
1360
|
-
pi.sendUserMessage(`Continue working toward the goal. Reply with ${AUTO_LOOP_DONE_TOKEN} when fully complete.`, {
|
|
1361
|
-
deliverAs: "followUp",
|
|
1362
|
-
});
|
|
1363
|
-
});
|
|
1364
|
-
// ── Cron* tools (agent-callable) ──────────────────────────────────────────
|
|
1365
|
-
const toolText = (s) => ({ content: [{ type: "text", text: s }], details: undefined });
|
|
1366
|
-
pi.registerTool(defineTool({
|
|
1367
|
-
name: "CronCreate",
|
|
1368
|
-
label: "Schedule Task",
|
|
1369
|
-
description: "Schedule a prompt to be re-submitted on a cron schedule (5-field, local time: minute hour day-of-month month day-of-week). recurring=false fires once then deletes.",
|
|
1370
|
-
parameters: Type.Object({
|
|
1371
|
-
cron: Type.String({ description: "5-field cron expression in local time" }),
|
|
1372
|
-
prompt: Type.String({ description: "Prompt to enqueue at each fire time" }),
|
|
1373
|
-
recurring: Type.Optional(Type.Boolean({ description: "Fire repeatedly (default true) or once" })),
|
|
1374
|
-
}),
|
|
1375
|
-
async execute(_id, params) {
|
|
1376
|
-
if (!scheduler)
|
|
1377
|
-
return toolText("Scheduler not ready.");
|
|
1378
|
-
if (!isFiveFieldCron(params.cron))
|
|
1379
|
-
return toolText(`Invalid cron "${params.cron}" (need 5 fields).`);
|
|
1380
|
-
const task = scheduler.create({
|
|
1381
|
-
cron: params.cron,
|
|
1382
|
-
prompt: params.prompt,
|
|
1383
|
-
recurring: params.recurring ?? true,
|
|
1384
|
-
});
|
|
1385
|
-
return toolText(`Scheduled ${task.id}: "${task.cron}" (${task.recurring ? "recurring" : "once"})`);
|
|
1386
|
-
},
|
|
1387
|
-
}));
|
|
1388
|
-
pi.registerTool(defineTool({
|
|
1389
|
-
name: "CronList",
|
|
1390
|
-
label: "List Scheduled Tasks",
|
|
1391
|
-
description: "List all scheduled tasks (id, cron, recurring, prompt).",
|
|
1392
|
-
parameters: Type.Object({}),
|
|
1393
|
-
async execute() {
|
|
1394
|
-
const tasks = scheduler?.list() ?? [];
|
|
1395
|
-
if (tasks.length === 0)
|
|
1396
|
-
return toolText("No scheduled tasks.");
|
|
1397
|
-
return toolText(tasks
|
|
1398
|
-
.map((t) => `${t.id} ${t.cron} ${t.recurring ? "recurring" : "once"} ${JSON.stringify(t.prompt)}`)
|
|
1399
|
-
.join("\n"));
|
|
1400
|
-
},
|
|
1401
|
-
}));
|
|
1402
|
-
pi.registerTool(defineTool({
|
|
1403
|
-
name: "CronDelete",
|
|
1404
|
-
label: "Delete Scheduled Task",
|
|
1405
|
-
description: "Delete a scheduled task by id.",
|
|
1406
|
-
parameters: Type.Object({ id: Type.String({ description: "Task id from CronCreate/CronList" }) }),
|
|
1407
|
-
async execute(_id, params) {
|
|
1408
|
-
const removed = scheduler?.delete(params.id) ?? false;
|
|
1409
|
-
return toolText(removed ? `Deleted ${params.id}.` : `No task ${params.id}.`);
|
|
1410
|
-
},
|
|
1411
|
-
}));
|
|
1412
|
-
// ── /loop command ─────────────────────────────────────────────────────────
|
|
1413
|
-
pi.registerCommand("loop", {
|
|
1414
|
-
description: 'Schedule prompts via cron or run an autonomous loop. /loop "<cron>" <prompt> | /loop <5m|2h> <prompt> | /loop once "<cron>" <prompt> | /loop list | /loop delete <id> | /loop stop | /loop auto [--max-turns N] <task>',
|
|
1415
|
-
getArgumentCompletions: (prefix) => ["list", "delete", "stop", "once", "auto"]
|
|
1416
|
-
.filter((s) => s.startsWith(prefix))
|
|
1417
|
-
.map((s) => ({ value: s, label: s })),
|
|
1418
|
-
handler: async (args, ctx) => {
|
|
1419
|
-
const trimmed = args.trim();
|
|
1420
|
-
if (!scheduler) {
|
|
1421
|
-
ctx.ui.notify("Scheduler not ready yet.", "warning");
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
if (!trimmed || trimmed === "list") {
|
|
1425
|
-
const tasks = scheduler.list();
|
|
1426
|
-
ctx.ui.notify(tasks.length === 0
|
|
1427
|
-
? auto
|
|
1428
|
-
? `Autonomous loop active (${auto.remaining} turns left).`
|
|
1429
|
-
: "No scheduled tasks."
|
|
1430
|
-
: tasks.map((t) => `${t.id}: ${t.cron} ${t.recurring ? "" : "(once) "}— ${t.prompt}`).join("\n"), "info");
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
if (trimmed === "stop") {
|
|
1434
|
-
const had = scheduler.list().length > 0 || auto !== null;
|
|
1435
|
-
scheduler.clear();
|
|
1436
|
-
auto = null;
|
|
1437
|
-
ctx.ui.notify(had ? "Stopped all loops and scheduled tasks." : "Nothing to stop.", "info");
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
if (trimmed.startsWith("delete")) {
|
|
1441
|
-
const id = trimmed.slice("delete".length).trim();
|
|
1442
|
-
if (!id) {
|
|
1443
|
-
ctx.ui.notify("Usage: /loop delete <id>", "warning");
|
|
1444
|
-
return;
|
|
1445
|
-
}
|
|
1446
|
-
ctx.ui.notify(scheduler.delete(id) ? `Deleted ${id}.` : `No task ${id}.`, "info");
|
|
1447
|
-
return;
|
|
1448
|
-
}
|
|
1449
|
-
if (trimmed.startsWith("auto")) {
|
|
1450
|
-
let rest = trimmed.slice("auto".length).trim();
|
|
1451
|
-
let maxTurns = DEFAULT_AUTO_MAX_TURNS;
|
|
1452
|
-
const flag = /^--max-turns\s+(\d+)\s*(.*)$/.exec(rest);
|
|
1453
|
-
if (flag) {
|
|
1454
|
-
maxTurns = Number(flag[1]);
|
|
1455
|
-
rest = flag[2].trim();
|
|
1456
|
-
}
|
|
1457
|
-
if (!rest) {
|
|
1458
|
-
ctx.ui.notify("Usage: /loop auto [--max-turns N] <task>", "warning");
|
|
1459
|
-
return;
|
|
1460
|
-
}
|
|
1461
|
-
auto = { remaining: maxTurns };
|
|
1462
|
-
pi.sendUserMessage(`${rest}\n\n(Autonomous loop: keep working until the task is fully complete, then reply with ${AUTO_LOOP_DONE_TOKEN}.)`, { deliverAs: "followUp" });
|
|
1463
|
-
ctx.ui.notify(`Autonomous loop started (max ${maxTurns} turns). Stop with /loop stop.`, "info");
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
// Scheduling: one-shot or recurring, via quoted cron or interval token.
|
|
1467
|
-
let recurring = true;
|
|
1468
|
-
let body = trimmed;
|
|
1469
|
-
if (body.startsWith("once")) {
|
|
1470
|
-
recurring = false;
|
|
1471
|
-
body = body.slice("once".length).trim();
|
|
1472
|
-
}
|
|
1473
|
-
let cron = null;
|
|
1474
|
-
let prompt = "";
|
|
1475
|
-
const quoted = extractQuotedCron(body);
|
|
1476
|
-
if (quoted) {
|
|
1477
|
-
cron = quoted.cron;
|
|
1478
|
-
prompt = quoted.rest;
|
|
1479
|
-
}
|
|
1480
|
-
else {
|
|
1481
|
-
const [first, ...restWords] = body.split(/\s+/);
|
|
1482
|
-
cron = intervalToCron(first);
|
|
1483
|
-
prompt = restWords.join(" ").trim();
|
|
1484
|
-
}
|
|
1485
|
-
if (!cron || !isFiveFieldCron(cron)) {
|
|
1486
|
-
ctx.ui.notify('Usage: /loop "<cron>" <prompt> or /loop <5m|2h|1d> <prompt>', "warning");
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
if (!prompt) {
|
|
1490
|
-
ctx.ui.notify("Nothing to schedule — provide a prompt after the schedule.", "warning");
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
const task = scheduler.create({ cron, prompt, recurring });
|
|
1494
|
-
ctx.ui.notify(`Scheduled ${task.id}: "${cron}" ${recurring ? "recurring" : "once"} — "${prompt}". Manage with /loop list • /loop delete ${task.id}.`, "info");
|
|
1495
|
-
},
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
// ============================================================================
|
|
1499
|
-
// /plugin — marketplace add/list + plugin install/list/remove
|
|
1500
|
-
// ============================================================================
|
|
1501
|
-
function sanitizeForDir(s) {
|
|
1502
|
-
return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
|
|
1503
|
-
}
|
|
1504
|
-
function isGitSource(loc) {
|
|
1505
|
-
return /^https?:\/\//.test(loc) || loc.startsWith("git@") || loc.endsWith(".git");
|
|
1506
|
-
}
|
|
1507
|
-
/**
|
|
1508
|
-
* `/plugin` installs plugins from marketplaces (a git repo or local dir with a
|
|
1509
|
-
* Claude `.claude-plugin/marketplace.json` or Copilot-style `.github/marketplace.json`
|
|
1510
|
-
* index). Installed plugins are placed in `.hoocode/plugins/<name>` and loaded by the
|
|
1511
|
-
* plugin loader after a reload.
|
|
1512
|
-
*
|
|
1513
|
-
* /plugin marketplace add <git-url|path>
|
|
1514
|
-
* /plugin marketplace list
|
|
1515
|
-
* /plugin list list available plugins across marketplaces
|
|
1516
|
-
* /plugin install <name>
|
|
1517
|
-
* /plugin remove <name>
|
|
1518
|
-
*/
|
|
1519
|
-
export function setupMarketplace(pi) {
|
|
1520
|
-
const storePath = (cwd) => join(cwd, ".hoocode", "marketplaces.json");
|
|
1521
|
-
const pluginsDir = (cwd) => join(cwd, ".hoocode", "plugins");
|
|
1522
|
-
const cacheDir = (cwd) => join(cwd, ".hoocode", "marketplace-cache");
|
|
1523
|
-
const findPlugin = (cwd, name) => {
|
|
1524
|
-
for (const record of readMarketplaceStore(storePath(cwd))) {
|
|
1525
|
-
const market = parseMarketplaceDir(record.dir);
|
|
1526
|
-
const entry = market?.plugins.find((p) => p.name === name);
|
|
1527
|
-
if (market && entry)
|
|
1528
|
-
return { market, entry };
|
|
1529
|
-
}
|
|
1530
|
-
return undefined;
|
|
1531
|
-
};
|
|
1532
|
-
pi.registerCommand("plugin", {
|
|
1533
|
-
description: "Manage plugin marketplaces. /plugin marketplace add <git-url|path> | /plugin marketplace list | /plugin list | /plugin install <name> | /plugin remove <name>",
|
|
1534
|
-
getArgumentCompletions: (prefix) => ["marketplace", "list", "install", "remove"]
|
|
1535
|
-
.filter((s) => s.startsWith(prefix))
|
|
1536
|
-
.map((s) => ({ value: s, label: s })),
|
|
1537
|
-
handler: async (args, ctx) => {
|
|
1538
|
-
const trimmed = args.trim();
|
|
1539
|
-
const cwd = ctx.cwd;
|
|
1540
|
-
// ── marketplace add / list ──────────────────────────────────────────
|
|
1541
|
-
if (trimmed.startsWith("marketplace")) {
|
|
1542
|
-
const sub = trimmed.slice("marketplace".length).trim();
|
|
1543
|
-
if (sub === "list" || sub === "") {
|
|
1544
|
-
const records = readMarketplaceStore(storePath(cwd));
|
|
1545
|
-
if (records.length === 0) {
|
|
1546
|
-
ctx.ui.notify("No marketplaces. Add one with /plugin marketplace add <git-url|path>.", "info");
|
|
1547
|
-
return;
|
|
1548
|
-
}
|
|
1549
|
-
const lines = records.map((r) => {
|
|
1550
|
-
const market = parseMarketplaceDir(r.dir);
|
|
1551
|
-
return `${market?.name ?? r.location} — ${market?.plugins.length ?? 0} plugin(s) [${r.location}]`;
|
|
1552
|
-
});
|
|
1553
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
1554
|
-
return;
|
|
1555
|
-
}
|
|
1556
|
-
if (sub.startsWith("add")) {
|
|
1557
|
-
const loc = sub.slice("add".length).trim();
|
|
1558
|
-
if (!loc) {
|
|
1559
|
-
ctx.ui.notify("Usage: /plugin marketplace add <git-url|path>", "warning");
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
let dir;
|
|
1563
|
-
if (isGitSource(loc)) {
|
|
1564
|
-
dir = join(cacheDir(cwd), sanitizeForDir(loc));
|
|
1565
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1566
|
-
mkdirSync(cacheDir(cwd), { recursive: true });
|
|
1567
|
-
const res = await pi.exec("git", ["clone", "--depth", "1", loc, dir]);
|
|
1568
|
-
if (res.code !== 0) {
|
|
1569
|
-
ctx.ui.notify(`Clone failed: ${res.stderr || res.stdout}`, "error");
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
else {
|
|
1574
|
-
dir = resolvePluginSource(loc, cwd).kind === "local" ? join(cwd, loc) : loc;
|
|
1575
|
-
if (!existsSync(dir)) {
|
|
1576
|
-
ctx.ui.notify(`Path not found: ${dir}`, "error");
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
const market = parseMarketplaceDir(dir);
|
|
1581
|
-
if (!market) {
|
|
1582
|
-
ctx.ui.notify("No marketplace manifest found (.claude-plugin/ or .github/marketplace.json).", "error");
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
const records = readMarketplaceStore(storePath(cwd)).filter((r) => r.location !== loc);
|
|
1586
|
-
records.push({ location: loc, dir });
|
|
1587
|
-
writeMarketplaceStore(storePath(cwd), records);
|
|
1588
|
-
ctx.ui.notify(`Added marketplace "${market.name}" (${market.plugins.length} plugin(s)).`, "info");
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
ctx.ui.notify("Usage: /plugin marketplace add <git-url|path> | /plugin marketplace list", "warning");
|
|
1592
|
-
return;
|
|
1593
|
-
}
|
|
1594
|
-
// ── list available plugins ──────────────────────────────────────────
|
|
1595
|
-
if (trimmed === "list" || trimmed === "") {
|
|
1596
|
-
const records = readMarketplaceStore(storePath(cwd));
|
|
1597
|
-
const lines = [];
|
|
1598
|
-
for (const record of records) {
|
|
1599
|
-
const market = parseMarketplaceDir(record.dir);
|
|
1600
|
-
for (const p of market?.plugins ?? []) {
|
|
1601
|
-
lines.push(`${p.name} — ${p.description ?? p.source}`);
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
ctx.ui.notify(lines.length ? lines.join("\n") : "No plugins available. Add a marketplace first.", "info");
|
|
1605
|
-
return;
|
|
1606
|
-
}
|
|
1607
|
-
// ── install <name> ──────────────────────────────────────────────────
|
|
1608
|
-
if (trimmed.startsWith("install")) {
|
|
1609
|
-
const name = trimmed.slice("install".length).trim();
|
|
1610
|
-
if (!name) {
|
|
1611
|
-
ctx.ui.notify("Usage: /plugin install <name>", "warning");
|
|
1612
|
-
return;
|
|
1613
|
-
}
|
|
1614
|
-
const found = findPlugin(cwd, name);
|
|
1615
|
-
if (!found) {
|
|
1616
|
-
ctx.ui.notify(`Plugin "${name}" not found in any marketplace.`, "error");
|
|
1617
|
-
return;
|
|
1618
|
-
}
|
|
1619
|
-
const resolved = resolvePluginSource(found.entry.source, found.market.root);
|
|
1620
|
-
const dest = join(pluginsDir(cwd), sanitizeForDir(name));
|
|
1621
|
-
rmSync(dest, { recursive: true, force: true });
|
|
1622
|
-
mkdirSync(pluginsDir(cwd), { recursive: true });
|
|
1623
|
-
if (resolved.kind === "local") {
|
|
1624
|
-
cpSync(resolved.path, dest, { recursive: true });
|
|
1625
|
-
}
|
|
1626
|
-
else if (resolved.kind === "git") {
|
|
1627
|
-
const res = await pi.exec("git", ["clone", "--depth", "1", resolved.url, dest]);
|
|
1628
|
-
if (res.code !== 0) {
|
|
1629
|
-
ctx.ui.notify(`Clone failed: ${res.stderr || res.stdout}`, "error");
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
else {
|
|
1634
|
-
ctx.ui.notify(`npm plugin sources are not supported yet (${resolved.spec}).`, "warning");
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
ctx.ui.notify(`Installed "${name}" — reloading…`, "info");
|
|
1638
|
-
await ctx.reload();
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
// ── remove <name> ───────────────────────────────────────────────────
|
|
1642
|
-
if (trimmed.startsWith("remove")) {
|
|
1643
|
-
const name = trimmed.slice("remove".length).trim();
|
|
1644
|
-
if (!name) {
|
|
1645
|
-
ctx.ui.notify("Usage: /plugin remove <name>", "warning");
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
const dest = join(pluginsDir(cwd), sanitizeForDir(name));
|
|
1649
|
-
if (!existsSync(dest)) {
|
|
1650
|
-
ctx.ui.notify(`Plugin "${name}" is not installed.`, "info");
|
|
1651
|
-
return;
|
|
1652
|
-
}
|
|
1653
|
-
rmSync(dest, { recursive: true, force: true });
|
|
1654
|
-
ctx.ui.notify(`Removed "${name}" — reloading…`, "info");
|
|
1655
|
-
await ctx.reload();
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1658
|
-
ctx.ui.notify("Usage: /plugin marketplace add|list | /plugin list | /plugin install <name> | /plugin remove <name>", "warning");
|
|
1659
|
-
},
|
|
1660
|
-
});
|
|
1661
|
-
}
|
|
1662
|
-
// ============================================================================
|
|
1663
|
-
// Extension entry point
|
|
1664
|
-
// ============================================================================
|
|
2
|
+
* hoo-core — HooCode built-in core extension (composition root).
|
|
3
|
+
*
|
|
4
|
+
* Each concern lives in its own module in this directory:
|
|
5
|
+
* - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy
|
|
6
|
+
* - mcp-loader.ts — discovers MCP server configs, connects, registers tools
|
|
7
|
+
* - modes.ts — active mode resolution, mode prompts, /mode /plan /approve
|
|
8
|
+
* - cost.ts — /cost session token + cost totals
|
|
9
|
+
* - scaffold.ts — /new-skill /new-agent /new-command
|
|
10
|
+
* - ask-options.ts — the ask_options tool (inline decision pane)
|
|
11
|
+
* - thinking-escalation.ts — raise thinking after tool errors, then restore
|
|
12
|
+
* - loop.ts — /loop cron scheduling + autonomous continuation
|
|
13
|
+
* - marketplace.ts — /plugin marketplace + install/remove
|
|
14
|
+
* - config.ts — hoo-config.json types, I/O, and merge rules
|
|
15
|
+
*
|
|
16
|
+
* `bin/hoocode.js` loads this module's default export as the built-in
|
|
17
|
+
* extension factory.
|
|
18
|
+
*/
|
|
19
|
+
import { setupAskOptions } from "./ask-options.js";
|
|
20
|
+
import { setupCost } from "./cost.js";
|
|
21
|
+
import { setupLoop } from "./loop.js";
|
|
22
|
+
import { setupMarketplace } from "./marketplace.js";
|
|
23
|
+
import { setupMcpLoader } from "./mcp-loader.js";
|
|
24
|
+
import { setupMode } from "./modes.js";
|
|
25
|
+
import { setupPermissionGate } from "./permission-gate.js";
|
|
26
|
+
import { setupScaffold } from "./scaffold.js";
|
|
27
|
+
import { setupThinkingEscalation } from "./thinking-escalation.js";
|
|
1665
28
|
function hooCore(pi) {
|
|
1666
29
|
setupPermissionGate(pi);
|
|
1667
30
|
setupMcpLoader(pi);
|
|
1668
31
|
setupMode(pi);
|
|
32
|
+
setupCost(pi);
|
|
1669
33
|
setupScaffold(pi);
|
|
1670
34
|
setupAskOptions(pi);
|
|
1671
35
|
setupThinkingEscalation(pi);
|