@causyne/agent 0.0.0-stage → 1.10.0
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/LICENSE +102 -0
- package/README.md +31 -2
- package/SECURITY.md +24 -0
- package/bin/causyne.js +13 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/PROVENANCE.md +29 -0
- package/docs/SECURITY_REVIEW.md +89 -0
- package/docs/THIRD_PARTY_NOTICES.md +49 -0
- package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
- package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
- package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
- package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
- package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
- package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
- package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
- package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
- package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
- package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
- package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
- package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
- package/native-skills/android/play-store-release/SKILL.md +43 -0
- package/native-skills/android/room-and-persistence/SKILL.md +40 -0
- package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
- package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
- package/native-skills/backend/caching-strategy/SKILL.md +44 -0
- package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
- package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
- package/native-skills/backend/rest-api-design/SKILL.md +40 -0
- package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
- package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
- package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
- package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
- package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
- package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
- package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
- package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
- package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
- package/native-skills/data/metric-definition/SKILL.md +42 -0
- package/native-skills/data/notebook-to-production/SKILL.md +43 -0
- package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
- package/native-skills/database/connection-pooling/SKILL.md +42 -0
- package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
- package/native-skills/database/query-optimisation/SKILL.md +38 -0
- package/native-skills/database/safe-migrations/SKILL.md +47 -0
- package/native-skills/database/schema-design/SKILL.md +38 -0
- package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
- package/native-skills/design/colour-system-construction/SKILL.md +38 -0
- package/native-skills/design/design-direction-brief/SKILL.md +41 -0
- package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
- package/native-skills/design/responsive-strategy/SKILL.md +39 -0
- package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
- package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
- package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
- package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
- package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
- package/native-skills/devops/incident-triage/SKILL.md +38 -0
- package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
- package/native-skills/devops/observability-baseline/SKILL.md +38 -0
- package/native-skills/devops/secret-management/SKILL.md +40 -0
- package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
- package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
- package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
- package/native-skills/git/branch-strategy/SKILL.md +39 -0
- package/native-skills/git/commit-hygiene/SKILL.md +41 -0
- package/native-skills/git/history-recovery/SKILL.md +44 -0
- package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
- package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
- package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
- package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
- package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
- package/native-skills/mobile/deep-linking/SKILL.md +39 -0
- package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
- package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
- package/native-skills/mobile/push-notifications/SKILL.md +38 -0
- package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
- package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
- package/native-skills/performance/load-testing/SKILL.md +42 -0
- package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
- package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
- package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
- package/native-skills/python/async-correctness/SKILL.md +40 -0
- package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
- package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
- package/native-skills/python/environment-isolation/SKILL.md +41 -0
- package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
- package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
- package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
- package/native-skills/security/authorization-model/SKILL.md +42 -0
- package/native-skills/security/crypto-usage/SKILL.md +48 -0
- package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
- package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
- package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
- package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
- package/native-skills/security/threat-modelling/SKILL.md +46 -0
- package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
- package/native-skills/testing/property-based-testing/SKILL.md +46 -0
- package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
- package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
- package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
- package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
- package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
- package/native-skills/web/accessible-components/SKILL.md +45 -0
- package/native-skills/web/api-client-resilience/SKILL.md +39 -0
- package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
- package/native-skills/web/core-web-vitals/SKILL.md +40 -0
- package/native-skills/web/css-layout-systems/SKILL.md +41 -0
- package/native-skills/web/form-validation-ux/SKILL.md +39 -0
- package/native-skills/web/react-state-architecture/SKILL.md +41 -0
- package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
- package/package.json +68 -4
- package/src/agentLoop.js +2458 -0
- package/src/appChannel.js +109 -0
- package/src/approvalGrants.js +66 -0
- package/src/auditLog.js +52 -0
- package/src/autoUpdate.js +12 -0
- package/src/capabilities.js +36 -0
- package/src/checkpoints.js +1199 -0
- package/src/cliMode.js +638 -0
- package/src/commands.js +805 -0
- package/src/compiledSkills.js +540 -0
- package/src/config.js +130 -0
- package/src/contextManager.js +498 -0
- package/src/cortex/autoCondenser.js +192 -0
- package/src/cortex/provisioner.js +27 -0
- package/src/cortex/vectorDb.js +447 -0
- package/src/designPalettes.js +99 -0
- package/src/devMode.js +6 -0
- package/src/embodimentSession.js +180 -0
- package/src/entrypoint.js +194 -0
- package/src/execSafe.js +21 -0
- package/src/gitSnapshot.js +378 -0
- package/src/mcp/jsonRpc.js +165 -0
- package/src/mcp/mcpClient.js +136 -0
- package/src/mcp/mcpRegistry.js +207 -0
- package/src/memory.js +62 -0
- package/src/memoryManager.js +124 -0
- package/src/modelCatalogue.js +132 -0
- package/src/modelIds.js +42 -0
- package/src/modelRouter.js +145 -0
- package/src/outputStore.js +246 -0
- package/src/perception.js +124 -0
- package/src/planCache.js +437 -0
- package/src/procSignal.js +32 -0
- package/src/providers/anthropic.js +408 -0
- package/src/providers/base.js +328 -0
- package/src/providers/dashscope-byok.js +86 -0
- package/src/providers/deepseek.js +80 -0
- package/src/providers/factory.js +37 -0
- package/src/providers/gemini.js +346 -0
- package/src/providers/moonshot.js +73 -0
- package/src/providers/muse.js +169 -0
- package/src/providers/ollama.js +104 -0
- package/src/providers/openai-compatible.js +84 -0
- package/src/providers/openai.js +20 -0
- package/src/providers/qwen-byok.js +76 -0
- package/src/providers/xai.js +73 -0
- package/src/provisioning/capabilityConsent.js +24 -0
- package/src/provisioning/capabilityProvisioner.js +271 -0
- package/src/provisioning/capabilityTools.js +29 -0
- package/src/reporting.js +373 -0
- package/src/sandbox.js +529 -0
- package/src/sessionStore.js +279 -0
- package/src/skillsManager.js +288 -0
- package/src/slopDetector.js +307 -0
- package/src/taskBuffer.js +45 -0
- package/src/taskShape.js +241 -0
- package/src/textDiff.js +150 -0
- package/src/tokenTracker.js +560 -0
- package/src/tool-scripts/ax_snapshot.applescript +39 -0
- package/src/tool-scripts/ax_snapshot.ps1 +10 -0
- package/src/tool-scripts/desktop_act.applescript +58 -0
- package/src/tool-scripts/resolve_automator.py +119 -0
- package/src/toolPacks.js +524 -0
- package/src/tools/archive.js +63 -0
- package/src/tools/astryxOracle.js +78 -0
- package/src/tools/axSnapshot.js +165 -0
- package/src/tools/blenderAutomator.js +349 -0
- package/src/tools/browserAutomator.js +252 -0
- package/src/tools/checkSystemSpecs.js +64 -0
- package/src/tools/clipboard.js +36 -0
- package/src/tools/codeSearchAst.js +101 -0
- package/src/tools/codeSearcher.js +230 -0
- package/src/tools/codeTools.js +262 -0
- package/src/tools/codebaseSearch.js +75 -0
- package/src/tools/createSkill.js +129 -0
- package/src/tools/deferredMcpSearch.js +246 -0
- package/src/tools/desktopAct.js +208 -0
- package/src/tools/desktopOpen.js +34 -0
- package/src/tools/dynamicLoader.js +44 -0
- package/src/tools/executeProceduralMedia.js +93 -0
- package/src/tools/fsDelete.js +51 -0
- package/src/tools/fsEdit.js +105 -0
- package/src/tools/fsFind.js +128 -0
- package/src/tools/fsList.js +77 -0
- package/src/tools/fsMove.js +57 -0
- package/src/tools/fsRead.js +87 -0
- package/src/tools/fsSearch.js +198 -0
- package/src/tools/fsWrite.js +41 -0
- package/src/tools/gameAiBehaviorTree.js +208 -0
- package/src/tools/gameAnimationRig.js +194 -0
- package/src/tools/gameAssetFetcher.js +372 -0
- package/src/tools/gameAssetHarvester.js +210 -0
- package/src/tools/gameAudioSynth.js +267 -0
- package/src/tools/gameBuilder.js +886 -0
- package/src/tools/gameCanvasAudit.js +101 -0
- package/src/tools/gameCinematicSequencer.js +151 -0
- package/src/tools/gameDataForge.js +124 -0
- package/src/tools/gameDialogueTreeGen.js +166 -0
- package/src/tools/gameEconomyBalancer.js +161 -0
- package/src/tools/gameEntitySpawner.js +207 -0
- package/src/tools/gameLoreForge.js +153 -0
- package/src/tools/gameQuestArcBuilder.js +152 -0
- package/src/tools/gameSceneBuilder.js +158 -0
- package/src/tools/gameShaderForge.js +181 -0
- package/src/tools/gameTilemapGenerator.js +164 -0
- package/src/tools/gameUiHudBuilder.js +292 -0
- package/src/tools/gameVfxEmitterGen.js +108 -0
- package/src/tools/gameWebRuntimeInspector.js +91 -0
- package/src/tools/generateMediaViaApi.js +358 -0
- package/src/tools/gitExec.js +54 -0
- package/src/tools/godotAutomator.js +437 -0
- package/src/tools/manageLocalAiDependencies.js +220 -0
- package/src/tools/memoryWrite.js +99 -0
- package/src/tools/multiEdit.js +107 -0
- package/src/tools/palmierAutomator.js +104 -0
- package/src/tools/pdfReader.js +112 -0
- package/src/tools/persistentShell.js +148 -0
- package/src/tools/printer.js +316 -0
- package/src/tools/procTerm.js +91 -0
- package/src/tools/processList.js +52 -0
- package/src/tools/readNativeSkill.js +39 -0
- package/src/tools/registry.js +277 -0
- package/src/tools/resolveAutomator.js +102 -0
- package/src/tools/screenshot.js +75 -0
- package/src/tools/subagentOrchestrator.js +305 -0
- package/src/tools/sysRun.js +125 -0
- package/src/tools/todoWrite.js +75 -0
- package/src/tools/toolDispatch.js +127 -0
- package/src/tools/toolForge.js +124 -0
- package/src/tools/updatePlan.js +88 -0
- package/src/tools/viewImage.js +82 -0
- package/src/tools/visionInspector.js +154 -0
- package/src/tools/webFetch.js +74 -0
- package/src/tools/webSearch.js +82 -0
- package/src/tui/ask.js +164 -0
- package/src/tui/banner.js +158 -0
- package/src/tui/block.js +152 -0
- package/src/tui/chatRenderer.js +953 -0
- package/src/tui/completion.js +424 -0
- package/src/tui/dialogs.js +606 -0
- package/src/tui/diff.js +296 -0
- package/src/tui/history.js +135 -0
- package/src/tui/home.js +217 -0
- package/src/tui/invocation.js +105 -0
- package/src/tui/keys.js +443 -0
- package/src/tui/lineEditor.js +256 -0
- package/src/tui/overlay.js +256 -0
- package/src/tui/prompt.js +1258 -0
- package/src/tui/selectList.js +335 -0
- package/src/tui/stage.js +330 -0
- package/src/tui/stageSurface.js +609 -0
- package/src/tui/statusLine.js +254 -0
- package/src/tui/text.js +345 -0
- package/src/tui/theme.js +393 -0
- package/src/tui/toolDisplay.js +176 -0
- package/src/tui/transcript.js +235 -0
- package/src/tui/viewport.js +337 -0
- package/src/uncertainty.js +271 -0
- package/src/userSkills.js +265 -0
- package/src/utils/cli_ui.js +0 -0
- package/src/utils/inquirer.js +12 -0
- package/src/utils/paths.js +37 -0
- package/src/version.js +38 -0
- package/src/vfeTracker.js +220 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Shadow-git checkpoint backend.
|
|
2
|
+
//
|
|
3
|
+
// The file-copy store (checkpoints.js) works, but it copies bytes and therefore
|
|
4
|
+
// has to cap what it will look at — MAX_TREE_FILES is 5000, and a real home
|
|
5
|
+
// directory blows past that, which is how a user ended up with a workspace root
|
|
6
|
+
// silently marked `too_many_files` and NOT covered by any rollback.
|
|
7
|
+
//
|
|
8
|
+
// Git already solves this problem properly, so this backend borrows opencode's
|
|
9
|
+
// design (packages/core/src/snapshot.ts):
|
|
10
|
+
//
|
|
11
|
+
// * The repository lives in ~/.causyne/snapshot/<project>/<hash(worktree)>.
|
|
12
|
+
// The user's own .git is NEVER touched — no stash, no commit, no index
|
|
13
|
+
// mutation, no HEAD movement. Their history is theirs.
|
|
14
|
+
// * objects/info/alternates points at the user's object database, and their
|
|
15
|
+
// index is copied in, so blobs the project already stores are never
|
|
16
|
+
// re-hashed. This is what makes a checkpoint instant on a large tree
|
|
17
|
+
// instead of a minutes-long walk.
|
|
18
|
+
// * A checkpoint id IS a `write-tree` SHA. No commits, no refs, nothing to
|
|
19
|
+
// garbage collect by hand.
|
|
20
|
+
//
|
|
21
|
+
// ZERO-MALWARE: every git invocation goes through runGit(), which uses execFile
|
|
22
|
+
// with an ARRAY of arguments. There is no shell anywhere in this file, and no
|
|
23
|
+
// argument is ever built by string concatenation, so there is nothing for an
|
|
24
|
+
// injected filename to escape into. This is the same hardening applied to
|
|
25
|
+
// printer.js in v1.1.44 after its exec() shell-injection hole.
|
|
26
|
+
|
|
27
|
+
import { execFileSync, execFile } from 'child_process';
|
|
28
|
+
import { promisify } from 'util';
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import os from 'os';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import crypto from 'crypto';
|
|
33
|
+
import { causyneHome } from './utils/paths.js';
|
|
34
|
+
|
|
35
|
+
/** A git call that hangs must not hang a task. */
|
|
36
|
+
const GIT_TIMEOUT_MS = 20000;
|
|
37
|
+
|
|
38
|
+
/** Untracked files above this are excluded rather than stored (opencode uses 2 MB). */
|
|
39
|
+
const MAX_UNTRACKED_BYTES = 2 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run git.
|
|
43
|
+
*
|
|
44
|
+
* `args` is an ARRAY — execFile passes it straight to execve, so no shell ever
|
|
45
|
+
* parses it and a path containing a space, a quote or `$(...)` is just a path.
|
|
46
|
+
* Never change this to exec() or add `shell: true`.
|
|
47
|
+
*/
|
|
48
|
+
function runGit(args, options = {}) {
|
|
49
|
+
if (!Array.isArray(args) || args.some((a) => typeof a !== 'string')) {
|
|
50
|
+
throw new Error('git arguments must be an array of strings');
|
|
51
|
+
}
|
|
52
|
+
// Synchronous on purpose. Everything in checkpoints.js is synchronous, and a
|
|
53
|
+
// checkpoint MUST complete before the agent is allowed to touch a file — an
|
|
54
|
+
// asynchronous capture would race the task's own writes and snapshot a state
|
|
55
|
+
// that already includes the edits it is supposed to protect against. The cost
|
|
56
|
+
// is nothing next to what this replaces: the native store walks and hashes
|
|
57
|
+
// every file in the tree, where git reuses its index and writes one object.
|
|
58
|
+
return execFileSync('git', args, {
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
timeout: GIT_TIMEOUT_MS,
|
|
61
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
64
|
+
...options
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const execFileAsync = promisify(execFile);
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async function runGitAsync(args, options = {}) {
|
|
72
|
+
if (!Array.isArray(args) || args.some((a) => typeof a !== 'string')) {
|
|
73
|
+
throw new Error('git arguments must be an array of strings');
|
|
74
|
+
}
|
|
75
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
timeout: GIT_TIMEOUT_MS,
|
|
78
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
...options
|
|
81
|
+
});
|
|
82
|
+
return stdout;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Capture without blocking — the form the task hot path must use.
|
|
87
|
+
*
|
|
88
|
+
* beginTask runs at the START of every task, so it is the one capture that can
|
|
89
|
+
* never be synchronous. Preview and restore stay
|
|
90
|
+
* synchronous: they run only when the user has explicitly asked for an undo and
|
|
91
|
+
* is waiting for the answer.
|
|
92
|
+
*/
|
|
93
|
+
export async function captureTreeAsync(worktree) {
|
|
94
|
+
try {
|
|
95
|
+
const gitDir = initShadow(worktree);
|
|
96
|
+
excludeOversizedUntracked(gitDir, worktree);
|
|
97
|
+
await runGitAsync(
|
|
98
|
+
['--git-dir', gitDir, '--work-tree', worktree, 'add', '--all'],
|
|
99
|
+
{ cwd: worktree }
|
|
100
|
+
);
|
|
101
|
+
const sha = (await runGitAsync(['--git-dir', gitDir, 'write-tree'])).trim();
|
|
102
|
+
return sha || null;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Is a usable git binary on PATH? Cached — the answer cannot change mid-run. */
|
|
109
|
+
let gitAvailable = null;
|
|
110
|
+
export function isGitAvailable() {
|
|
111
|
+
if (gitAvailable !== null) return gitAvailable;
|
|
112
|
+
try {
|
|
113
|
+
runGit(['--version']);
|
|
114
|
+
gitAvailable = true;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
gitAvailable = false;
|
|
117
|
+
}
|
|
118
|
+
return gitAvailable;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The worktree root for `dir`, or null when it is not inside a git repository. */
|
|
122
|
+
export function resolveWorktree(dir) {
|
|
123
|
+
try {
|
|
124
|
+
const out = runGit(['rev-parse', '--show-toplevel'], { cwd: dir });
|
|
125
|
+
const root = out.trim();
|
|
126
|
+
return root ? path.resolve(root) : null;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Where this worktree's shadow repository lives. */
|
|
133
|
+
export function shadowDirFor(worktree) {
|
|
134
|
+
const hash = crypto.createHash('sha256').update(path.resolve(worktree)).digest('hex').slice(0, 16);
|
|
135
|
+
return path.join(causyneHome(), 'snapshot', path.basename(worktree), hash);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create (or reuse) the shadow repository for a worktree.
|
|
140
|
+
*
|
|
141
|
+
* Returns the --git-dir path. Safe to call repeatedly.
|
|
142
|
+
*/
|
|
143
|
+
export function initShadow(worktree) {
|
|
144
|
+
const gitDir = shadowDirFor(worktree);
|
|
145
|
+
const alreadyInitialised = fs.existsSync(path.join(gitDir, 'HEAD'));
|
|
146
|
+
|
|
147
|
+
if (!alreadyInitialised) {
|
|
148
|
+
fs.mkdirSync(gitDir, { recursive: true });
|
|
149
|
+
runGit(['--git-dir', gitDir, 'init', '--bare', '--quiet']);
|
|
150
|
+
|
|
151
|
+
// Tuning that matters on large trees, applied once at creation.
|
|
152
|
+
for (const [key, value] of [
|
|
153
|
+
['feature.manyFiles', 'true'],
|
|
154
|
+
['index.version', '4'],
|
|
155
|
+
['index.threads', 'true'],
|
|
156
|
+
['core.untrackedCache', 'true']
|
|
157
|
+
]) {
|
|
158
|
+
try {
|
|
159
|
+
runGit(['--git-dir', gitDir, 'config', key, value]);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
// Tuning is an optimisation; an older git that rejects a key must not
|
|
162
|
+
// stop the checkpoint from being taken.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Point at the user's object database so blobs are never re-hashed, and adopt
|
|
168
|
+
// their index so already-hashed entries are reused. Both are best-effort: a
|
|
169
|
+
// missing or incompatible source just means a full, slower add.
|
|
170
|
+
const sourceObjects = path.join(worktree, '.git', 'objects');
|
|
171
|
+
if (fs.existsSync(sourceObjects)) {
|
|
172
|
+
try {
|
|
173
|
+
const infoDir = path.join(gitDir, 'objects', 'info');
|
|
174
|
+
fs.mkdirSync(infoDir, { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(infoDir, 'alternates'), `${sourceObjects}\n`, 'utf8');
|
|
176
|
+
} catch (e) {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!alreadyInitialised) {
|
|
180
|
+
const sourceIndex = path.join(worktree, '.git', 'index');
|
|
181
|
+
if (fs.existsSync(sourceIndex)) {
|
|
182
|
+
try {
|
|
183
|
+
fs.copyFileSync(sourceIndex, path.join(gitDir, 'index'));
|
|
184
|
+
} catch (e) {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return gitDir;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Exclude untracked files too large to be worth storing.
|
|
193
|
+
*
|
|
194
|
+
* Written into the SHADOW repo's info/exclude, never the user's .gitignore.
|
|
195
|
+
*/
|
|
196
|
+
function excludeOversizedUntracked(gitDir, worktree) {
|
|
197
|
+
let listing = '';
|
|
198
|
+
try {
|
|
199
|
+
listing = runGit(
|
|
200
|
+
['--git-dir', gitDir, '--work-tree', worktree, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
201
|
+
{ cwd: worktree }
|
|
202
|
+
);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const oversized = [];
|
|
208
|
+
for (const rel of listing.split('\0')) {
|
|
209
|
+
if (!rel) continue;
|
|
210
|
+
try {
|
|
211
|
+
const stat = fs.statSync(path.join(worktree, rel));
|
|
212
|
+
if (stat.isFile() && stat.size > MAX_UNTRACKED_BYTES) oversized.push(rel);
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
}
|
|
215
|
+
if (oversized.length === 0) return;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const infoDir = path.join(gitDir, 'info');
|
|
219
|
+
fs.mkdirSync(infoDir, { recursive: true });
|
|
220
|
+
fs.writeFileSync(path.join(infoDir, 'exclude'), `${oversized.join('\n')}\n`, 'utf8');
|
|
221
|
+
} catch (e) {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Capture the worktree's current state.
|
|
226
|
+
*
|
|
227
|
+
* @returns {string|null} a tree SHA, or null if the capture failed —
|
|
228
|
+
* a checkpoint is best-effort and must never fail the task it belongs to.
|
|
229
|
+
*/
|
|
230
|
+
export function captureTree(worktree) {
|
|
231
|
+
try {
|
|
232
|
+
const gitDir = initShadow(worktree);
|
|
233
|
+
excludeOversizedUntracked(gitDir, worktree);
|
|
234
|
+
|
|
235
|
+
// The user's ignore rules decide what is worth keeping. --no-index keeps it
|
|
236
|
+
// pattern-based rather than dependent on what their index happens to track.
|
|
237
|
+
// NO --force here, deliberately.
|
|
238
|
+
//
|
|
239
|
+
// `--force` means "add otherwise ignored files". It was doing exactly that:
|
|
240
|
+
// node_modules, build outputs, caches — everything .gitignore exists to keep
|
|
241
|
+
// out. The first capture of this repository took 23 seconds and stalled task
|
|
242
|
+
// startup. The user's own ignore rules are the correct definition of "files
|
|
243
|
+
// worth being able to restore".
|
|
244
|
+
runGit(['--git-dir', gitDir, '--work-tree', worktree, 'add', '--all'], { cwd: worktree });
|
|
245
|
+
|
|
246
|
+
const sha = (runGit(['--git-dir', gitDir, 'write-tree'])).trim();
|
|
247
|
+
return sha || null;
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What restoring `files` would change — WITHOUT touching the worktree.
|
|
255
|
+
*
|
|
256
|
+
* The technique is opencode's (packages/core/src/git.ts), which built it and
|
|
257
|
+
* never wired it to a UI: point GIT_INDEX_FILE at a throwaway index, read-tree
|
|
258
|
+
* the current state into it, update-index the planned blobs over the top,
|
|
259
|
+
* write-tree that hypothetical, and diff it against now. Nothing anywhere near
|
|
260
|
+
* the user's files is written, and the temp index is removed in a finally.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} worktree
|
|
263
|
+
* @param {string} fromTree tree SHA to restore from
|
|
264
|
+
* @returns {Array<{path,status,additions,deletions,patch}>}
|
|
265
|
+
*/
|
|
266
|
+
export function previewRestore(worktree, fromTree) {
|
|
267
|
+
const gitDir = initShadow(worktree);
|
|
268
|
+
const tempIndex = path.join(gitDir, `preview-${crypto.randomUUID()}.index`);
|
|
269
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const current = captureTree(worktree);
|
|
273
|
+
if (!current) return [];
|
|
274
|
+
|
|
275
|
+
runGit(['--git-dir', gitDir, 'read-tree', current], { env });
|
|
276
|
+
const raw = runGit(
|
|
277
|
+
['--git-dir', gitDir, 'diff', '--numstat', '--find-renames', current, fromTree],
|
|
278
|
+
{ env }
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const changes = [];
|
|
282
|
+
for (const line of raw.split('\n')) {
|
|
283
|
+
if (!line.trim()) continue;
|
|
284
|
+
const [added, removed, file] = line.split('\t');
|
|
285
|
+
if (!file) continue;
|
|
286
|
+
|
|
287
|
+
let patch = '';
|
|
288
|
+
try {
|
|
289
|
+
patch = runGit(
|
|
290
|
+
['--git-dir', gitDir, 'diff', '--unified=3', current, fromTree, '--', file],
|
|
291
|
+
{ env }
|
|
292
|
+
);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
// A binary file has no textual patch; the counts still describe it.
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
changes.push({
|
|
298
|
+
path: file,
|
|
299
|
+
// Restoring runs backwards: a file absent from the OLD tree was created
|
|
300
|
+
// by the task and would be deleted, and vice versa.
|
|
301
|
+
status: added === '-' && removed === '-' ? 'binary' : classify(added, removed),
|
|
302
|
+
additions: added === '-' ? 0 : Number(added) || 0,
|
|
303
|
+
deletions: removed === '-' ? 0 : Number(removed) || 0,
|
|
304
|
+
patch
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return changes;
|
|
308
|
+
} finally {
|
|
309
|
+
try { fs.rmSync(tempIndex, { force: true }); } catch (e) {}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function classify(added, removed) {
|
|
314
|
+
const a = Number(added) || 0;
|
|
315
|
+
const d = Number(removed) || 0;
|
|
316
|
+
if (a > 0 && d === 0) return 'added';
|
|
317
|
+
if (d > 0 && a === 0) return 'deleted';
|
|
318
|
+
return 'modified';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Restore the worktree to `fromTree`.
|
|
323
|
+
*
|
|
324
|
+
* Only called after the user has approved a preview built from the same pair of
|
|
325
|
+
* trees, so what they were shown is what happens.
|
|
326
|
+
*/
|
|
327
|
+
export function restoreTree(worktree, fromTree) {
|
|
328
|
+
const gitDir = initShadow(worktree);
|
|
329
|
+
const current = captureTree(worktree);
|
|
330
|
+
|
|
331
|
+
// Files the agent CREATED must be removed, not merely left behind.
|
|
332
|
+
//
|
|
333
|
+
// `git checkout <tree> -- .` restores every path the tree contains and is
|
|
334
|
+
// silent about every path it does not — so a file created after the
|
|
335
|
+
// checkpoint survived the rollback, and "restored to the previous state"
|
|
336
|
+
// quietly meant "mostly". Deletions are computed first, because the checkout
|
|
337
|
+
// below rewrites the index this diff is read from.
|
|
338
|
+
const toRemove = [];
|
|
339
|
+
if (current) {
|
|
340
|
+
let raw = '';
|
|
341
|
+
try {
|
|
342
|
+
raw = runGit(['--git-dir', gitDir, 'diff', '--name-status', '-z', current, fromTree]);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
raw = '';
|
|
345
|
+
}
|
|
346
|
+
// -z output is NUL separated: status, path, status, path…
|
|
347
|
+
const parts = raw.split('\0').filter(Boolean);
|
|
348
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
349
|
+
const status = parts[i];
|
|
350
|
+
const rel = parts[i + 1];
|
|
351
|
+
// 'D' going current -> target means: present now, absent then.
|
|
352
|
+
if (status[0] !== 'D') continue;
|
|
353
|
+
|
|
354
|
+
// A path must never climb out of the worktree it belongs to.
|
|
355
|
+
const absolute = path.resolve(worktree, rel);
|
|
356
|
+
const root = path.resolve(worktree);
|
|
357
|
+
if (absolute !== root && !absolute.startsWith(root + path.sep)) continue;
|
|
358
|
+
toRemove.push(absolute);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
runGit(
|
|
363
|
+
['--git-dir', gitDir, '--work-tree', worktree, 'checkout', fromTree, '--', '.'],
|
|
364
|
+
{ cwd: worktree }
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
for (const absolute of toRemove) {
|
|
368
|
+
try { fs.rmSync(absolute, { force: true }); } catch (e) {}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Retention: the shadow repo prunes itself rather than growing forever. */
|
|
373
|
+
export function pruneShadow(worktree) {
|
|
374
|
+
try {
|
|
375
|
+
const gitDir = initShadow(worktree);
|
|
376
|
+
runGit(['--git-dir', gitDir, 'gc', '--prune=7.days', '--quiet']);
|
|
377
|
+
} catch (e) {}
|
|
378
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A JSON-RPC channel over a child process's stdin/stdout.
|
|
9
|
+
*
|
|
10
|
+
* MCP frames stdio traffic as newline-delimited JSON. stderr is deliberately
|
|
11
|
+
* NOT parsed — servers use it for human-readable logging, and treating it as
|
|
12
|
+
* protocol is how a chatty server becomes a hang.
|
|
13
|
+
*/
|
|
14
|
+
export class StdioChannel {
|
|
15
|
+
constructor({ command, args = [], env = {}, cwd }) {
|
|
16
|
+
this.command = command;
|
|
17
|
+
this.args = args;
|
|
18
|
+
this.env = env;
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
this.child = null;
|
|
21
|
+
this.buffer = '';
|
|
22
|
+
this.pending = new Map();
|
|
23
|
+
this.nextId = 1;
|
|
24
|
+
this.notificationHandler = null;
|
|
25
|
+
this.stderrTail = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async open() {
|
|
29
|
+
this.child = spawn(this.command, this.args, {
|
|
30
|
+
cwd: this.cwd || process.cwd(),
|
|
31
|
+
env: { ...process.env, ...this.env },
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.child.stdout.setEncoding('utf-8');
|
|
36
|
+
this.child.stdout.on('data', chunk => this.#ingest(chunk));
|
|
37
|
+
|
|
38
|
+
// Kept only so a failure can be explained. Bounded, because a server that
|
|
39
|
+
|
|
40
|
+
this.child.stderr.setEncoding('utf-8');
|
|
41
|
+
this.child.stderr.on('data', chunk => {
|
|
42
|
+
this.stderrTail.push(String(chunk));
|
|
43
|
+
if (this.stderrTail.length > 20) this.stderrTail.shift();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.child.on('exit', code => this.#failAll(new Error(`server exited (code ${code})${this.lastError()}`)));
|
|
47
|
+
this.child.on('error', err => this.#failAll(err));
|
|
48
|
+
|
|
49
|
+
// Surface a command that does not exist as an error rather than a timeout.
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
const onError = (err) => reject(err);
|
|
52
|
+
this.child.once('error', onError);
|
|
53
|
+
setTimeout(() => { this.child.removeListener('error', onError); resolve(); }, 150);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
lastError() {
|
|
58
|
+
const tail = this.stderrTail.join('').trim();
|
|
59
|
+
return tail ? `: ${tail.slice(-400)}` : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#ingest(chunk) {
|
|
63
|
+
this.buffer += chunk;
|
|
64
|
+
let newline;
|
|
65
|
+
while ((newline = this.buffer.indexOf('\n')) !== -1) {
|
|
66
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
67
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
68
|
+
if (!line) continue;
|
|
69
|
+
let frame;
|
|
70
|
+
try { frame = JSON.parse(line); } catch (e) { continue; }
|
|
71
|
+
this.#dispatch(frame);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#dispatch(frame) {
|
|
76
|
+
if (frame.id !== undefined && this.pending.has(frame.id)) {
|
|
77
|
+
const { resolve, reject, timer } = this.pending.get(frame.id);
|
|
78
|
+
this.pending.delete(frame.id);
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
if (frame.error) reject(new Error(frame.error.message || 'JSON-RPC error'));
|
|
81
|
+
else resolve(frame.result);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (frame.method && this.notificationHandler) {
|
|
85
|
+
try { this.notificationHandler(frame); } catch (e) {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#failAll(error) {
|
|
90
|
+
for (const [, { reject, timer }] of this.pending) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
reject(error);
|
|
93
|
+
}
|
|
94
|
+
this.pending.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
send(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
98
|
+
if (!this.child || this.child.killed) return Promise.reject(new Error('channel is closed'));
|
|
99
|
+
const id = this.nextId++;
|
|
100
|
+
const frame = { jsonrpc: '2.0', id, method, params: params ?? {} };
|
|
101
|
+
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
this.pending.delete(id);
|
|
105
|
+
reject(new Error(`${method} timed out after ${timeoutMs} ms`));
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
108
|
+
try {
|
|
109
|
+
this.child.stdin.write(JSON.stringify(frame) + '\n');
|
|
110
|
+
} catch (e) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
this.pending.delete(id);
|
|
113
|
+
reject(e);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
notify(method, params) {
|
|
119
|
+
if (!this.child || this.child.killed) return;
|
|
120
|
+
try {
|
|
121
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {} }) + '\n');
|
|
122
|
+
} catch (e) {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close() {
|
|
126
|
+
this.#failAll(new Error('channel closed'));
|
|
127
|
+
try { if (this.child) this.child.kill('SIGTERM'); } catch (e) {}
|
|
128
|
+
this.child = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A JSON-RPC channel over HTTP POST. One request, one response, no session. */
|
|
133
|
+
export class HttpChannel {
|
|
134
|
+
constructor({ url, headers = {} }) {
|
|
135
|
+
this.url = url;
|
|
136
|
+
this.headers = headers;
|
|
137
|
+
this.nextId = 1;
|
|
138
|
+
this.notificationHandler = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async open() {
|
|
142
|
+
// Nothing to open. Validate the URL now so a typo fails at connect time
|
|
143
|
+
// rather than on the first tool call.
|
|
144
|
+
const parsed = new URL(this.url);
|
|
145
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
146
|
+
throw new Error(`unsupported transport protocol: ${parsed.protocol}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async send(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
151
|
+
const response = await fetch(this.url, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', ...this.headers },
|
|
154
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: this.nextId++, method, params: params ?? {} }),
|
|
155
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
156
|
+
});
|
|
157
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from ${this.url}`);
|
|
158
|
+
const frame = await response.json();
|
|
159
|
+
if (frame.error) throw new Error(frame.error.message || 'JSON-RPC error');
|
|
160
|
+
return frame.result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
notify() { /* HTTP transport has no notification channel */ }
|
|
164
|
+
close() { /* nothing to close */ }
|
|
165
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Model Context Protocol client.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
//
|
|
5
|
+
// `src/tools/deferredMcpSearch.js` carries "mcp" in its name and is not an MCP
|
|
6
|
+
// client — it searches Causyne's own tool registry. The only code in the tree
|
|
7
|
+
// that spoke JSON-RPC was `palmierAutomator.js`, against one hardcoded server.
|
|
8
|
+
// So the single largest extensibility lever every competing agent has was
|
|
9
|
+
// missing, while the name suggested it was present.
|
|
10
|
+
//
|
|
11
|
+
// SECURITY POSTURE — read before changing anything here
|
|
12
|
+
//
|
|
13
|
+
// A client that launches third-party servers is the highest-risk pattern in
|
|
14
|
+
// this codebase, so the boundary is drawn in exactly one place and stated
|
|
15
|
+
// plainly:
|
|
16
|
+
//
|
|
17
|
+
// * A server command NEVER comes from model output. Servers are read from
|
|
18
|
+
// `config.mcpServers`, which only the user writes. The model can ask for a
|
|
19
|
+
// configured server to be enabled; it cannot name a new one, cannot alter a
|
|
20
|
+
// command, and cannot pass arguments to a process launch.
|
|
21
|
+
// * Enabling a server requires the same human approval as any other
|
|
22
|
+
// consent-gated action (`sandbox.js` classifies `grant_mcp_server` with the
|
|
23
|
+
// download actions).
|
|
24
|
+
// * What the model DOES control is tool arguments, which is the entire point
|
|
25
|
+
// of the protocol — the same trust boundary as any other tool call.
|
|
26
|
+
//
|
|
27
|
+
// If a future change lets a server command originate anywhere other than user
|
|
28
|
+
// config, that is a remote-code-execution hole, not a feature.
|
|
29
|
+
|
|
30
|
+
import { StdioChannel, HttpChannel } from './jsonRpc.js';
|
|
31
|
+
import { getPackageVersion } from '../version.js';
|
|
32
|
+
|
|
33
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
34
|
+
const CLIENT_INFO = { name: 'causyne-agent', version: getPackageVersion() };
|
|
35
|
+
|
|
36
|
+
export class McpClient {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} definition a user-configured server entry:
|
|
39
|
+
* stdio: { name, command, args?, env?, cwd? }
|
|
40
|
+
* http: { name, url, headers? }
|
|
41
|
+
*/
|
|
42
|
+
constructor(definition) {
|
|
43
|
+
this.name = definition.name;
|
|
44
|
+
this.definition = definition;
|
|
45
|
+
this.channel = null;
|
|
46
|
+
this.tools = [];
|
|
47
|
+
this.serverInfo = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static validateDefinition(definition) {
|
|
51
|
+
if (!definition || typeof definition !== 'object') return 'not an object';
|
|
52
|
+
if (typeof definition.name !== 'string' || !/^[a-z0-9_-]{1,48}$/i.test(definition.name)) {
|
|
53
|
+
return 'name must be 1-48 characters of [A-Za-z0-9_-]';
|
|
54
|
+
}
|
|
55
|
+
const hasStdio = typeof definition.command === 'string' && definition.command.length > 0;
|
|
56
|
+
const hasHttp = typeof definition.url === 'string' && definition.url.length > 0;
|
|
57
|
+
if (hasStdio === hasHttp) return 'exactly one of `command` (stdio) or `url` (http) is required';
|
|
58
|
+
if (hasStdio && !Array.isArray(definition.args || [])) return '`args` must be an array';
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async connect() {
|
|
63
|
+
const problem = McpClient.validateDefinition(this.definition);
|
|
64
|
+
if (problem) throw new Error(`invalid server definition: ${problem}`);
|
|
65
|
+
|
|
66
|
+
this.channel = this.definition.url
|
|
67
|
+
? new HttpChannel({ url: this.definition.url, headers: this.definition.headers })
|
|
68
|
+
: new StdioChannel({
|
|
69
|
+
command: this.definition.command,
|
|
70
|
+
args: this.definition.args || [],
|
|
71
|
+
env: this.definition.env || {},
|
|
72
|
+
cwd: this.definition.cwd
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await this.channel.open();
|
|
76
|
+
|
|
77
|
+
const result = await this.channel.send('initialize', {
|
|
78
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
79
|
+
capabilities: { tools: {} },
|
|
80
|
+
clientInfo: CLIENT_INFO
|
|
81
|
+
});
|
|
82
|
+
this.serverInfo = result?.serverInfo || null;
|
|
83
|
+
|
|
84
|
+
// Required by the protocol: the server may not serve requests until it has
|
|
85
|
+
// seen this.
|
|
86
|
+
this.channel.notify('notifications/initialized');
|
|
87
|
+
|
|
88
|
+
await this.listTools();
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listTools() {
|
|
93
|
+
const result = await this.channel.send('tools/list', {});
|
|
94
|
+
const raw = Array.isArray(result?.tools) ? result.tools : [];
|
|
95
|
+
|
|
96
|
+
// Only keep entries that are actually callable. A malformed advertisement
|
|
97
|
+
// reaching the model produces a tool it can call and we cannot dispatch —
|
|
98
|
+
// the exact failure mode D-6 was about, arriving from someone else's code.
|
|
99
|
+
this.tools = raw
|
|
100
|
+
.filter(t => t && typeof t.name === 'string' && t.name.length > 0)
|
|
101
|
+
.map(t => ({
|
|
102
|
+
name: t.name,
|
|
103
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
104
|
+
inputSchema: (t.inputSchema && typeof t.inputSchema === 'object')
|
|
105
|
+
? t.inputSchema
|
|
106
|
+
: { type: 'object', properties: {} }
|
|
107
|
+
}));
|
|
108
|
+
return this.tools;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async callTool(toolName, args) {
|
|
112
|
+
const result = await this.channel.send('tools/call', { name: toolName, arguments: args ?? {} });
|
|
113
|
+
|
|
114
|
+
// MCP returns content blocks. Flatten text for the agent loop, and report an
|
|
115
|
+
// error result as an error rather than as successful output.
|
|
116
|
+
const blocks = Array.isArray(result?.content) ? result.content : [];
|
|
117
|
+
const text = blocks
|
|
118
|
+
.map(b => (b?.type === 'text' && typeof b.text === 'string') ? b.text : '')
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join('\n');
|
|
121
|
+
|
|
122
|
+
if (result?.isError) {
|
|
123
|
+
return { success: false, error: text || 'the MCP server reported an error' };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
output: text || JSON.stringify(result ?? {}, null, 2)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
close() {
|
|
132
|
+
try { if (this.channel) this.channel.close(); } catch (e) {}
|
|
133
|
+
this.channel = null;
|
|
134
|
+
this.tools = [];
|
|
135
|
+
}
|
|
136
|
+
}
|